#!/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.* -> // .*, 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 replaces BigRegister.* everywhere (required) // --context lowercase Dutch ubiquitous term, passed to `plop context` (required) // --keep 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 --context ' + '[--keep ] [--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*\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 -> ----------------------------------------- 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( //, ``, ) .replace(/.*<\/title>/, `<title>${args.name}`) .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 , ). 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();