Local semgrep run found 25 findings (not the WP's remembered 27 — already-stale by the time this ran): dependabot cooldown, npm min-release-age, every GitHub Action pinned to a full commit SHA (dependabot's existing github-actions ecosystem entry keeps these current), and 2 detect-non-literal-regexp findings in e2e/create-ssp.mjs suppressed as false positives (non-attacker-controlled input: a test's own captured version number, a local generator's CLI arg). `semgrep scan` now runs with `--error`, a real blocking gate instead of report-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
528 lines
21 KiB
JavaScript
528 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-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);
|
|
// 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',
|
|
];
|
|
|
|
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();
|