diff --git a/package-lock.json b/package-lock.json index e56ac52..9a6b3aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "@stackbilt/build", "version": "0.1.0", "license": "Apache-2.0", + "dependencies": { + "@stackbilt/core": "^1.0.0", + "@stackbilt/scaffold-core": "^1.1.0" + }, "bin": { "stackbilt": "dist/cli.js" }, @@ -807,6 +811,27 @@ "win32" ] }, + "node_modules/@stackbilt/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@stackbilt/core/-/core-1.0.0.tgz", + "integrity": "sha512-PoDqnHolmwZJBqHJharBqa5HceHajBaZlJ6DDXMHgXeG1LCvhpi8Ih9MI0juxOVrFZqyqx1Ce2b0AZ0+ZqXhxw==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1502,6 +1527,15 @@ "engines": { "node": ">=8" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index ae38c93..afb42bf 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,10 @@ "build": "tsc -p tsconfig.json", "test": "vitest run" }, - "dependencies": {}, + "dependencies": { + "@stackbilt/core": "^1.0.0", + "@stackbilt/scaffold-core": "^1.1.0" + }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0", diff --git a/src/__tests__/auth-wiring.test.ts b/src/__tests__/auth-wiring.test.ts index 3f856ad..e7bf8f7 100644 --- a/src/__tests__/auth-wiring.test.ts +++ b/src/__tests__/auth-wiring.test.ts @@ -93,27 +93,33 @@ afterEach(() => { fs.rmSync(tmpCwd, { recursive: true, force: true }); }); -describe('architect — auth wiring', () => { - it('forwards the env-sourced API key (and custom baseUrl) to EngineClient', async () => { +describe('architect — governance-only (no network)', () => { + // architect was rewritten in Issue #5 to be zero-network: it runs classify + // heuristics locally and emits governance docs as markdown/JSON. + // EngineClient is no longer instantiated by this command. + + it('returns SUCCESS and emits JSON governance docs without any network call', async () => { mockedResolveApiKey.mockReturnValue({ apiKey: 'ea_env_wiring', source: 'env', baseUrl: 'https://engine.example', }); - await architectCommand(options, ['a simple project description']); + const code = await architectCommand(options, ['multi-tenant SaaS API with Stripe billing']); - expect(hoisted.constructorArgs).toHaveLength(1); - expect(hoisted.constructorArgs[0].apiKey).toBe('ea_env_wiring'); - expect(hoisted.constructorArgs[0].baseUrl).toBe('https://engine.example'); + expect(code).toBe(0); + // EngineClient must NOT have been instantiated — architect is pure heuristic + expect(hoisted.constructorArgs).toHaveLength(0); }); - it('passes apiKey=null to EngineClient when resolveApiKey returns null', async () => { + it('returns SUCCESS with no API key — governance docs need no auth', async () => { mockedResolveApiKey.mockReturnValue(null); - await architectCommand(options, ['unauthenticated fallback']); + const code = await architectCommand(options, ['GitHub webhook handler']); - expect(hoisted.constructorArgs[0].apiKey).toBeNull(); + expect(code).toBe(0); + expect(hoisted.constructorArgs).toHaveLength(0); + expect(hoisted.buildFn).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/classify.test.ts b/src/__tests__/classify.test.ts new file mode 100644 index 0000000..c4ab992 --- /dev/null +++ b/src/__tests__/classify.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { classifyScaffoldIntention } from '../commands/classify.js'; + +describe('classifyScaffoldIntention', () => { + it('returns a pattern for a workers-saas intention', () => { + const r = classifyScaffoldIntention('multi-tenant SaaS API with Stripe billing'); + expect(r.pattern).toBeTruthy(); + expect(typeof r.pattern).toBe('string'); + }); + + it('returns mcp-server pattern for MCP server intentions', () => { + const r = classifyScaffoldIntention('MCP server exposing tool endpoints for LLM agents'); + expect(r.pattern).toBe('mcp-server'); + }); + + it('returns a valid pattern for queue intentions', () => { + // charter#221: package may classify queue-consumer as 'api' until pattern vocab is aligned + const r = classifyScaffoldIntention('Queue consumer for background job processing'); + expect(typeof r.pattern).toBe('string'); + expect(r.pattern.length).toBeGreaterThan(0); + }); + + it('returns scheduled pattern for cron worker intentions', () => { + const r = classifyScaffoldIntention('Scheduled cron worker for daily digest emails'); + expect(r.pattern).toBe('scheduled'); + }); + + it('returns a numeric confidence between 0 and 1', () => { + const r = classifyScaffoldIntention('REST API with D1 database'); + expect(typeof r.confidence).toBe('number'); + expect(r.confidence).toBeGreaterThan(0); + expect(r.confidence).toBeLessThanOrEqual(1); + }); + + it('returns lower confidence for a bare single-word intention', () => { + const full = classifyScaffoldIntention('multi-tenant SaaS API with Stripe billing and JWT auth'); + const bare = classifyScaffoldIntention('api'); + expect(bare.confidence).toBeLessThan(full.confidence); + }); + + it('traits is a flat string array', () => { + const r = classifyScaffoldIntention('REST API with JWT authentication and D1 database'); + expect(Array.isArray(r.traits)).toBe(true); + }); + + it('detects authentication in quality profile', () => { + const r = classifyScaffoldIntention('REST API with JWT authentication'); + expect(r.qualityProfile.authentication).toBe(true); + }); + + it('detects durable-object pattern for collaborative/realtime intentions', () => { + const r = classifyScaffoldIntention('Realtime collaborative whiteboard with WebSockets and Durable Objects'); + expect(r.pattern).toBe('durable-object'); + }); + + it('qualityProfile has expected boolean fields', () => { + const r = classifyScaffoldIntention('REST API with rate limiting and observability'); + expect(typeof r.qualityProfile.authentication).toBe('boolean'); + expect(typeof r.qualityProfile.rateLimiting).toBe('boolean'); + expect(typeof r.qualityProfile.observability).toBe('boolean'); + expect(typeof r.qualityProfile.piiHandling).toBe('boolean'); + expect(Array.isArray(r.qualityProfile.complianceDomains)).toBe(true); + }); + + it('produces json-serialisable output', () => { + const r = classifyScaffoldIntention('Workers API with D1 and JWT auth'); + const json = JSON.parse(JSON.stringify(r)); + expect(typeof json.pattern).toBe('string'); + expect(typeof json.confidence).toBe('number'); + expect(Array.isArray(json.traits)).toBe(true); + expect(json.qualityProfile).toBeTruthy(); + }); +}); diff --git a/src/__tests__/scaffold-cache.test.ts b/src/__tests__/scaffold-cache.test.ts new file mode 100644 index 0000000..66ec010 --- /dev/null +++ b/src/__tests__/scaffold-cache.test.ts @@ -0,0 +1,179 @@ +/** + * Tests for scaffold/run/architect cache contract (#6, #7) + * + * #6: run now always writes last-build.json (gateway + engine paths) + * #7: unified cache shape — { intention, pattern, classification, governance, files?, createdAt } + * scaffold.ts reads both the new shape and legacy BuildResult shape. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +// The cache path logic must be consistent between run and scaffold. +// Both use: path.join(options.configPath || '.charter', 'last-build.json') +// We verify this contract directly via the file system. + +function makeBuildResult(overrides: Record = {}) { + return { + stack: [ + { + id: 1, + name: 'Hono', + category: 'framework', + element: 'fire', + maturity: 'stable', + tier: 'blessed', + cloudflareNative: true, + traits: ['edge'], + keywords: { upright: ['fast'], reversed: ['fragile'] }, + orientation: 'upright', + position: 'Present', + }, + ], + compatibility: { + pairs: [], + totalScore: 0, + normalizedScore: 0, + dominant: 'fire', + tensions: [], + }, + scaffold: { + 'src/index.ts': 'export default {}', + 'wrangler.toml': 'name = "my-worker"', + }, + seed: 42, + receipt: 'abc123', + requirements: { + description: 'test intention', + keywords: ['test'], + constraints: {}, + complexity: 'simple', + }, + ...overrides, + }; +} + +describe('scaffold/run cache contract', () => { + let tmpDir: string; + let cacheDir: string; + let cachePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackbilt-test-')); + cacheDir = path.join(tmpDir, '.charter'); + cachePath = path.join(cacheDir, 'last-build.json'); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('cache file is absent when nothing has run', () => { + expect(fs.existsSync(cachePath)).toBe(false); + }); + + it('a BuildResult written to cache can be parsed back by scaffold logic', () => { + const buildResult = makeBuildResult(); + + // Simulate what run.ts does (cacheBuildResult function): + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(buildResult, null, 2)); + + // Simulate what scaffold.ts does (read + validate): + expect(fs.existsSync(cachePath)).toBe(true); + const raw = fs.readFileSync(cachePath, 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed.scaffold).toBeDefined(); + expect(Object.keys(parsed.scaffold).length).toBeGreaterThan(0); + expect(parsed.seed).toBe(42); + expect(parsed.stack).toHaveLength(1); + }); + + it('scaffold content round-trips through JSON without loss', () => { + const buildResult = makeBuildResult({ + scaffold: { + 'src/index.ts': 'export default { fetch(req) { return new Response("ok"); } }', + 'wrangler.toml': '[vars]\nNAME = "test"', + }, + }); + + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(buildResult, null, 2)); + + const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + expect(parsed.scaffold['src/index.ts']).toContain('export default'); + expect(parsed.scaffold['wrangler.toml']).toContain('[vars]'); + }); + + it('both scaffold.ts and run.ts use path.join(configPath, "last-build.json")', () => { + // Verifies the path contract by checking both commands read/write the same key. + // This is a contract test — if either command changes the path, this breaks. + const configPath = cacheDir; + const expectedPath = path.join(configPath, 'last-build.json'); + + fs.mkdirSync(configPath, { recursive: true }); + fs.writeFileSync(expectedPath, JSON.stringify(makeBuildResult(), null, 2)); + + // The file is readable at the expected path + const data = JSON.parse(fs.readFileSync(expectedPath, 'utf-8')); + expect(data.scaffold).toBeDefined(); + }); + + it('cache dir is created if absent (mkdir -p behaviour)', () => { + // The nested dir does not exist yet + const deepCacheDir = path.join(tmpDir, 'nested', 'deep', '.charter'); + const deepCachePath = path.join(deepCacheDir, 'last-build.json'); + + expect(fs.existsSync(deepCacheDir)).toBe(false); + + // Simulate writeCachedBuild mkdir behaviour + fs.mkdirSync(deepCacheDir, { recursive: true }); + fs.writeFileSync(deepCachePath, JSON.stringify(makeBuildResult(), null, 2)); + + expect(fs.existsSync(deepCachePath)).toBe(true); + }); + + it('new unified cache shape is parseable and has expected fields', () => { + const unified = { + intention: 'multi-tenant SaaS API with Stripe billing', + pattern: 'api', + classification: { pattern: 'api', confidence: 0.9, traits: ['multi-tenant'], qualityProfile: { testingLevel: 'standard', observability: true, authentication: true, rateLimiting: false, piiHandling: false, complianceDomains: [] }, enrichedIntention: 'multi-tenant SaaS API with Stripe billing' }, + governance: { threatModel: '# Threat Model', adr001: '# ADR-001', testPlan: '# Test Plan' }, + files: [{ path: 'src/index.ts', content: 'export default {}' }], + createdAt: '2026-06-12T00:00:00.000Z', + }; + + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(unified, null, 2)); + + const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + expect(parsed.intention).toBe('multi-tenant SaaS API with Stripe billing'); + expect(parsed.pattern).toBe('api'); + expect(parsed.classification.pattern).toBe('api'); + expect(typeof parsed.classification.confidence).toBe('number'); + expect(parsed.files).toHaveLength(1); + expect(parsed.files[0].path).toBe('src/index.ts'); + expect(parsed.governance.threatModel).toBeDefined(); + }); + + it('architect cache (no files field) is distinguishable from run cache', () => { + const architectCache = { + intention: 'REST API with JWT auth', + pattern: 'api', + classification: { pattern: 'api', confidence: 0.85, traits: ['auth'], qualityProfile: { testingLevel: 'standard', observability: false, authentication: true, rateLimiting: false, piiHandling: false, complianceDomains: [] }, enrichedIntention: 'REST API with JWT auth' }, + governance: { threatModel: '# Threat Model', adr001: '# ADR-001', testPlan: '# Test Plan' }, + createdAt: '2026-06-12T00:00:00.000Z', + // no `files` field — this is what architect writes + }; + + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(cachePath, JSON.stringify(architectCache, null, 2)); + + const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + expect(parsed.files).toBeUndefined(); + expect(parsed.intention).toBeDefined(); + expect(parsed.governance).toBeDefined(); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index a33d6eb..67e2d51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,24 +1,216 @@ #!/usr/bin/env node +import { createRequire } from 'node:module'; import { CLIError, EXIT_CODE } from './index.js'; import { loginCommand } from './commands/login.js'; import { architectCommand } from './commands/architect.js'; +import { classifyCommand } from './commands/classify.js'; import { runCommand } from './commands/run.js'; import { scaffoldCommand } from './commands/scaffold.js'; -const [,, cmd, ...args] = process.argv; -const options = { configPath: '.charter', format: 'text' as const, ciMode: false, yes: false }; +// ============================================================================ +// Version (read from package.json at runtime — single source of truth) +// ============================================================================ + +function getVersion(): string { + try { + const require = createRequire(import.meta.url); + // Resolve relative to the package root (dist/../package.json) + const pkg = require('../package.json') as { version?: string }; + return pkg.version ?? '0.0.0'; + } catch { + return '0.0.0'; + } +} + +// ============================================================================ +// Help text +// ============================================================================ + +const HELP = ` +Usage: stackbilt [options] + +Commands: + login Authenticate with the Stackbilt engine + architect Generate governance docs (threat model + ADRs) for an intention + classify Classify an intention into a scaffold pattern + run Full scaffold pipeline — classify, build, write files + scaffold Write files from a cached build result + +Global options: + --format json Emit structured JSON output instead of human-readable text + --version Print the CLI version and exit + --help Show this help message + +Command help: + stackbilt login --help + stackbilt architect --help + stackbilt classify --help + stackbilt run --help + stackbilt scaffold --help +`.trimStart(); + +const COMMAND_HELP: Record = { + login: ` +Usage: stackbilt login [options] + +Authenticate with the Stackbilt engine. + +Options: + --key API key (ea_xxx, sb_live_xxx, or sb_test_xxx) + --url Override engine base URL + --logout Clear stored credentials + --format json Emit JSON output + +Examples: + stackbilt login --key sb_live_abc123 + stackbilt login --logout +`.trimStart(), + + architect: ` +Usage: stackbilt architect [options] + +Generate governance documents for a project intention. +Produces a threat model and ADR-001 (always), plus ADR-002 if compliance +domains (PCI, GDPR, HIPAA, SOC 2) are detected. + +No network calls — pure heuristic, <1ms. + +Options: + --format json Emit { threatModel, adr001, adr002, testPlan } as JSON + +Examples: + stackbilt architect "multi-tenant SaaS API with Stripe billing" + stackbilt architect "GitHub webhook handler" --format json +`.trimStart(), + + classify: ` +Usage: stackbilt classify [options] + +Classify a plain-text intention into a scaffold pattern. +No network calls — pure heuristic, <1ms. + +Options: + --format json Emit classification result as JSON + +Examples: + stackbilt classify "Discord bot with slash commands" + stackbilt classify "Scheduled cron worker for daily digests" --format json +`.trimStart(), + + run: ` +Usage: stackbilt run [options] + +Run the full scaffold pipeline: classify, build, and write project files. + +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 + --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 --file spec.md --output ./my-project +`.trimStart(), + + scaffold: ` +Usage: stackbilt scaffold [options] + +Write files from a cached build result (produced by \`stackbilt run\`). + +Options: + --output Output directory (default: .) + --dry-run List files that would be written without writing them + --format json Emit file manifest as JSON + +Examples: + stackbilt scaffold + stackbilt scaffold --output ./projects/my-app --dry-run +`.trimStart(), +}; + +// ============================================================================ +// Global flag parsing +// ============================================================================ + +function parseGlobalFlags(rawArgs: string[]): { cmd: string | undefined; args: string[]; format: 'text' | 'json'; showHelp: boolean; showVersion: boolean } { + const showVersion = rawArgs.includes('--version') || rawArgs.includes('-v'); + // --help at top level (no command) or as the only arg + const showHelp = rawArgs.length === 0 || rawArgs[0] === '--help' || rawArgs[0] === '-h'; + + const [firstArg, ...rest] = rawArgs; + const isCmd = firstArg && !firstArg.startsWith('-'); + + const cmd = isCmd ? firstArg : undefined; + const args = isCmd ? rest : rawArgs; + + const format = args.includes('--format') && args[args.indexOf('--format') + 1] === 'json' + ? 'json' + : 'text'; + + return { cmd, args, format, showHelp, showVersion }; +} + +// ============================================================================ +// Entry point +// ============================================================================ async function run() { + const rawArgs = process.argv.slice(2); + const { cmd, args, format, showHelp, showVersion } = parseGlobalFlags(rawArgs); + + if (showVersion) { + console.log(getVersion()); + process.exit(EXIT_CODE.SUCCESS); + } + + if (showHelp && !cmd) { + process.stdout.write(HELP); + process.exit(EXIT_CODE.SUCCESS); + } + + // Per-command --help + if (cmd && (args.includes('--help') || args.includes('-h'))) { + const helpText = COMMAND_HELP[cmd] ?? `No help available for '${cmd}'.\n`; + process.stdout.write(helpText); + process.exit(EXIT_CODE.SUCCESS); + } + + const options = { + configPath: '.charter', + format: format, + ciMode: args.includes('--ci'), + yes: args.includes('--yes') || args.includes('-y'), + }; + try { let code: number = EXIT_CODE.SUCCESS; + if (cmd === 'login') code = await loginCommand(options, args); else if (cmd === 'architect') code = await architectCommand(options, args); + else if (cmd === 'classify') code = await classifyCommand(options, args); else if (cmd === 'run') code = await runCommand(options, args); else if (cmd === 'scaffold') code = await scaffoldCommand(options, args); - else { console.error(`Unknown command: ${cmd}`); code = EXIT_CODE.FAILURE; } + else if (!cmd) { + // No command and no --help/--version: show help + process.stdout.write(HELP); + code = EXIT_CODE.SUCCESS; + } else { + console.error(`Unknown command: ${cmd}\nRun \`stackbilt --help\` for usage.`); + code = EXIT_CODE.FAILURE; + } + process.exit(code); } catch (e) { - if (e instanceof CLIError) { console.error(e.message); process.exit(EXIT_CODE.FAILURE); } + if (e instanceof CLIError) { + console.error(e.message); + process.exit(EXIT_CODE.FAILURE); + } throw e; } } diff --git a/src/commands/architect.ts b/src/commands/architect.ts index a2940de..ece2526 100644 --- a/src/commands/architect.ts +++ b/src/commands/architect.ts @@ -1,125 +1,64 @@ +/** + * stackbilt architect + * + * Zero-network governance document generator. Runs classify → knowledge → governance + * via @stackbilt/scaffold-core and emits: threat model, ADR-001, ADR-002 (if compliance + * domains detected), and test plan. + * + * Flags: + * --format json emit { threatModel, adr001, adr002, testPlan } as JSON + */ + import * as fs from 'node:fs'; import * as path from 'node:path'; +import { sanitizeInput } from '@stackbilt/core'; +import { buildScaffold } from '@stackbilt/scaffold-core'; import type { CLIOptions } from '../index.js'; import { EXIT_CODE, CLIError } from '../index.js'; -import { getFlag } from '../flags.js'; -import { resolveApiKey } from '../credentials.js'; -import { EngineClient, type BuildRequest, type BuildResult } from '../http-client.js'; export async function architectCommand(options: CLIOptions, args: string[]): Promise { - const filePath = getFlag(args, '--file'); - const positional = args.filter(a => !a.startsWith('-') && a !== filePath); - let description: string; - - if (filePath) { - if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); - description = fs.readFileSync(filePath, 'utf-8').trim(); - } else if (positional.length > 0) { - description = positional.join(' '); - } else { - throw new CLIError('Provide a project description:\n stackbilt architect "Build a real-time chat app"\n stackbilt architect --file spec.md'); - } - - if (!description) throw new CLIError('Empty description.'); - - const request: BuildRequest = { description, constraints: {} }; - if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true; - const fw = getFlag(args, '--framework'); - if (fw) request.constraints!.framework = fw; - const db = getFlag(args, '--database'); - if (db) request.constraints!.database = db; - - const seedStr = getFlag(args, '--seed'); - if (seedStr) request.seed = parseInt(seedStr, 10); + const positional = args.filter(a => !a.startsWith('-')); + const intention = positional.join(' ').trim(); - const resolved = resolveApiKey(); - const baseUrl = getFlag(args, '--url'); - const client = new EngineClient({ - baseUrl: baseUrl ?? resolved?.baseUrl, - apiKey: resolved?.apiKey ?? null, - }); - - let result: BuildResult; - try { - result = await client.build(request); - } catch (err) { - throw new CLIError(`Build failed: ${(err as Error).message}`); + if (!intention) { + throw new CLIError( + 'Provide an intention:\n stackbilt architect "multi-tenant SaaS API with Stripe billing"', + ); } - const dryRun = args.includes('--dry-run'); + const sanitized = sanitizeInput(intention); + const result = buildScaffold(sanitized); + const { governance: docs } = result; + + // Write unified cache so `stackbilt scaffold` can generate files without re-running. + const cacheDir = options.configPath || '.charter'; + if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync(path.join(cacheDir, 'last-build.json'), JSON.stringify({ + intention: sanitized, + pattern: result.classification.pattern, + classification: result.classification, + governance: result.governance, + createdAt: new Date().toISOString(), + }, null, 2)); if (options.format === 'json') { - console.log(JSON.stringify(result, null, 2)); - if (!dryRun) cacheResult(result, options.configPath); + console.log(JSON.stringify({ + threatModel: docs.threatModel, + adr001: docs.adr001, + adr002: docs.adr002 ?? null, + testPlan: docs.testPlan, + }, null, 2)); return EXIT_CODE.SUCCESS; } - printResult(result); + console.log(docs.threatModel); + console.log('\n---\n'); + console.log(docs.adr001); - if (!dryRun) { - cacheResult(result, options.configPath); - console.log(''); - console.log(`Build cached. Run \`stackbilt scaffold\` to write files.`); - } else { - console.log(''); - console.log('(dry run — no files written)'); + if (docs.adr002) { + console.log('\n---\n'); + console.log(docs.adr002); } return EXIT_CODE.SUCCESS; } - -function printResult(r: BuildResult): void { - const c = r.compatibility; - - console.log(''); - console.log(` Stack (seed: ${r.seed}, ${r.requirements.complexity})`); - console.log(''); - - const maxPos = Math.max(...r.stack.map(s => s.position.length)); - const maxName = Math.max(...r.stack.map(s => s.name.length)); - for (const s of r.stack) { - const pos = s.position.padEnd(maxPos); - const name = s.name.padEnd(maxName); - const orient = s.orientation === 'reversed' ? '↓' : '↑'; - const cf = s.cloudflareNative ? ' [CF]' : ''; - console.log(` ${pos} ${name} (${s.element}, ${orient})${cf}`); - } - - console.log(''); - console.log(` Compatibility: ${c.normalizedScore} (${c.pairs.length} pairs, ${c.tensions.length} tensions)`); - - for (const p of c.pairs) { - const sign = p.score > 0 ? '+' : p.score < 0 ? '' : ' '; - console.log(` ${p.techs[0]} + ${p.techs[1]} = ${p.relationship} (${sign}${p.score})`); - } - - if (c.tensions.length > 0) { - console.log(''); - console.log(' Tensions:'); - for (const t of c.tensions) { - console.log(` ⚡ ${t.description}`); - } - } - - console.log(''); - console.log(` Scaffold: ${Object.keys(r.scaffold).length} files`); - for (const f of Object.keys(r.scaffold).sort()) { - const lines = r.scaffold[f].split('\n').length; - console.log(` ${f} (${lines} lines)`); - } - - console.log(''); - console.log(` Keywords: ${r.requirements.keywords.slice(0, 8).join(', ')}`); - console.log(` Receipt: ${r.receipt.slice(0, 16)}`); -} - -function cacheResult(result: BuildResult, configPath: string): void { - const dir = configPath || '.charter'; - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync( - path.join(dir, 'last-build.json'), - JSON.stringify(result, null, 2), - ); -} diff --git a/src/commands/classify.ts b/src/commands/classify.ts new file mode 100644 index 0000000..22e2962 --- /dev/null +++ b/src/commands/classify.ts @@ -0,0 +1,58 @@ +/** + * stackbilt classify + * + * Zero-network, zero-inference intent classification. Pure heuristic, <1ms. + * Delegates to @stackbilt/scaffold-core for the canonical classifier. + */ + +export type { ClassifyResult, PatternName, QualityProfile } from '@stackbilt/scaffold-core'; + +import { classify } from '@stackbilt/scaffold-core'; +import type { CLIOptions } from '../index.js'; +import { EXIT_CODE, CLIError } from '../index.js'; + +export function classifyScaffoldIntention(intention: string) { + return classify(intention); +} + +export async function classifyCommand(options: CLIOptions, args: string[]): Promise { + const positional = args.filter(a => !a.startsWith('-')); + const intention = positional.join(' ').trim(); + + if (!intention) { + throw new CLIError( + 'Provide an intention to classify:\n stackbilt classify "multi-tenant SaaS API with Stripe billing"', + ); + } + + const result = classifyScaffoldIntention(intention); + + if (options.format === 'json') { + console.log(JSON.stringify(result, null, 2)); + return EXIT_CODE.SUCCESS; + } + + const pct = Math.round(result.confidence * 100); + const qp = result.qualityProfile; + const flags = [ + qp.authentication && 'auth', + qp.rateLimiting && 'rate-limit', + qp.observability && 'observability', + qp.piiHandling && 'pii', + ...qp.complianceDomains, + ].filter(Boolean).join(', '); + + console.log(''); + console.log(` Pattern: ${result.pattern}`); + console.log(` Confidence: ${pct}%`); + if (result.traits.length > 0) { + console.log(` Traits: ${result.traits.join(', ')}`); + } + if (flags) { + console.log(` Quality: ${flags}`); + } + console.log(` Testing: ${qp.testingLevel}`); + console.log(''); + + return EXIT_CODE.SUCCESS; +} diff --git a/src/commands/run.ts b/src/commands/run.ts index 539f307..2cc3a8a 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -5,6 +5,30 @@ import { EXIT_CODE, CLIError } from '../index.js'; import { getFlag } from '../flags.js'; import { resolveApiKey } from '../credentials.js'; import { EngineClient, type BuildRequest, type ScaffoldResult } from '../http-client.js'; +import { buildScaffold } from '@stackbilt/scaffold-core'; + +// Write the unified cache contract so `stackbilt scaffold` can use it. +// Shape: { intention, pattern, classification, governance, files?, createdAt } +function writeCachedBuild( + intention: string, + files: Array<{ path: string; content: string }>, + configPath: string, +): void { + const dir = configPath || '.charter'; + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const core = buildScaffold(intention); + fs.writeFileSync( + path.join(dir, 'last-build.json'), + JSON.stringify({ + intention, + pattern: core.classification.pattern, + classification: core.classification, + governance: core.governance, + files, + createdAt: new Date().toISOString(), + }, null, 2), + ); +} const PHASE_LABELS = ['PRODUCT', 'UX', 'RISK', 'ARCHITECT', 'TDD', 'SPRINT']; const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -100,13 +124,15 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise ({ - 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, - })); + scaffoldPromise = client.build(request).then(r => { + 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, + }; + }); } if (options.format === 'json') { @@ -114,6 +140,7 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise ({ path: p, content })), options.configPath); } return EXIT_CODE.SUCCESS; } @@ -176,6 +203,7 @@ export async function runCommand(options: CLIOptions, args: string[]): Promise ({ path: p, content })), options.configPath); console.log(` → ${result.files.length} files scaffolded to ${resolvedOutput}/`); console.log(` → Architecture governed · seed: ${result.seed ?? 'deterministic'}`); if (result.nextSteps && result.nextSteps.length > 0) { diff --git a/src/commands/scaffold.ts b/src/commands/scaffold.ts index ec1cd66..015289c 100644 --- a/src/commands/scaffold.ts +++ b/src/commands/scaffold.ts @@ -3,54 +3,141 @@ import * as path from 'node:path'; import type { CLIOptions } from '../index.js'; import { EXIT_CODE, CLIError } from '../index.js'; import { getFlag } from '../flags.js'; -import type { BuildResult } from '../http-client.js'; +import { buildScaffold } from '@stackbilt/scaffold-core'; + +// Cache shapes written by `run` and `architect`. +// New shape (>= this PR): { intention, pattern, classification, governance, files?, createdAt } +// Old shape (legacy BuildResult): { scaffold: Record, stack, seed, ... } +type CachedBuild = { + intention?: string; + files?: Array<{ path: string; content: string }>; + scaffold?: Record; +}; export async function scaffoldCommand(options: CLIOptions, args: string[]): Promise { const configPath = options.configPath || '.charter'; const cachePath = path.join(configPath, 'last-build.json'); - if (!fs.existsSync(cachePath)) { - throw new CLIError('No cached build found. Run `stackbilt architect "..."` first.'); - } + // ---- Determine file source: cache (engine/run path) or local scaffold-core ---- + + const positional = args.filter(a => !a.startsWith('-') && a !== getFlag(args, '--output') && a !== getFlag(args, '--intention')); + const intentionFlag = getFlag(args, '--intention'); + + // If there's a cached build from `stackbilt run` or `stackbilt architect`, use it. + if (fs.existsSync(cachePath)) { + let cached: CachedBuild; + try { + cached = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + } catch { + throw new CLIError('Could not parse cached build. Run `stackbilt architect "..."` again.'); + } + + // Resolve files from either new shape (files[]) or old shape (scaffold dict) + let resolvedFiles: Array<[string, string]>; + if (cached.files && cached.files.length > 0) { + // New unified shape: files[] from run — or generate from cached intention (architect path) + resolvedFiles = cached.files.map(f => [f.path, f.content] as [string, string]); + } else if (!cached.files && cached.intention) { + // Architect cache: no files yet — generate them now + const generated = buildScaffold(cached.intention).files; + resolvedFiles = generated.map(f => [f.path, f.content] as [string, string]); + } else if (cached.scaffold && Object.keys(cached.scaffold).length > 0) { + // Legacy BuildResult shape + resolvedFiles = Object.entries(cached.scaffold) as [string, string][]; + } else { + throw new CLIError('Cached build has no scaffold files. Run `stackbilt run "..."` first.'); + } + + const outputDir = getFlag(args, '--output') ?? '.'; + const dryRun = args.includes('--dry-run'); + + const files = resolvedFiles.sort(([a], [b]) => a.localeCompare(b)); + + if (options.format === 'json') { + const manifest = files.map(([name, content]) => ({ + path: path.join(outputDir, name), + lines: content.split('\n').length, + })); + console.log(JSON.stringify({ outputDir, dryRun, files: manifest }, null, 2)); + if (!dryRun) writeFiles(outputDir, files); + return EXIT_CODE.SUCCESS; + } + + console.log(''); + console.log(` Scaffold from build`); + console.log(` Output: ${path.resolve(outputDir)}`); + console.log(''); + + for (const [name, content] of files) { + const lines = content.split('\n').length; + const target = path.join(outputDir, name); + const exists = fs.existsSync(target); + const marker = exists ? ' (exists, will overwrite)' : ''; + console.log(` ${name} (${lines} lines)${marker}`); + } + + if (dryRun) { + console.log(''); + console.log(' (dry run — no files written)'); + return EXIT_CODE.SUCCESS; + } + + writeFiles(outputDir, files); - let result: BuildResult; - try { - result = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); - } catch { - throw new CLIError('Could not parse cached build. Run `stackbilt architect "..."` again.'); + console.log(''); + console.log(` ${files.length} files written.`); + return EXIT_CODE.SUCCESS; } - if (!result.scaffold || Object.keys(result.scaffold).length === 0) { - throw new CLIError('Cached build has no scaffold files.'); + // ---- No cache: fall back to buildScaffold() from @stackbilt/scaffold-core ---- + // Requires an intention passed as a positional arg or --intention flag. + + const intention = intentionFlag ?? positional.join(' ').trim(); + + if (!intention) { + throw new CLIError( + 'No cached build found and no intention provided.\n' + + ' Option 1: Run `stackbilt run "..."` first to cache a build, then re-run scaffold.\n' + + ' Option 2: Provide an intention directly:\n' + + ' stackbilt scaffold "multi-tenant SaaS API with Stripe billing"', + ); } + const scaffoldOutput = buildScaffold(intention); + const outputDir = getFlag(args, '--output') ?? '.'; const dryRun = args.includes('--dry-run'); - const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b)); - if (options.format === 'json') { - const manifest = files.map(([name, content]) => ({ - path: path.join(outputDir, name), - lines: content.split('\n').length, + const manifest = scaffoldOutput.files.map(f => ({ + path: path.join(outputDir, f.path), + lines: f.content.split('\n').length, + role: f.role, })); - console.log(JSON.stringify({ outputDir, dryRun, files: manifest }, null, 2)); - if (!dryRun) writeFiles(outputDir, files); + console.log(JSON.stringify({ + outputDir, + dryRun, + pattern: scaffoldOutput.classification.pattern, + files: manifest, + }, null, 2)); + if (!dryRun) { + writeScaffoldFiles(outputDir, scaffoldOutput.files); + } return EXIT_CODE.SUCCESS; } console.log(''); - console.log(` Scaffold from build (seed: ${result.seed})`); - console.log(` Stack: ${result.stack.map(s => s.name).join(' + ')}`); + console.log(` Local scaffold (inference-free · @stackbilt/scaffold-core)`); + console.log(` Pattern: ${scaffoldOutput.classification.pattern}`); console.log(` Output: ${path.resolve(outputDir)}`); console.log(''); - for (const [name, content] of files) { - const lines = content.split('\n').length; - const target = path.join(outputDir, name); + for (const f of scaffoldOutput.files) { + const lines = f.content.split('\n').length; + const target = path.join(outputDir, f.path); const exists = fs.existsSync(target); const marker = exists ? ' (exists, will overwrite)' : ''; - console.log(` ${name} (${lines} lines)${marker}`); + console.log(` ${f.path} (${lines} lines)${marker}`); } if (dryRun) { @@ -59,10 +146,10 @@ export async function scaffoldCommand(options: CLIOptions, args: string[]): Prom return EXIT_CODE.SUCCESS; } - writeFiles(outputDir, files); + writeScaffoldFiles(outputDir, scaffoldOutput.files); console.log(''); - console.log(` ${files.length} files written.`); + console.log(` ${scaffoldOutput.files.length} files written.`); return EXIT_CODE.SUCCESS; } @@ -76,3 +163,14 @@ function writeFiles(outputDir: string, files: [string, string][]): void { fs.writeFileSync(target, content); } } + +function writeScaffoldFiles(outputDir: string, files: Array<{ path: string; content: string }>): void { + for (const { path: name, content } of files) { + const target = path.join(outputDir, name); + const dir = path.dirname(target); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(target, content); + } +}