Files
ehoandClaude Sonnet 5 920ce138cb fix(scaffolding): rename create-ssp to create-frontend, fix 2 bugs it surfaced
Renamed scripts/create-ssp.mjs -> create-frontend.mjs (+ its WP-45 doc, npm
script, and every prose/command reference) since "ssp" reads as an acronym
where "create-frontend" says what it does.

Also fixes two real bugs found while running it for real during WP-61:
scripts/ci-local.sh was missing from RENAME_CONTENT_FILES (any --name'd
clone that keeps a backend would break `npm run ci`, still hardcoding
BigRegister.slnx), and plopfile.mjs's `gen:context` insertion into
.dependency-cruiser.js anchored on the `showcase: null,` line, which
create-ssp/create-frontend has already stripped by the time gen:context
runs in the same invocation — silently leaving a freshly scaffolded
context with no CONTEXT_ALLOWED fence entry at all. Re-anchored on the
`const CONTEXT_ALLOWED = {` line instead, which never moves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 23:26:28 +02:00

529 lines
21 KiB
JavaScript

#!/usr/bin/env node
// Mechanises .claude/skills/new-ssp/SKILL.md (WP-45): bootstrap a new self-service portal
// from this repo as a template. Run ONCE, inside a fresh `git clone` of this repo (after
// `npm ci`), not against this repo's own working tree.
//
// It strips the BIG-register business contexts + their wiring, renames BigRegister.* ->
// <Name>.*, re-runs gen:api, and reuses gen:context (WP-44, `plop context`) to seed the new
// portal's first real context. Backend business rules and real branding can't be generated
// from nothing — those steps print an explicit checklist instead of pretending to solve them.
//
// Usage:
// node scripts/create-frontend.mjs --name Kvk --context inschrijving
// node scripts/create-frontend.mjs --name Kvk --context inschrijving --keep registratie --dry-run
//
// --name <PascalName> replaces BigRegister.* everywhere (required)
// --context <name> lowercase Dutch ubiquitous term, passed to `plop context` (required)
// --keep <context> don't strip this one business context yet (temporary worked example)
// --dry-run print planned file operations, touch nothing
// --skip-backend skip gen:api (no .NET SDK available) — prints a reminder instead
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ALL_CONTEXTS = ['registratie', 'herregistratie', 'brief', 'showcase'];
function parseArgs(argv) {
const out = { dryRun: false, skipBackend: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--name') out.name = argv[++i];
else if (a === '--context') out.context = argv[++i];
else if (a === '--keep') out.keep = argv[++i];
else if (a === '--dry-run') out.dryRun = true;
else if (a === '--skip-backend') out.skipBackend = true;
else usageAndExit(`Unknown argument: ${a}`);
}
return out;
}
function usageAndExit(message) {
if (message) console.error(message + '\n');
console.error(
'Usage: node scripts/create-frontend.mjs --name <PascalName> --context <lowercase-term> ' +
'[--keep <context>] [--dry-run] [--skip-backend]',
);
process.exit(1);
}
function section(title) {
console.log(`\n\x1b[1;36m▶ ${title}\x1b[0m`);
}
const abs = (p) => path.join(ROOT, p);
const readFile = (p) => fs.readFileSync(abs(p), 'utf8');
function writeFile(p, content, args) {
if (args.dryRun) {
console.log(` [dry-run] would write ${p}`);
return;
}
fs.writeFileSync(abs(p), content);
console.log(` wrote ${p}`);
}
function deletePath(p, args) {
if (!fs.existsSync(abs(p))) return;
if (args.dryRun) {
console.log(` [dry-run] would delete ${p}`);
return;
}
fs.rmSync(abs(p), { recursive: true, force: true });
console.log(` deleted ${p}`);
}
function movePath(from, to, args) {
if (!fs.existsSync(abs(from))) return;
if (args.dryRun) {
console.log(` [dry-run] would move ${from} -> ${to}`);
return;
}
fs.mkdirSync(path.dirname(abs(to)), { recursive: true });
fs.renameSync(abs(from), abs(to));
console.log(` moved ${from} -> ${to}`);
}
function run(cmd, cmdArgs, args) {
if (args.dryRun) {
console.log(` [dry-run] would run: ${cmd} ${cmdArgs.join(' ')}`);
return;
}
console.log(` running: ${cmd} ${cmdArgs.join(' ')}`);
execFileSync(cmd, cmdArgs, { cwd: ROOT, stdio: 'inherit' });
}
const kebabCase = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
const pascalCase = (name) =>
name
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('');
// --- 1. strip business contexts ---------------------------------------------
function stripContexts(names, args) {
for (const name of names) {
deletePath(`src/app/${name}`, args);
pruneDependencyCruiser(name, args);
pruneTsconfig(name, args);
const removedPaths = pruneRoutes(name, args);
pruneNavLinks(removedPaths, args);
if (name === 'registratie') {
deletePath('src/app/shared/ui/debug-state', args);
patchShellComponent(args);
}
if (name === 'showcase') {
deletePath('scripts/gen-snippets.mjs', args);
pruneGenSnippetsWiring(args);
}
}
const keptCaps = pruneCapabilities(args);
syncMeAdapterKnown(keptCaps, args);
syncMeAdapterSpec(keptCaps, args);
pruneStoryCaps(keptCaps, args);
}
function pruneDependencyCruiser(name, args) {
const file = '.dependency-cruiser.js';
const content = readFile(file);
// name is this local generator's own CLI arg (the operator's own context name), never
// external/attacker input.
const re = new RegExp(`^\\s*${name}:\\s*(?:\\[[^\\]]*\\]|null),.*\\n`, 'm'); // nosemgrep
const next = content.replace(re, '');
if (next === content) {
console.log(` (no CONTEXT_ALLOWED entry for '${name}' in ${file} — already gone?)`);
return;
}
writeFile(file, next, args);
}
function pruneTsconfig(name, args) {
const file = 'tsconfig.json';
const content = readFile(file);
// name is this local generator's own CLI arg (the operator's own context name), never
// external/attacker input.
const re = new RegExp(`^\\s*"@${name}/\\*":\\s*\\["src/app/${name}/\\*"\\],\\n`, 'm'); // nosemgrep
const next = content.replace(re, '');
if (next === content) {
console.log(` (no @${name}/* alias in ${file} — already gone?)`);
return;
}
writeFile(file, next, args);
}
// Route children are 6-space-indented multi-line objects, e.g.:
// {
// path: 'registratie',
// ...
// },
// The two anchors that must never move — `{ path: '', ... }` and the `**` wildcard — are
// single-line (per plopfile.mjs's own "lines that never move" convention), so this pattern
// (which requires the closing `},` at the START of its own line) never matches them.
const ROUTE_BLOCK = /^ {6}\{\n[\s\S]*?\n {6}\},\n/gm;
/** Returns the `path:` values of every route block removed, so nav-link arrays elsewhere
(which reference routes by path string, not import alias) can be pruned to match. */
function pruneRoutes(name, args) {
const file = 'src/app/app.routes.ts';
const content = readFile(file);
// A route can reference a context by import alias regardless of its own `path:` — e.g.
// `beheer/zaken` imports `@registratie/ui/admin-cases.page`. Match on the import, not the
// route's own path segment.
const marker = name === 'showcase' ? `'./showcase/` : `@${name}/`;
const removedPaths = [];
const next = content.replace(ROUTE_BLOCK, (block) => {
if (!block.includes(marker)) return block;
if (block.includes(`path: 'dashboard'`)) return block; // handled by repointDashboard
const m = block.match(/path: '([^']+)'/);
if (m) removedPaths.push(m[1]);
return '';
});
if (!removedPaths.length) {
console.log(` (no ${file} route block referenced ${marker})`);
return removedPaths;
}
writeFile(file, next, args);
return removedPaths;
}
/** site-header.component.ts's NAV_ITEMS and admin-links.ts's ADMIN_LINKS reference routes by
path string, not import alias — so they don't get caught by pruneRoutes. Strip any entry
whose `to:` matches a route path that was just removed. */
function pruneNavLinks(removedPaths, args) {
if (!removedPaths.length) return;
const pathSet = new Set(removedPaths.map((p) => `/${p}`));
const headerFile = 'src/app/shared/layout/site-header/site-header.component.ts';
if (fs.existsSync(abs(headerFile))) {
const content = readFile(headerFile);
const next = content
.split('\n')
.filter((line) => {
const m = line.match(/to: '([^']+)'/);
return !(m && pathSet.has(m[1]));
})
.join('\n');
if (next !== content) writeFile(headerFile, next, args);
}
const adminFile = 'src/app/shared/layout/admin-links.ts';
if (fs.existsSync(abs(adminFile))) {
const content = readFile(adminFile);
const ADMIN_LINK_BLOCK = /^ {2}\{\n[\s\S]*?\n {2}\},\n/gm;
const next = content.replace(ADMIN_LINK_BLOCK, (block) => {
const toMatch = block.match(/to: '([^']+)'/);
return !toMatch || !pathSet.has(toMatch[1]) ? block : '';
});
if (next !== content) writeFile(adminFile, next, args);
}
}
/** Capability.ts's union members are plain string literals (no import), so a stripped
context's caps survive deletion silently. Rather than tracking which admin-link entry
"owned" which cap (a capability can gate more than one page — 'cases:manage' gates both
/beheer/zaken, which registratie owns, and /beheer/audit, which survives it), recompute
actual usage once every route/nav-link prune is done: a cap survives iff some remaining
file still references it as a string literal. Call once, after stripContexts' loop.
(Under --dry-run nothing was actually written above, so this reads pre-prune content —
an accepted approximation for a preview flag.) */
function pruneCapabilities(args) {
const file = 'src/app/shared/domain/capability.ts';
if (!fs.existsSync(abs(file))) return [];
const usageFiles = ['src/app/app.routes.ts', 'src/app/shared/layout/admin-links.ts'];
const usedCaps = new Set();
for (const f of usageFiles) {
if (!fs.existsSync(abs(f))) continue;
for (const m of readFile(f).matchAll(/'([a-z]+:[a-z]+)'/g)) usedCaps.add(m[1]);
}
const content = readFile(file);
const lines = content.split('\n');
const keptCaps = [];
const kept = lines.filter((line) => {
const m = line.match(/^\s*\|\s*'([^']+)'/);
if (!m) return true;
if (!usedCaps.has(m[1])) return false;
keptCaps.push(m[1]);
return true;
});
// Re-terminate the union type: only the last `|` line should carry the trailing `;`.
let lastIdx = -1;
for (let i = 0; i < kept.length; i++) {
if (/^\s*\|\s*'/.test(kept[i])) {
kept[i] = kept[i].replace(/;\s*$/, '');
lastIdx = i;
}
}
if (lastIdx >= 0) kept[lastIdx] += ';';
const next = kept.join('\n');
if (next !== content) writeFile(file, next, args);
return keptCaps;
}
/** me.adapter.ts's KNOWN array is documented as "the current principal's capabilities" — by
definition the same set Capability allows, so regenerate it to match exactly rather than
treating it as an independent usage site (it would otherwise keep now-invalid literals a
plain string-literal array doesn't get flagged for by the type checker until `KNOWN` is
actually assigned, which it is — `readonly Capability[]` — so this is build-breaking, not
cosmetic, if left stale). */
function syncMeAdapterKnown(keptCaps, args) {
const file = 'src/app/shared/infrastructure/me.adapter.ts';
if (!fs.existsSync(abs(file))) return;
const content = readFile(file);
const next = content.replace(
/const KNOWN: readonly Capability\[\] = \[\n[\s\S]*?\n\];/,
`const KNOWN: readonly Capability[] = [\n${keptCaps.map((c) => ` '${c}',`).join('\n')}\n];`,
);
if (next !== content) writeFile(file, next, args);
}
/** me.adapter.spec.ts hardcodes example capability strings as test fixtures (not typed
against Capability, so tsc/lint don't catch drift — only actually running the suite
surfaces it, as a plain assertion failure). Retarget the stale ones at surviving caps
(cycling through keptCaps so a multi-example test still gets distinct values) and drop
the one test that's about a capability tied entirely to a stripped feature. */
function syncMeAdapterSpec(keptCaps, args) {
const file = 'src/app/shared/infrastructure/me.adapter.spec.ts';
if (!fs.existsSync(abs(file)) || !keptCaps.length) return;
let content = readFile(file);
content = content.replace(
/\n {2}it\('recognizes the admin org-template capability[\s\S]*?\n {2}\}\);\n/,
'\n',
);
const stale = ['brief:approve', 'brief:reject', 'brief:send', 'orgtemplate:edit'];
stale.forEach((s, i) => {
content = content.replaceAll(`'${s}'`, `'${keptCaps[i % keptCaps.length]}'`);
});
writeFile(file, content, args);
}
/** site-header.stories.ts fixtures a couple of admin caps by hand for its "with admin nav"
story — filter out any that no longer exist, same reasoning as syncMeAdapterKnown. */
function pruneStoryCaps(keptCaps, args) {
const file = 'src/app/shared/layout/site-header/site-header.stories.ts';
if (!fs.existsSync(abs(file))) return;
const keptSet = new Set(keptCaps);
const content = readFile(file);
const next = content.replace(/withCaps\(\[([^\]]*)\]\)/g, (whole, inner) => {
const kept = [...inner.matchAll(/'([^']+)'/g)].map((m) => m[1]).filter((c) => keptSet.has(c));
return `withCaps([${kept.map((c) => `'${c}'`).join(', ')}])`;
});
if (next !== content) writeFile(file, next, args);
}
function patchShellComponent(args) {
const file = 'src/app/shared/layout/shell/shell.component.ts';
const content = readFile(file);
const next = content
.replace(/^import \{ DebugStateComponent \}.*\n/m, '')
.replace(/^\s*DebugStateComponent,\n/m, '')
.replace(/^\s*@if \(isDev\) \{\n\s*<app-debug-state \/>\n\s*\}\n/m, '');
writeFile(file, next, args);
}
function pruneGenSnippetsWiring(args) {
const pkgPath = 'package.json';
const pkg = JSON.parse(readFile(pkgPath));
delete pkg.scripts['gen:snippets'];
writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', args);
for (const file of ['.github/workflows/ci.yml', 'scripts/ci-local.sh']) {
const content = readFile(file);
const next = content
.replace(/^\s*#.*[Ss]howcase snippets.*\n/m, '')
.replace(/^.*npm run gen:snippets.*\n/m, '');
writeFile(file, next, args);
}
}
// --- 2. rename BigRegister -> <Name> -----------------------------------------
const RENAME_CONTENT_FILES = [
'docker-compose.yml',
'package.json',
'.github/workflows/ci.yml',
'playwright.config.ts',
'README.md',
'backend/README.md',
'scripts/ci-local.sh',
];
const SKIP_DIRS = new Set(['bin', 'obj', 'node_modules', '.git']);
/** Recursively rename any BigRegister-named file/dir and replace BigRegister in file content. */
function renameAndReplaceInTree(dirRel, name, args) {
const dirAbs = abs(dirRel);
if (!fs.existsSync(dirAbs)) return;
for (const entry of fs.readdirSync(dirAbs, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const childRel = path.join(dirRel, entry.name);
if (entry.isDirectory()) {
renameAndReplaceInTree(childRel, name, args);
continue;
}
let fileRel = childRel;
if (entry.name.includes('BigRegister')) {
const renamed = path.join(dirRel, entry.name.replaceAll('BigRegister', name));
movePath(fileRel, renamed, args);
fileRel = renamed;
}
if (args.dryRun) continue;
const content = fs.readFileSync(abs(fileRel), 'utf8');
if (content.includes('BigRegister')) {
writeFile(fileRel, content.replaceAll('BigRegister', name), args);
}
}
}
function renameBigRegister(name, args) {
const kebab = kebabCase(name);
for (const file of RENAME_CONTENT_FILES) {
if (!fs.existsSync(abs(file))) continue;
const content = readFile(file);
const next = content.replaceAll('BigRegister', name).replaceAll('bigregister', kebab);
if (next !== content) writeFile(file, next, args);
}
movePath('backend/BigRegister.slnx', `backend/${name}.slnx`, args);
movePath('backend/src/BigRegister.Api', `backend/src/${name}.Api`, args);
movePath('backend/tests/BigRegister.Tests', `backend/tests/${name}.Tests`, args);
if (!args.dryRun) {
for (const dir of [`backend/src/${name}.Api`, `backend/tests/${name}.Tests`]) {
renameAndReplaceInTree(dir, name, args);
}
const slnx = `backend/${name}.slnx`;
if (fs.existsSync(abs(slnx))) {
const content = readFile(slnx);
writeFile(slnx, content.replaceAll('BigRegister', name), args);
}
}
}
// --- 3/4. checklists (deliberately not scripted — see WP-45 plan) -----------
function backendChecklist(name, checklist) {
checklist.push(
`Backend Domain/Contracts/Data content is BIG-register business logic and can't be ` +
`auto-generated for a new register (renamed to ${name}.* only, content untouched). ` +
`Rewrite, verifying \`dotnet test\` stays green after each step:`,
" 1. Contracts/Dtos.cs + Mappers.cs -> your register's wire shapes",
' 2. Domain/{Diplomas,Documents,Intake,Letters,People,Registrations,Submissions}/* ' +
"-> your register's rules (Stamdata/StamdataFile.cs + StamdataTable.cs stay generic)",
' 3. Stamdata/{Beroep,Opleiding,Specialisme,ProfessionMapping}.cs + their *.json ' +
'-> your reference data; update StamdataCatalog.All to match',
' 4. Data/SeedData.cs -> fixtures for the new Domain/* shapes',
" 5. Zgw/ -> delete if you don't integrate with OpenZaak/ZGW, else adapt",
` 6. tests/${name}.Tests/* -> mostly assert BIG-specific rules today; treat as a shape ` +
'reference (WebApplicationFactory harness, ProblemDetails assertions), rewrite content',
);
console.log(' (printed to the final checklist — not scriptable)');
}
function brandingChecklist(args, checklist) {
const kebab = kebabCase(args.name);
const file = 'src/index.html';
const content = readFile(file);
const next = content
.replace(
/<link rel="stylesheet" href="cibg-huisstijl\/css\/huisstijl\.min\.css" \/>/,
`<link rel="stylesheet" href="${kebab}-huisstijl/css/huisstijl.min.css" />`,
)
.replace(/<title>.*<\/title>/, `<title>${args.name}</title>`)
.replace(' class="brand--cibg"', '');
writeFile(file, next, args);
const placeholder = `public/${kebab}-huisstijl/css/huisstijl.min.css`;
if (!args.dryRun) {
fs.mkdirSync(path.dirname(abs(placeholder)), { recursive: true });
if (!fs.existsSync(abs(placeholder))) {
fs.writeFileSync(abs(placeholder), '/* placeholder — vendor your real house style here */\n');
}
}
console.log(` wrote placeholder ${placeholder}`);
checklist.push(
`Branding is only placeholder-swapped (${file}'s <link>, <title>). Vendor your real ` +
`house-style CSS into public/${kebab}-huisstijl/, then re-point the ~54 --rhc-* ` +
"token definitions in src/styles.scss's :root block to your palette (ADR-0003 bridge " +
'pattern — keep the --rhc-* names, only their right-hand values change). Run ' +
'`npm run check:tokens` afterward.',
);
}
// --- 6. re-point the dashboard placeholder after gen:context runs -----------
function repointDashboard(contextName, registratieWasStripped, args) {
if (!registratieWasStripped) return;
const file = 'src/app/app.routes.ts';
const content = readFile(file);
const pageClass = `${pascalCase(contextName)}Page`;
const next = content.replace(
/loadComponent: \(\) => import\('@registratie\/ui\/dashboard\.page'\)\.then\(\(m\) => m\.DashboardPage\),/,
`// TODO(create-frontend): stopgap landing page — point this at a real overview once you have one.\n` +
` loadComponent: () =>\n` +
` import('@${contextName}/ui/${contextName}.page').then((m) => m.${pageClass}),`,
);
if (next === content) {
console.log(
` (dashboard route's loadComponent didn't match the expected pattern — check ${file} by hand)`,
);
return;
}
writeFile(file, next, args);
}
// --- checklist + main --------------------------------------------------------
function printChecklist(checklist, args) {
section('Next steps (not scriptable — read carefully)');
for (const item of checklist) console.log(item);
console.log(
'\nAlso update docs/CLAUDE.md, ARCHITECTURE.md, docs/reference/scaffolding.md, and ' +
'e2e/*.spec.ts (still BIG-register user-flow tests) once the backend content above ' +
'is real. Then run `npm run ci` end-to-end.',
);
if (args.dryRun) console.log('\n(--dry-run: nothing above was actually written.)');
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.name || !args.context) usageAndExit('--name and --context are required.');
const checklist = [];
const toStrip = ALL_CONTEXTS.filter((c) => c !== args.keep);
section('1/6 Strip business contexts');
stripContexts(toStrip, args);
section(`2/6 Rename BigRegister -> ${args.name}`);
renameBigRegister(args.name, args);
section('3/6 Backend re-seed (manual)');
backendChecklist(args.name, checklist);
section('4/6 Branding (mostly manual)');
brandingChecklist(args, checklist);
section('5/6 Regenerate API client');
if (args.skipBackend) {
console.log(' skipped (--skip-backend)');
checklist.push('Run `npm run gen:api` once a .NET SDK is available.');
} else {
run('npm', ['run', 'gen:api'], args);
}
section('6/6 Seed first context via gen:context');
run('npx', ['plop', 'context', args.context], args);
repointDashboard(args.context, toStrip.includes('registratie'), args);
printChecklist(checklist, args);
}
main();