|
| 1 | +/** |
| 2 | + * charter architect — generate a tech stack from a project description. |
| 3 | + * |
| 4 | + * Usage: |
| 5 | + * charter architect "Build a real-time chat app on Cloudflare" |
| 6 | + * charter architect --file spec.md |
| 7 | + * charter architect "API backend" --cloudflare-only --framework Hono --database D1 |
| 8 | + * charter architect "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 | +export async function architectCommand(options: CLIOptions, args: string[]): Promise<number> { |
| 20 | + // Parse description from positional arg or --file |
| 21 | + const filePath = getFlag(args, '--file'); |
| 22 | + const positional = args.filter(a => !a.startsWith('-') && a !== filePath); |
| 23 | + let description: string; |
| 24 | + |
| 25 | + if (filePath) { |
| 26 | + if (!fs.existsSync(filePath)) throw new CLIError(`File not found: ${filePath}`); |
| 27 | + description = fs.readFileSync(filePath, 'utf-8').trim(); |
| 28 | + } else if (positional.length > 0) { |
| 29 | + description = positional.join(' '); |
| 30 | + } else { |
| 31 | + throw new CLIError('Provide a project description:\n charter architect "Build a real-time chat app"\n charter architect --file spec.md'); |
| 32 | + } |
| 33 | + |
| 34 | + if (!description) throw new CLIError('Empty description.'); |
| 35 | + |
| 36 | + // Parse constraint flags |
| 37 | + const request: BuildRequest = { description, constraints: {} }; |
| 38 | + if (args.includes('--cloudflare-only')) request.constraints!.cloudflareOnly = true; |
| 39 | + const fw = getFlag(args, '--framework'); |
| 40 | + if (fw) request.constraints!.framework = fw; |
| 41 | + const db = getFlag(args, '--database'); |
| 42 | + if (db) request.constraints!.database = db; |
| 43 | + |
| 44 | + const seedStr = getFlag(args, '--seed'); |
| 45 | + if (seedStr) request.seed = parseInt(seedStr, 10); |
| 46 | + |
| 47 | + // Load credentials (optional — engine may not require auth yet) |
| 48 | + const creds = loadCredentials(); |
| 49 | + const baseUrl = getFlag(args, '--url'); |
| 50 | + const client = new EngineClient({ baseUrl: baseUrl ?? creds?.baseUrl, apiKey: creds?.apiKey }); |
| 51 | + |
| 52 | + // Build |
| 53 | + let result: BuildResult; |
| 54 | + try { |
| 55 | + result = await client.build(request); |
| 56 | + } catch (err) { |
| 57 | + throw new CLIError(`Build failed: ${(err as Error).message}`); |
| 58 | + } |
| 59 | + |
| 60 | + const dryRun = args.includes('--dry-run'); |
| 61 | + |
| 62 | + // JSON output |
| 63 | + if (options.format === 'json') { |
| 64 | + console.log(JSON.stringify(result, null, 2)); |
| 65 | + if (!dryRun) cacheResult(result, options.configPath); |
| 66 | + return EXIT_CODE.SUCCESS; |
| 67 | + } |
| 68 | + |
| 69 | + // Text output |
| 70 | + printResult(result); |
| 71 | + |
| 72 | + // Write scaffold |
| 73 | + if (!dryRun) { |
| 74 | + cacheResult(result, options.configPath); |
| 75 | + console.log(''); |
| 76 | + console.log(`Build cached. Run \`charter scaffold\` to write files.`); |
| 77 | + } else { |
| 78 | + console.log(''); |
| 79 | + console.log('(dry run — no files written)'); |
| 80 | + } |
| 81 | + |
| 82 | + return EXIT_CODE.SUCCESS; |
| 83 | +} |
| 84 | + |
| 85 | +function printResult(r: BuildResult): void { |
| 86 | + const c = r.compatibility; |
| 87 | + |
| 88 | + console.log(''); |
| 89 | + console.log(` Stack (seed: ${r.seed}, ${r.requirements.complexity})`); |
| 90 | + console.log(''); |
| 91 | + |
| 92 | + const maxPos = Math.max(...r.stack.map(s => s.position.length)); |
| 93 | + const maxName = Math.max(...r.stack.map(s => s.name.length)); |
| 94 | + for (const s of r.stack) { |
| 95 | + const pos = s.position.padEnd(maxPos); |
| 96 | + const name = s.name.padEnd(maxName); |
| 97 | + const orient = s.orientation === 'reversed' ? '↓' : '↑'; |
| 98 | + const cf = s.cloudflareNative ? ' [CF]' : ''; |
| 99 | + console.log(` ${pos} ${name} (${s.element}, ${orient})${cf}`); |
| 100 | + } |
| 101 | + |
| 102 | + console.log(''); |
| 103 | + console.log(` Compatibility: ${c.normalizedScore} (${c.pairs.length} pairs, ${c.tensions.length} tensions)`); |
| 104 | + |
| 105 | + for (const p of c.pairs) { |
| 106 | + const sign = p.score > 0 ? '+' : p.score < 0 ? '' : ' '; |
| 107 | + console.log(` ${p.techs[0]} + ${p.techs[1]} = ${p.relationship} (${sign}${p.score})`); |
| 108 | + } |
| 109 | + |
| 110 | + if (c.tensions.length > 0) { |
| 111 | + console.log(''); |
| 112 | + console.log(' Tensions:'); |
| 113 | + for (const t of c.tensions) { |
| 114 | + console.log(` ⚡ ${t.description}`); |
| 115 | + } |
| 116 | + } |
| 117 | + |
| 118 | + console.log(''); |
| 119 | + console.log(` Scaffold: ${Object.keys(r.scaffold).length} files`); |
| 120 | + for (const f of Object.keys(r.scaffold).sort()) { |
| 121 | + const lines = r.scaffold[f].split('\n').length; |
| 122 | + console.log(` ${f} (${lines} lines)`); |
| 123 | + } |
| 124 | + |
| 125 | + console.log(''); |
| 126 | + console.log(` Keywords: ${r.requirements.keywords.slice(0, 8).join(', ')}`); |
| 127 | + console.log(` Receipt: ${r.receipt.slice(0, 16)}`); |
| 128 | +} |
| 129 | + |
| 130 | +function cacheResult(result: BuildResult, configPath: string): void { |
| 131 | + const dir = configPath || '.charter'; |
| 132 | + if (!fs.existsSync(dir)) { |
| 133 | + fs.mkdirSync(dir, { recursive: true }); |
| 134 | + } |
| 135 | + fs.writeFileSync( |
| 136 | + path.join(dir, 'last-build.json'), |
| 137 | + JSON.stringify(result, null, 2), |
| 138 | + ); |
| 139 | +} |
0 commit comments