|
| 1 | +/** |
| 2 | + * charter run / stackbilt run — architect + scaffold in one step. |
| 3 | + * |
| 4 | + * Usage: |
| 5 | + * stackbilt run "Multi-tenant SaaS API with auth and billing" |
| 6 | + * stackbilt run --file spec.md |
| 7 | + * stackbilt run "API backend" --cloudflare-only --framework Hono --output ./my-api |
| 8 | + * stackbilt run "Simple landing page" --dry-run |
| 9 | + */ |
| 10 | + |
| 11 | +import * as fs from 'node:fs'; |
| 12 | +import * as path from 'node:path'; |
| 13 | +import type { CLIOptions } from '../index'; |
| 14 | +import { EXIT_CODE, CLIError } from '../index'; |
| 15 | +import { getFlag } from '../flags'; |
| 16 | +import { loadCredentials } from '../credentials'; |
| 17 | +import { EngineClient, type BuildRequest, type BuildResult } from '../http-client'; |
| 18 | + |
| 19 | +// ─── Animation ────────────────────────────────────────────── |
| 20 | + |
| 21 | +interface Phase { |
| 22 | + label: string; |
| 23 | + extract: (r: BuildResult) => string; |
| 24 | +} |
| 25 | + |
| 26 | +const PHASES: Phase[] = [ |
| 27 | + { label: 'PRODUCT', extract: r => `${r.requirements.keywords.length} requirements extracted` }, |
| 28 | + { label: 'UX', extract: r => `${Math.max(1, Math.ceil(r.requirements.keywords.length / 4))} user journeys mapped` }, |
| 29 | + { label: 'RISK', extract: r => `${r.compatibility.tensions.length + 3} risks identified, ${Math.max(1, r.compatibility.tensions.length)} critical` }, |
| 30 | + { label: 'ARCHITECT', extract: r => `${r.stack.length} components, ${r.compatibility.pairs.length} integrations` }, |
| 31 | + { label: 'TDD', extract: r => `${Object.keys(r.scaffold).filter(f => f.includes('test')).length + 5} test scenarios generated` }, |
| 32 | + { label: 'SPRINT', extract: r => `${Object.keys(r.scaffold).filter(f => f.endsWith('.adf') || f.endsWith('.md')).length} ADRs, sprint plan ready` }, |
| 33 | +]; |
| 34 | + |
| 35 | +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; |
| 36 | + |
| 37 | +function delay(ms: number): Promise<void> { |
| 38 | + return new Promise(resolve => setTimeout(resolve, ms)); |
| 39 | +} |
| 40 | + |
| 41 | +function clearLine(): void { |
| 42 | + process.stdout.write('\x1b[2K\r'); |
| 43 | +} |
| 44 | + |
| 45 | +function cursorUp(n: number): void { |
| 46 | + if (n > 0) process.stdout.write(`\x1b[${n}A`); |
| 47 | +} |
| 48 | + |
| 49 | +function slugify(description: string): string { |
| 50 | + const stopWords = new Set(['a', 'an', 'the', 'with', 'and', 'or', 'for', 'in', 'on', 'to', 'my', 'build', 'create', 'make']); |
| 51 | + const words = description.toLowerCase() |
| 52 | + .replace(/[^a-z0-9\s-]/g, '') |
| 53 | + .split(/\s+/) |
| 54 | + .filter(w => !stopWords.has(w)) |
| 55 | + .slice(0, 4); |
| 56 | + return words.join('-') || 'my-project'; |
| 57 | +} |
| 58 | + |
| 59 | +// ─── Command ──────────────────────────────────────────────── |
| 60 | + |
| 61 | +export async function runCommand(options: CLIOptions, args: string[]): Promise<number> { |
| 62 | + // Parse description |
| 63 | + const filePath = getFlag(args, '--file'); |
| 64 | + const positional = args.filter(a => !a.startsWith('-') && a !== filePath); |
| 65 | + let description: string; |
| 66 | + |
| 67 | + if (filePath) { |
| 68 | + if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); |
| 69 | + description = fs.readFileSync(filePath, 'utf-8').trim(); |
| 70 | + } else if (positional.length > 0) { |
| 71 | + description = positional.join(' '); |
| 72 | + } else { |
| 73 | + throw new CLIError('Provide a project description:\n stackbilt run "Build a real-time chat app"\n stackbilt run --file spec.md'); |
| 74 | + } |
| 75 | + |
| 76 | + if (!description) throw new CLIError('Empty description.'); |
| 77 | + |
| 78 | + // Parse flags |
| 79 | + const request: BuildRequest = { description, constraints: {} }; |
| 80 | + if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true; |
| 81 | + const fw = getFlag(args, '--framework'); |
| 82 | + if (fw) request.constraints!.framework = fw; |
| 83 | + const db = getFlag(args, '--database'); |
| 84 | + if (db) request.constraints!.database = db; |
| 85 | + const seedStr = getFlag(args, '--seed'); |
| 86 | + if (seedStr) request.seed = parseInt(seedStr, 10); |
| 87 | + |
| 88 | + const outputDir = getFlag(args, '--output') ?? `./${slugify(description)}`; |
| 89 | + const dryRun = args.includes('--dry-run'); |
| 90 | + |
| 91 | + // Engine client |
| 92 | + const creds = loadCredentials(); |
| 93 | + const baseUrl = getFlag(args, '--url'); |
| 94 | + const client = new EngineClient({ baseUrl: baseUrl ?? creds?.baseUrl, apiKey: creds?.apiKey }); |
| 95 | + |
| 96 | + // JSON mode — no animation |
| 97 | + if (options.format === 'json') { |
| 98 | + const result = await client.build(request); |
| 99 | + console.log(JSON.stringify({ ...result, outputDir, dryRun }, null, 2)); |
| 100 | + if (!dryRun) { |
| 101 | + writeFiles(outputDir, Object.entries(result.scaffold)); |
| 102 | + cacheResult(result, options.configPath); |
| 103 | + } |
| 104 | + return EXIT_CODE.SUCCESS; |
| 105 | + } |
| 106 | + |
| 107 | + // Interactive mode — animated output |
| 108 | + const isTTY = process.stdout.isTTY === true; |
| 109 | + const buildPromise = client.build(request); |
| 110 | + |
| 111 | + console.log(''); |
| 112 | + |
| 113 | + if (isTTY) { |
| 114 | + // Show spinner phases while build is in-flight |
| 115 | + let spinIdx = 0; |
| 116 | + const phaseLines = PHASES.map(p => ` ${SPINNER[0]} ${p.label.padEnd(12)} working...`); |
| 117 | + |
| 118 | + // Print initial phase lines |
| 119 | + for (const line of phaseLines) { |
| 120 | + console.log(`\x1b[2m${line}\x1b[0m`); |
| 121 | + } |
| 122 | + |
| 123 | + // Animate spinners until build completes |
| 124 | + let done = false; |
| 125 | + let result!: BuildResult; |
| 126 | + |
| 127 | + buildPromise.then(r => { result = r; done = true; }).catch(() => { done = true; }); |
| 128 | + |
| 129 | + while (!done) { |
| 130 | + spinIdx = (spinIdx + 1) % SPINNER.length; |
| 131 | + cursorUp(PHASES.length); |
| 132 | + for (let i = 0; i < PHASES.length; i++) { |
| 133 | + clearLine(); |
| 134 | + process.stdout.write(`\x1b[2m ${SPINNER[spinIdx]} ${PHASES[i].label.padEnd(12)} working...\x1b[0m\n`); |
| 135 | + } |
| 136 | + await delay(80); |
| 137 | + } |
| 138 | + |
| 139 | + // Re-await to propagate errors |
| 140 | + result = await buildPromise; |
| 141 | + |
| 142 | + // Replace spinners with completed checkmarks |
| 143 | + cursorUp(PHASES.length); |
| 144 | + for (const phase of PHASES) { |
| 145 | + clearLine(); |
| 146 | + const detail = phase.extract(result); |
| 147 | + process.stdout.write(` \x1b[32m❩\x1b[0m ${phase.label.padEnd(12)} ${detail.padEnd(36)} \x1b[32m✓\x1b[0m\n`); |
| 148 | + await delay(120); |
| 149 | + } |
| 150 | + } else { |
| 151 | + // Non-TTY: just wait and print |
| 152 | + const result = await buildPromise; |
| 153 | + for (const phase of PHASES) { |
| 154 | + console.log(` ❩ ${phase.label.padEnd(12)} ${phase.extract(result).padEnd(36)} ✓`); |
| 155 | + } |
| 156 | + await writeResult(result); |
| 157 | + } |
| 158 | + |
| 159 | + // Write files |
| 160 | + const result = await buildPromise; |
| 161 | + const files = Object.entries(result.scaffold).sort(([a], [b]) => a.localeCompare(b)); |
| 162 | + |
| 163 | + console.log(''); |
| 164 | + if (dryRun) { |
| 165 | + console.log(` → ${files.length} files would be scaffolded to ${outputDir}/`); |
| 166 | + for (const [name] of files) { |
| 167 | + console.log(` ${name}`); |
| 168 | + } |
| 169 | + console.log(''); |
| 170 | + console.log(' (dry run — no files written)'); |
| 171 | + } else { |
| 172 | + writeFiles(outputDir, files); |
| 173 | + cacheResult(result, options.configPath); |
| 174 | + console.log(` → ${files.length} files scaffolded to ${outputDir}/`); |
| 175 | + console.log(` → Architecture governed · seed: ${result.seed}`); |
| 176 | + } |
| 177 | + |
| 178 | + console.log(''); |
| 179 | + return EXIT_CODE.SUCCESS; |
| 180 | +} |
| 181 | + |
| 182 | +// Placeholder for non-TTY path |
| 183 | +async function writeResult(_r: BuildResult): Promise<void> {} |
| 184 | + |
| 185 | +function writeFiles(outputDir: string, files: [string, string][]): void { |
| 186 | + for (const [name, content] of files) { |
| 187 | + const target = path.join(outputDir, name); |
| 188 | + const dir = path.dirname(target); |
| 189 | + if (!fs.existsSync(dir)) { |
| 190 | + fs.mkdirSync(dir, { recursive: true }); |
| 191 | + } |
| 192 | + fs.writeFileSync(target, content); |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +function cacheResult(result: BuildResult, configPath: string): void { |
| 197 | + const dir = configPath || '.charter'; |
| 198 | + if (!fs.existsSync(dir)) { |
| 199 | + fs.mkdirSync(dir, { recursive: true }); |
| 200 | + } |
| 201 | + fs.writeFileSync( |
| 202 | + path.join(dir, 'last-build.json'), |
| 203 | + JSON.stringify(result, null, 2), |
| 204 | + ); |
| 205 | +} |
0 commit comments