#!/usr/bin/env node /** * cli:scaffold-frontend-auth — Scaffolds useAuth + PermissionGuard. * * Idempotent : skips writes if the existing content matches; honours * `// @customised` (or `/* @customised *\/`) marker at the top. */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { validate } from './validate.js'; import { generate } from './generate.js'; import { generateEnvelope, failGenerate, printEnvelope, } from '../../../../../lib/output.js'; import { readSpecArg } from '../../../../../lib/spec-arg.js'; const COMMAND = 'scaffold-frontend-auth'; function isCustomised(source: string): boolean { const head = source.trimStart().slice(0, 100); return head.startsWith('/* @customised') || head.startsWith('// @customised'); } function main(): void { const { values } = parseArgs({ options: { spec: { type: 'string' }, 'spec-file': { type: 'string' }, 'project-path': { type: 'string' }, 'app-code': { type: 'string' }, dry_run: { type: 'boolean', default: false }, }, strict: true, }); // Three invocation modes (priority order) : // - --spec '' (Phase 3a orchestrated): the orchestrator passes a // full JSON spec including projectPath + appCode. // - --project-path --app-code (sub-step runner direct // invocation, post-cli-args-resolver fix): the runner hands over both // the project path AND the canonical appCode it already knows from // DevSubStepRunArgs. This is the production path — preferred over the // legacy --project-path-only mode because deriveAppCode()'s fallback // walks the project's web/ folder and can pick a stale '*-web' subdir // created by a previous misrouted run, perpetuating the bug (e.g. the // classic 'web/app-web/' regression where the auth scaffold lands in // a placeholder dir while the real app is at 'web/{nameLower}-web/'). // - --project-path only (legacy + standalone CLI): falls back to // deriveAppCode() heuristics. Kept for hand-running the CLI from a // terminal without orchestration context, but flagged as fragile. let raw: unknown; if (values.spec || values['spec-file']) { const specSrc = readSpecArg(values); if ('error' in specSrc) { printEnvelope(failGenerate(COMMAND, [specSrc.error])); process.exit(1); } try { raw = JSON.parse(specSrc.raw); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in --spec'])); process.exit(1); } } else if (values['project-path']) { const projectPath = resolve(values['project-path']); const appCode = values['app-code'] ?? deriveAppCode(projectPath); raw = { projectPath, appCode, force: false, }; } else { printEnvelope(failGenerate(COMMAND, ['--spec or --project-path is required'])); process.exit(1); } const validation = validate(raw); if (!validation.valid || !validation.spec) { printEnvelope(failGenerate(COMMAND, validation.errors)); process.exit(1); } const spec = validation.spec; const files = generate(spec); if (values.dry_run) { printEnvelope(generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map((f) => f.path) }, warnings: validation.warnings, })); process.exit(0); } const filesCreated: string[] = []; const filesModified: string[] = []; const skipped: string[] = []; for (const file of files) { const abs = resolve(join(spec.projectPath, file.path)); if (existsSync(abs)) { const existing = readFileSync(abs, 'utf-8'); if (isCustomised(existing)) { skipped.push(file.path); continue; } if (!spec.force && existing === file.content) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); filesModified.push(abs); } else { mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); filesCreated.push(abs); } } const nextSteps: string[] = []; if (skipped.length > 0) { nextSteps.push(`Skipped ${skipped.length} customised file(s): ${skipped.join(', ')}. Remove the @customised marker to allow overwrites.`); } if (filesCreated.length === 0 && filesModified.length === 0 && skipped.length === 0) { nextSteps.push('Auth primitives already up-to-date — no changes needed.'); } if (filesCreated.length > 0) { nextSteps.push('Verify the backend exposes `GET /api/auth/me` returning `{ user, permissions: string[] }`.'); nextSteps.push('Components can now import `` from `@/components/auth/PermissionGuard` and `useAuth()` from `@/business/auth/useAuth`.'); } printEnvelope(generateEnvelope(COMMAND, { data: { fileCount: filesCreated.length + filesModified.length, skipped: skipped.length }, filesCreated, filesModified, warnings: validation.warnings, nextSteps, })); } /** * Best-effort appCode derivation when the orchestrator only hands us a * projectPath (i.e. neither `--spec` nor `--app-code` was provided — * typically a hand-run from a terminal). Strategy, ordered by reliability : * * 1. Read `.smartstack/init-state.json > projectConfig.nameLower`. This * is the canonical appCode emitted by `smartstack-studio init` and * stays stable for the project's lifetime. * 2. Fallback : take the basename of projectPath (e.g. "TestV2") and * lowercase it. Works for any project that follows the convention * `/.smartstack/...` even when init-state was deleted. * 3. Last-resort fallback : `'app'`. Returned only when both reads above * throw — at that point we can't tell anything from the disk. * * The previous heuristic walked `web/{*-web}/` and returned the first match. * That bit us hard in production : once a stray `web/app-web/` had been * created by a misrouted earlier run (because `appCode` defaulted to * `'app'` somewhere), every subsequent run picked it again — the bug was * self-reinforcing. Reading `.smartstack/init-state.json` cuts the loop * because the file is written ONCE at project init by the canonical name. */ function deriveAppCode(projectPath: string): string { // 1. init-state.json — authoritative source written by `smartstack-studio init`. try { const initStatePath = join(projectPath, '.smartstack', 'init-state.json'); if (existsSync(initStatePath)) { const parsed = JSON.parse(readFileSync(initStatePath, 'utf-8')) as { projectConfig?: { nameLower?: unknown }; }; const nameLower = parsed?.projectConfig?.nameLower; if (typeof nameLower === 'string' && nameLower.length > 0) { return nameLower; } } } catch { /* fall through to basename heuristic */ } // 2. Basename of the projectPath, lowercased. try { return basename(projectPath).toLowerCase(); } catch { return 'app'; } } main();