diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6063c20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.charter/ diff --git a/package-lock.json b/package-lock.json index 9a6b3aa..bda55c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@stackbilt/core": "^1.0.0", - "@stackbilt/scaffold-core": "^1.1.0" + "@stackbilt/scaffold-core": "^1.2.0" }, "bin": { "stackbilt": "dist/cli.js" @@ -824,9 +824,9 @@ } }, "node_modules/@stackbilt/scaffold-core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@stackbilt/scaffold-core/-/scaffold-core-1.1.0.tgz", - "integrity": "sha512-/5K9A8zz0FzOl0zcEguXDCGyEM+4+9pRhCJUzRwMYWZUvT4otQuWUsPzreWT4cJdZFLTATmeJK2w9V3ik6JhHA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@stackbilt/scaffold-core/-/scaffold-core-1.2.0.tgz", + "integrity": "sha512-A9RbD+cW/gzPjM65EWCMBwnrShXENoxiWPpI3/Yvzvc+hnY0CgbgVYKY3D4P71tdsklDFHVJnWCzMku0y9trBw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index afb42bf..c5074e2 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ }, "dependencies": { "@stackbilt/core": "^1.0.0", - "@stackbilt/scaffold-core": "^1.1.0" + "@stackbilt/scaffold-core": "^1.2.0" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/src/__tests__/auth-wiring.test.ts b/src/__tests__/auth-wiring.test.ts index e7bf8f7..3f69f95 100644 --- a/src/__tests__/auth-wiring.test.ts +++ b/src/__tests__/auth-wiring.test.ts @@ -123,29 +123,30 @@ describe('architect — governance-only (no network)', () => { }); }); -describe('run — gateway vs engine routing', () => { - it('uses the gateway (scaffold) when the env var provides an API key', async () => { +describe('run — offline-first with explicit --gateway opt-in', () => { + it('uses local buildScaffold() by default — no gateway call even with API key', async () => { mockedResolveApiKey.mockReturnValue({ apiKey: 'ea_env_gateway', source: 'env' }); await runCommand(options, ['a description', '--dry-run']); - expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1); + // No EngineClient methods called — local path is zero-network + expect(hoisted.scaffoldFn).not.toHaveBeenCalled(); expect(hoisted.buildFn).not.toHaveBeenCalled(); }); - it('falls back to engine /build when no API key is resolved', async () => { + it('uses local buildScaffold() with no API key — no credentials required', async () => { mockedResolveApiKey.mockReturnValue(null); await runCommand(options, ['a description', '--dry-run']); - expect(hoisted.buildFn).toHaveBeenCalledTimes(1); expect(hoisted.scaffoldFn).not.toHaveBeenCalled(); + expect(hoisted.buildFn).not.toHaveBeenCalled(); }); - it('uses the gateway when login-stored credentials are resolved (parity with env path)', async () => { + it('uses the gateway scaffold when --gateway flag is passed with an API key', async () => { mockedResolveApiKey.mockReturnValue({ apiKey: 'sb_live_stored', source: 'credentials' }); - await runCommand(options, ['a description', '--dry-run']); + await runCommand(options, ['a description', '--dry-run', '--gateway']); expect(hoisted.scaffoldFn).toHaveBeenCalledTimes(1); expect(hoisted.buildFn).not.toHaveBeenCalled(); diff --git a/src/cli.ts b/src/cli.ts index 67e2d51..9e46025 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -101,21 +101,27 @@ Examples: Usage: stackbilt run [options] Run the full scaffold pipeline: classify, build, and write project files. +Default: fully offline using @stackbilt/scaffold-core (zero network, no API key required). Options: --file Read description from a file instead of inline argument --output Output directory (default: ./) - --seed Deterministic seed for stack selection - --url Override engine base URL - --framework Constrain framework selection - --database Constrain database selection - --cloudflare-only Only consider Cloudflare-native primitives + --gateway Use the Stackbilt gateway (requires API key, TarotScript tier-2) + --persist Save scaffold to your platform account (requires API key) + --oracle Request LLM polish on the persisted scaffold (requires --persist) + --seed Deterministic seed (gateway only) + --url Override engine base URL (gateway only) + --framework Constrain framework selection (gateway only) + --database Constrain database selection (gateway only) + --cloudflare-only Only consider Cloudflare-native primitives (gateway only) --dry-run Show what would be written, without writing files --format json Emit scaffold result as JSON Examples: stackbilt run "real-time chat app with Durable Objects" + stackbilt run "multi-tenant SaaS API" --persist --oracle stackbilt run --file spec.md --output ./my-project + stackbilt run "webhook handler" --gateway `.trimStart(), scaffold: ` diff --git a/src/commands/run.ts b/src/commands/run.ts index 2cc3a8a..7cbd257 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -7,6 +7,8 @@ import { resolveApiKey } from '../credentials.js'; import { EngineClient, type BuildRequest, type ScaffoldResult } from '../http-client.js'; import { buildScaffold } from '@stackbilt/scaffold-core'; +const PLATFORM_BASE_URL = process.env.STACKBILT_URL ?? 'https://stackbilder.com'; + // Write the unified cache contract so `stackbilt scaffold` can use it. // Shape: { intention, pattern, classification, governance, files?, createdAt } function writeCachedBuild( @@ -30,6 +32,62 @@ function writeCachedBuild( ); } +// Adapt LocalScaffoldResult to the ScaffoldResult shape used by the rest of the command. +function localScaffoldToResult(intention: string): ScaffoldResult { + const core = buildScaffold(intention); + const roleMap: Record = { + entry: 'scaffold', + config: 'config', + test: 'test', + migration: 'scaffold', + contract: 'governance', + adf: 'governance', + readme: 'doc', + }; + return { + files: core.files.map(f => ({ + path: f.path, + content: f.content, + role: roleMap[f.role] ?? 'scaffold', + })), + fileSource: 'basic', + nextSteps: [ + 'npm install', + 'npx wrangler dev', + `Pattern: ${core.classification.pattern} (confidence: ${Math.round(core.classification.confidence * 100)}%)`, + ], + seed: undefined, + facts: core.facts as unknown as Record, + }; +} + +// POST the scaffold result to the platform /api/flows for platform record. +async function persistToPlatform( + intention: string, + oracle: boolean, + apiKey: string, +): Promise<{ id: string } | null> { + try { + const res = await fetch(`${PLATFORM_BASE_URL}/api/flows`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ intention, oracle }), + }); + if (!res.ok) { + const text = await res.text(); + console.error(` [warn] Persist failed (${res.status}): ${text}`); + return null; + } + return res.json() as Promise<{ id: string }>; + } catch (err) { + console.error(` [warn] Persist error: ${err}`); + return null; + } +} + const PHASE_LABELS = ['PRODUCT', 'UX', 'RISK', 'ARCHITECT', 'TDD', 'SPRINT']; const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -98,19 +156,27 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise; if (useGateway) { + const client = new EngineClient({ + baseUrl: urlOverride ?? resolved?.baseUrl, + apiKey: resolved?.apiKey ?? null, + }); scaffoldPromise = client.scaffold({ description, project_type: args.includes('--cloudflare-only') ? 'worker' : undefined, @@ -118,30 +184,22 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise { - return { - files: Object.entries(r.scaffold).map(([p, content]) => ({ path: p, content, role: 'scaffold' as const })), - fileSource: 'engine' as const, - nextSteps: ['npm install', 'npm run dev'], - seed: r.seed, - receipt: r.receipt, - }; - }); + // Default: fully offline, zero network + scaffoldPromise = Promise.resolve(localScaffoldToResult(description)); } if (options.format === 'json') { const result = await scaffoldPromise; - console.log(JSON.stringify({ ...result, outputDir: resolvedOutput, dryRun }, null, 2)); + const output: Record = { ...result, outputDir: resolvedOutput, dryRun }; if (!dryRun) { writeFiles(resolvedOutput, result.files); writeCachedBuild(description, result.files.map(({ path: p, content }) => ({ path: p, content })), options.configPath); } + if (persist && resolved?.apiKey) { + const persisted = await persistToPlatform(description, oracle, resolved.apiKey); + if (persisted) output.flowId = persisted.id; + } + console.log(JSON.stringify(output, null, 2)); return EXIT_CODE.SUCCESS; } @@ -149,7 +207,7 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise