#!/usr/bin/env node /** * cli:uat-plan — Generate a `.plantest.yml` test plan for a generated SmartStack app. * * Pipeline: validate spec → resolve app structure + DB connection → DISCOVER live * (SQL nav/RBAC + componentRegistry views + controllers, scoped to `path`) → PROJECT * onto roles → assemble + serialise the plan → write `{name}.plantest.yml` + `.signature`. * * Discovery (SQL/filesystem) is the only non-deterministic step; everything from the * DiscoveryResult onward is pure (see generate.ts), so the plan is reproducible. */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { validate } from './validate.js'; import { discover } from './discover.js'; import { generatePlan } from './generate.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../lib/output.js'; const COMMAND = 'uat-plan'; async function main(): Promise { let values: { spec?: string; dry_run?: boolean }; try { values = parseArgs({ options: { spec: { type: 'string' }, dry_run: { type: 'boolean', default: false }, }, strict: true, }).values; } catch (e) { printEnvelope(failGenerate(COMMAND, [`Invalid arguments: ${(e as Error).message}`])); process.exit(1); return; } if (!values.spec) { printEnvelope(failGenerate(COMMAND, ['--spec is required'])); process.exit(1); } let raw: unknown; try { raw = JSON.parse(values.spec); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in --spec'])); process.exit(1); } const v = await validate(raw); if (!v.valid || !v.spec || !v.layout || !v.connection) { printEnvelope(failGenerate(COMMAND, v.errors)); process.exit(1); } const spec = v.spec; const layout = v.layout; const projectRoot = resolve(spec.projectPath); const warnings = [...v.warnings]; // Live discovery (SQL + filesystem) — the only step that can fail at runtime. let outcome; try { outcome = await discover({ apiDir: layout.apiDir, webDir: layout.webDir, path: spec.path, connection: v.connection, rolesOverride: spec.roles, includeApi: spec.includeApi, }); } catch (e) { printEnvelope(failGenerate(COMMAND, [`Discovery failed: ${(e as Error).message}`])); process.exit(1); return; } warnings.push(...outcome.warnings); const gen = generatePlan(outcome.result, { name: layout.name, outDir: layout.outDir, modes: spec.modes, caps: spec.caps, includeApi: spec.includeApi, generatedAt: spec.generatedAt, }); // Structural invariant violations are advisory (surface them; do not hard-gate). const violations = gen.violations.map((x) => `[inv ${x.invariant}] ${x.where}: ${x.message}`); warnings.push(...violations); const summary = { application: outcome.result.application, path: spec.path, roles: gen.plan.roles, routes: gen.plan.routes.length, endpoints: gen.plan.endpoints.length, connectionSource: v.connectionSource, signature: gen.signature, }; if (values.dry_run) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, ...summary, files: gen.files.map((f) => f.path) }, warnings, }), ); process.exit(0); } const filesCreated: string[] = []; const filesModified: string[] = []; for (const file of gen.files) { const abs = resolve(join(projectRoot, file.path)); const exists = existsSync(abs); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); (exists ? filesModified : filesCreated).push(abs); } const nextSteps = [ `Review the plan: ${layout.outDir}/${layout.name}.plantest.yml`, 'Run everything with `/uat run` — or step by step: `/uat provision`, `/uat api`, `/uat ui`, `/uat report`.', ]; if (violations.length > 0) { nextSteps.unshift( `${violations.length} structural invariant violation(s) — the plan may be malformed; inspect the warnings before running.`, ); } printEnvelope( generateEnvelope(COMMAND, { data: summary, filesCreated, filesModified, warnings, nextSteps, }), ); } void main();