feat(dx): create-ssp bootstrap generator (WP-45)
Mechanises the new-ssp skill's manual fork-and-strip recipe into a runnable script: strips the four business contexts and every wiring point that references them (routes, tsconfig aliases, dependency-cruiser boundaries, nav links, capability union, the debug-state panel, showcase snippet tooling), renames BigRegister.* throughout including the backend solution/projects, re-runs gen:api, and reuses gen:context (WP-44) to seed the new portal's first context. Backend business rules and real branding can't be generated from nothing, so those print an explicit checklist instead of pretending to be automated. Verified end-to-end against an isolated clone (not this working tree): lint, dep:check, check:tokens, all frontend tests, ng build, dotnet build/test on the renamed backend, and gen:api against it all pass clean with zero remaining references to the stripped contexts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
<context>` 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 `<link>` + `<title>` 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}</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-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();
|
||||
Reference in New Issue
Block a user