#!/usr/bin/env node /** * cli:scaffold-vitrine — Scaffold a public (pre-auth) marketing surface into a * generated SmartStack client app: the home (`/` override), presentation pages * and a full site vitrine. * * Writes editable React pages + sections + i18n the developer OWNS, and wires * them through the package's existing public seam: * - extensions/vitrine.generated.ts → PublicRouteRegistry.register + addClientResources + vitrineExtensions * - main.tsx → import the wiring + spread vitrineExtensions into the provider config * * Unlike login-config there is NO package-contract dependency: the seam ships in * @atlashub/smartstack (>= 3.55.0), so this works as soon as that version is installed. */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { validate } from './validate.js'; import { generate, injectVitrineExtensions, snippetForManualWiring, countSections, } from './generate.js'; import { deepMerge } from '../../../lib/json-merge.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../lib/output.js'; const COMMAND = 'scaffold-vitrine'; async function main(): Promise { const { values } = parseArgs({ options: { spec: { type: 'string' }, dry_run: { type: 'boolean', default: false }, }, strict: true, }); 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) { printEnvelope(failGenerate(COMMAND, v.errors)); process.exit(1); } const spec = v.spec; const layout = v.layout; const projectRoot = resolve(spec.projectPath); const files = generate(spec, layout); const warnings = [...v.warnings]; if (v.versionWarning) warnings.push(v.versionWarning); const nextSteps: string[] = []; const pagesRegistered = (spec.pages ?? []).map((p) => ({ path: p.path, layout: p.layout })); if (values.dry_run) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map((f) => `${f.path} [${f.strategy}]`), homeOverridden: !!spec.home?.enabled, pagesRegistered, sectionsGenerated: countSections(spec), webDir: layout.webDir, smartStackVersion: v.smartStackVersion, }, warnings, }), ); process.exit(0); } const filesCreated: string[] = []; const filesModified: string[] = []; for (const file of files) { const abs = resolve(join(projectRoot, file.path)); const exists = existsSync(abs); if (file.strategy === 'deep-merge-json') { let existing: unknown = {}; if (exists) { try { existing = JSON.parse(readFileSync(abs, 'utf-8')); } catch { existing = {}; } } const merged = deepMerge(existing, JSON.parse(file.content)); const out = `${JSON.stringify(merged, null, 2)}\n`; if (exists && !spec.force && readFileSync(abs, 'utf-8') === out) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, out, 'utf-8'); (exists ? filesModified : filesCreated).push(abs); } else if (file.strategy === 'skip-if-exists') { if (exists && !spec.force) continue; // dev-owned — preserve edits if (exists && readFileSync(abs, 'utf-8') === file.content) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); (exists ? filesModified : filesCreated).push(abs); } else { // overwrite (generated wiring) if (exists && !spec.force && readFileSync(abs, 'utf-8') === file.content) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); (exists ? filesModified : filesCreated).push(abs); } } // Wire main.tsx (import the generated module + spread vitrineExtensions). const mainAbs = resolve(join(projectRoot, layout.webDir, 'src', 'main.tsx')); let providerWired = false; let providerSkipped = false; if (!existsSync(mainAbs)) { providerSkipped = true; warnings.push(`main.tsx not found at ${layout.webDir}/src/main.tsx — wiring skipped.`); nextSteps.push(snippetForManualWiring()); } else { const src = readFileSync(mainAbs, 'utf-8'); const { content, status } = injectVitrineExtensions(src); if (status === 'applied') { writeFileSync(mainAbs, content, 'utf-8'); filesModified.push(mainAbs); providerWired = true; } else if (status === 'unchanged') { providerWired = true; } else if (status === 'skipped-customised') { providerSkipped = true; warnings.push('main.tsx is marked `// @customised` — wiring skipped.'); nextSteps.push(snippetForManualWiring()); } else { providerSkipped = true; warnings.push('Could not find `extensions: {}` in main.tsx — wiring skipped (was it customized?).'); nextSteps.push(snippetForManualWiring()); } } // nextSteps. if (!v.smartStackVersion) { nextSteps.push( 'Could not read the installed @atlashub/smartstack version — run `npm install` in the web app and ensure it is >= 3.55.0 (public-route seam).', ); } if (spec.languages.length > 1) { const others = spec.languages .slice(1) .map((l) => `${layout.webDir}/src/vitrine/locales/${l}/vitrine.json`) .join(', '); nextSteps.push(`Translate the non-primary locale file(s): ${others} (they were seeded with the same copy).`); } if ((spec.pages ?? []).some((p) => p.layout === 'none')) { nextSteps.push( 'White-label pages (layout:"none") render without the SmartStack Header/Footer; they use the generated MarketingHeader/MarketingFooter — edit those to taste.', ); } nextSteps.push('Restart `ss dev` (or the Vite dev server) to see the vitrine. The home is at `/`; presentation pages at their routes.'); if (filesCreated.length === 0 && filesModified.length === 0) { nextSteps.unshift('Vitrine already up-to-date — no changes needed.'); } printEnvelope( generateEnvelope(COMMAND, { data: { homeOverridden: !!spec.home?.enabled, pagesRegistered, sectionsGenerated: countSections(spec), webDir: layout.webDir, smartStackVersion: v.smartStackVersion, providerWired, providerSkipped, fileCount: filesCreated.length + filesModified.length, }, filesCreated, filesModified, warnings, nextSteps, }), ); } void main();