diff --git a/.claude/skills/new-ssp/SKILL.md b/.claude/skills/new-ssp/SKILL.md index 810aa5b..0c6e835 100644 --- a/.claude/skills/new-ssp/SKILL.md +++ b/.claude/skills/new-ssp/SKILL.md @@ -9,38 +9,89 @@ The template's value is the **enforced architecture** (layer fences, token gate, a11y gate, API-drift gate) and the shared building blocks — not the BIG-register business content. Keep the machinery, replace the domain. +## Run the script + +Clone this repo, `npm ci`, then mechanise the mechanical parts (WP-45): + +```bash +node scripts/create-ssp.mjs --name Kvk --context inschrijving +``` + +`--name` (PascalCase) replaces `BigRegister.*` everywhere; `--context` (lowercase Dutch +ubiquitous term) is passed straight to `gen:context` (`plop context`, WP-44) to seed the new +portal's first real context. Add `--dry-run` to preview file operations first, `--keep +` to leave one business context in place temporarily as a worked example, and +`--skip-backend` if no .NET SDK is available yet (skips `gen:api`). + +It strips the four business contexts and their wiring, renames the backend, re-runs +`gen:api`, and seeds the first context — then **prints a checklist** for what it deliberately +doesn't script: backend business rules and real branding can't be generated from nothing. +Work through that checklist, keeping the GREEN gate below passing at every step. + ## Keep as-is - `src/app/shared/` — kernel (`fp.ts`), application (`remote-data`, `store`, - `submit`), ui atoms/molecules, layout templates, upload subtree. -- Tooling: `eslint.config.mjs`, `scripts/check-tokens.sh`, `.github/workflows/ci.yml`, + `submit`), ui atoms/molecules, layout templates, upload subtree — **except** + `shared/ui/debug-state/`, which the script deletes (see below). +- Tooling: `eslint.config.mjs`, `.dependency-cruiser.js` (edited by the script, not + hand-stripped — see below), `scripts/check-tokens.sh`, `.github/workflows/ci.yml`, `nswag.json`, `.storybook/`, `proxy.conf.json`, `.npmrc` (`legacy-peer-deps` — and never `npm audit fix --force`, it downgrades Angular). - `src/app/auth/` (fake auth shell) and `src/app/shared/infrastructure/scenario.interceptor.ts` (dev-only). - `docs/reference/architecture/` ADRs 0001–0003 — the decisions still apply; amend, don't delete. - `CLAUDE.md`, `docs/reference/architecture/ARCHITECTURE.md`, `docs/reference/fp-tea-atomic-design.md` — update names/examples as contexts change. - `.claude/skills/` — these recipes are the point of the template. +- `src/app/beheer/` — its frontend is genuinely generic (data-driven off a `StamdataTable`/ + `AuditEntry` shape, nothing BIG-specific). Its _backend_ Stamdata catalog is not — see below. -## Strip / replace +## Strip / replace — what the script does -- Business contexts `registratie/`, `herregistratie/`, `brief/`, and `showcase/`: - delete or keep one slice temporarily as the worked example while building the - first real context (**new-context** + **new-feature** skills). If deleted, update - the worked-example paths in these skills to the new flagship context. -- `app.routes.ts` routes and `tsconfig.json` aliases for removed contexts, plus - their eslint blocks in `eslint.config.mjs`. +- Business contexts `registratie/`, `herregistratie/`, `brief/`, `showcase/`: deleted (or one + kept temporarily via `--keep` as the worked example while building the first real context — + **new-context** + **new-feature** skills; if kept, update the worked-example paths in those + skills to the new flagship context once you drop it for real). +- `app.routes.ts` route blocks and `tsconfig.json` aliases for removed contexts, and their + `CONTEXT_ALLOWED` entry in **`.dependency-cruiser.js`** — boundary rules moved there in + WP-38 and are no longer in `eslint.config.mjs` (which only keeps `no-explicit-any` + a11y + template rules). Route stripping matches on the _import alias_ a route uses, not its own + path segment — `beheer/zaken` imports `@registratie/ui/admin-cases.page` and gets dropped + along with `registratie` even though its own path doesn't say so. +- `src/app/shared/ui/debug-state/` (the dev `⚙ state` panel): imports + `@registratie/application/big-profile.store` directly and is the one path + `.dependency-cruiser.js`'s `shared-no-features` rule exempts — there's no generic way to + re-target it at an arbitrary new context, so it's deleted alongside `registratie`, along + with its three wiring lines in `shell.component.ts` (import, `imports:` entry, template tag). +- The `dashboard` route is **not** deleted even though it currently imports + `@registratie/ui/dashboard.page` — too much else hardcodes `/dashboard` (login's post-auth + redirect, `authGuard`'s fallback, header nav/logo, breadcrumb trail, several stories/specs). + The script rewrites its `loadComponent` to point at the freshly scaffolded `--context` page + instead (a `TODO(create-ssp)` stopgap landing page, not a real overview). +- `scripts/gen-snippets.mjs` (showcase-only) + its `package.json` script entry + its CI/ + `ci-local.sh` "showcase snippets drift" steps: deleted alongside `showcase/` — they run + unconditionally, so leaving them breaks `npm run ci` immediately once `showcase/` is gone. - Backend: keep the skeleton (`Program.cs` minimal-API style, ProblemDetails 422, `X-Correlation-Id` audit line, `/api/v1` versioning, `Contracts/`/`Domain/`/`Data/` - split, test project) — replace `Data/SeedData.cs`, `Domain/*` rules, and - `Contracts/*` DTOs with the new domain's. Rename the solution/projects from - `BigRegister.*` (also update `package.json` `gen:api` and `ci.yml` paths). + split, test project, and the generic `Stamdata/StamdataFile.cs`+`StamdataTable.cs` + reflection-driven `/stamdata` endpoint machinery, ADR-0004) — replace `Data/SeedData.cs`, + `Domain/*` rules, `Contracts/*` DTOs, and the three concrete Stamdata catalog entries + (`Beroep`/`Opleiding`/`Specialisme`/`ProfessionMapping` + their JSON) with the new + register's. The script renames the solution/projects from `BigRegister.*` (and updates + `package.json`'s `gen:api`, `ci.yml`'s paths, `docker-compose.yml`) — it does not rewrite + business content; that's the printed checklist. - Regenerate the seam: `npm run gen:api` (commits `backend/swagger.json` + - `src/app/shared/infrastructure/api-client.ts`). -- Branding: `public/cibg-huisstijl/` + the token bridge in `src/styles.scss` — for a - different house style, swap the vendored CSS and re-point the `--rhc-*` bridge - (ADR-0003 pattern: bridge, don't rewrite tokens). + `src/app/shared/infrastructure/api-client.ts`) — only reflects a new shape once the backend + content above is actually rewritten. +- Branding: the script swaps `src/index.html`'s stylesheet `` + `` to a + placeholder path and creates an empty `public/<name>-huisstijl/` — it cannot generate a + real house style. Vendor your CSS there, then re-point the `--rhc-*` bridge in + `src/styles.scss` (ADR-0003 pattern: bridge, don't rewrite tokens), then `npm run check:tokens`. - `docs/project/backlog/` WPs, PRDs, and memory-specific docs — new portal, new backlog (keep `docs/project/backlog/README.md`'s WP process/template if you like the workflow). + `docs/reference/scaffolding.md` also names `BigRegister.Api` in prose — update by hand. +- `e2e/*.spec.ts` (`smoke.spec.ts`, `brief-v2.spec.ts`, `error-state.spec.ts`): full + BIG-register user-flow tests (BSN login → registration wizard → submission assertions). + Not touched by the script (they don't block `npm run ci` — the `e2e` job runs separately) + but are 100% stale business content; rewrite once you have real flows to test. ## Verify — the GREEN gate must pass at every step @@ -51,5 +102,5 @@ cd backend && dotnet test && cd .. npm run gen:api && git diff --exit-code backend/swagger.json src/app/shared/infrastructure/api-client.ts ``` -Strip incrementally and keep this green — the fences are only worth having if they -never go red. +Or just `npm run ci` for the non-storybook subset. Strip incrementally and keep this green +— the fences are only worth having if they never go red. diff --git a/package.json b/package.json index 44ce425..99daaa4 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "gen:value-object": "plop value-object", "gen:form-machine": "plop form-machine", "gen:context": "plop context", + "create-ssp": "node scripts/create-ssp.mjs", "serve:i18n": "ng build --configuration development --localize && node scripts/serve-i18n.mjs", "ci": "bash scripts/ci-local.sh", "e2e": "playwright test", diff --git a/scripts/create-ssp.mjs b/scripts/create-ssp.mjs new file mode 100644 index 0000000..e6ffdb0 --- /dev/null +++ b/scripts/create-ssp.mjs @@ -0,0 +1,523 @@ +#!/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-ssp.mjs --name Kvk --context inschrijving +// node scripts/create-ssp.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-ssp.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); + const re = new RegExp(`^\\s*${name}:\\s*(?:\\[[^\\]]*\\]|null),.*\\n`, 'm'); + 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); + const re = new RegExp(`^\\s*"@${name}/\\*":\\s*\\["src/app/${name}/\\*"\\],\\n`, 'm'); + 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', +]; + +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}`) + .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-ssp): 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();