#!/usr/bin/env node /** * cli:build-manifest — index.ts * * Reads a JSON spec on --spec and writes tests/ui-test/manifest.json under * --project-path (defaults to cwd). Emits a SmartStack envelope on stdout. */ import { parseArgs } from 'node:util'; import path from 'node:path'; import { promises as fs } from 'node:fs'; import { validate } from './validate.js'; import { generate } from './generate.js'; import { deriveEntityRoles, loadStateGrants } from './derive-roles.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../../../lib/output.js'; const COMMAND = 'build-manifest'; async function main(): Promise { const args = parseArgs({ options: { spec: { type: 'string' }, 'project-path': { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, }, allowPositionals: false, }); if (!args.values.spec) { printEnvelope(failGenerate(COMMAND, ['--spec is required (JSON string)'])); process.exit(2); } let raw: unknown; try { raw = JSON.parse(args.values.spec); } catch (err) { printEnvelope(failGenerate(COMMAND, [`--spec is not valid JSON: ${err instanceof Error ? err.message : err}`])); process.exit(2); } const result = validate(raw); if (!result.valid || !result.data) { printEnvelope(failGenerate(COMMAND, result.errors)); process.exit(2); } const projectPath = args.values['project-path'] ?? result.data.projectPath; // Fill EMPTY role arrays from the seeded state (authored arrays win) — the // permission-negative scenarios only exist through this derivation: nobody // hand-feeds rolesWithoutAccess, and an empty array silently emits ZERO // negative tests (audit H10, UI half). const { grants, warning: stateWarning } = loadStateGrants(projectPath, result.data.appCode); const spec = grants ? { ...result.data, entities: result.data.entities.map((e) => deriveEntityRoles(e, grants, result.data.appCode, result.data.module), ), } : result.data; if (stateWarning) result.warnings.push(stateWarning); const files = generate(spec); const created: string[] = []; for (const f of files) { const fullPath = path.resolve(projectPath, f.path); if (args.values['dry-run']) { created.push(`(dry) ${f.path}`); continue; } await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, f.content, 'utf8'); created.push(f.path); } const envelope = generateEnvelope(COMMAND, { filesCreated: created, warnings: result.warnings, nextSteps: [ 'Verify tests/ui-test/test-users.json exists (regenerate seed if not).', 'Run Phase 5: npx --prefer-offline tsx skills/development/testing/ui-test/cli/run-ui-test/index.ts --project-path ', ], data: { module: result.data.module, testCount: files[0]?.content ? JSON.parse(files[0].content).tests.length : 0, }, }); printEnvelope(envelope); } main().catch((err: unknown) => { printEnvelope(failGenerate(COMMAND, [err instanceof Error ? err.message : String(err)])); process.exit(1); });