@tantainnovative/create-ndpr 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/index.mjs CHANGED
@@ -1,638 +1,478 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { createInterface } from 'readline';
3
+ import { createInterface } from 'node:readline';
4
4
  import {
5
5
  existsSync,
6
6
  mkdirSync,
7
- writeFileSync,
8
7
  readFileSync,
9
- readdirSync,
10
- statSync,
11
- } from 'fs';
12
- import { join, dirname, resolve } from 'path';
13
- import { fileURLToPath } from 'url';
8
+ realpathSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
14
13
 
15
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
15
  const TEMPLATES_DIR = join(__dirname, '..', 'templates');
17
16
  const CWD = process.cwd();
18
-
19
- // ---------------------------------------------------------------------------
20
- // ANSI colour helpers (no external deps)
21
- // ---------------------------------------------------------------------------
17
+ // Keep this exact pin synchronized with the repository root package version.
18
+ const TOOLKIT_VERSION = '6.0.0';
19
+ const FORCE = process.argv.slice(2).includes('--force');
22
20
 
23
21
  const c = {
24
- reset: '\x1b[0m',
25
- bold: '\x1b[1m',
26
- dim: '\x1b[2m',
27
- green: '\x1b[32m',
28
- cyan: '\x1b[36m',
29
- yellow: '\x1b[33m',
30
- blue: '\x1b[34m',
31
- magenta: '\x1b[35m',
32
- red: '\x1b[31m',
33
- white: '\x1b[37m',
22
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[32m',
23
+ cyan: '\x1b[36m', yellow: '\x1b[33m', red: '\x1b[31m',
34
24
  };
35
-
36
- const bold = (s) => `${c.bold}${s}${c.reset}`;
37
- const green = (s) => `${c.green}${s}${c.reset}`;
38
- const cyan = (s) => `${c.cyan}${s}${c.reset}`;
39
- const yellow = (s) => `${c.yellow}${s}${c.reset}`;
40
- const dim = (s) => `${c.dim}${s}${c.reset}`;
41
- const red = (s) => `${c.red}${s}${c.reset}`;
42
-
43
- // ---------------------------------------------------------------------------
44
- // Banner
45
- // ---------------------------------------------------------------------------
25
+ const colour = (code, value) => `${code}${value}${c.reset}`;
26
+ const bold = (value) => colour(c.bold, value);
27
+ const green = (value) => colour(c.green, value);
28
+ const cyan = (value) => colour(c.cyan, value);
29
+ const yellow = (value) => colour(c.yellow, value);
30
+ const dim = (value) => colour(c.dim, value);
31
+ const red = (value) => colour(c.red, value);
46
32
 
47
33
  function printBanner() {
48
34
  console.log();
49
- console.log(bold(cyan(' ███╗ ██╗██████╗ ██████╗ ██████╗ ')));
50
- console.log(bold(cyan(' ████╗ ██║██╔══██╗██╔══██╗██╔══██╗')));
51
- console.log(bold(cyan(' ██╔██╗ ██║██║ ██║██████╔╝██████╔╝')));
52
- console.log(bold(cyan(' ██║╚██╗██║██║ ██║██╔═══╝ ██╔══██╗')));
53
- console.log(bold(cyan(' ██║ ╚████║██████╔╝██║ ██║ ██║')));
54
- console.log(bold(cyan(' ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝ ╚═╝')));
55
- console.log();
56
- console.log(bold(' create-ndpr') + dim(' — NDPA compliance scaffolder'));
57
- console.log(dim(' Powered by @tantainnovative/ndpr-toolkit'));
35
+ console.log(bold(cyan(' create-ndpr')) + dim(' — NDPA compliance scaffolder'));
36
+ console.log(dim(` Generates @tantainnovative/ndpr-toolkit@${TOOLKIT_VERSION} integrations`));
37
+ if (FORCE) {
38
+ console.log(yellow(' --force enabled: existing generated files may be replaced.'));
39
+ }
58
40
  console.log();
59
41
  }
60
42
 
61
- // ---------------------------------------------------------------------------
62
- // Stack detection
63
- // ---------------------------------------------------------------------------
64
-
65
43
  function detectStack() {
66
- const detected = {
67
- framework: null, // 'nextjs-app' | 'nextjs-pages' | 'express' | null
68
- orm: null, // 'prisma' | 'drizzle' | null
69
- hasPackageJson: false,
70
- };
71
-
72
- // Check package.json exists
44
+ const detected = { framework: null, orm: null };
73
45
  const pkgPath = join(CWD, 'package.json');
46
+ let dependencies = {};
74
47
  if (existsSync(pkgPath)) {
75
- detected.hasPackageJson = true;
76
48
  try {
77
49
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
78
- const deps = {
79
- ...((pkg.dependencies) || {}),
80
- ...((pkg.devDependencies) || {}),
81
- };
82
-
83
- if (deps['express']) {
84
- detected.framework = 'express';
85
- }
86
-
87
- if (deps['drizzle-orm'] || deps['drizzle-kit']) {
88
- detected.orm = 'drizzle';
89
- }
90
- if (deps['@prisma/client'] || deps['prisma']) {
91
- detected.orm = 'prisma';
92
- }
50
+ dependencies = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
51
+ if (dependencies.express) detected.framework = 'express';
52
+ if (dependencies['drizzle-orm'] || dependencies['drizzle-kit']) detected.orm = 'drizzle';
53
+ if (dependencies['@prisma/client'] || dependencies.prisma) detected.orm = 'prisma';
93
54
  } catch {
94
- // ignore parse errors
55
+ console.log(yellow(' package.json could not be parsed; continuing with manual choices.'));
95
56
  }
96
57
  }
97
58
 
98
- // Next.js detection via config file
99
- const nextConfigs = ['next.config.js', 'next.config.ts', 'next.config.mjs'];
100
- const hasNextConfig = nextConfigs.some((f) => existsSync(join(CWD, f)));
101
- if (hasNextConfig) {
102
- // App Router vs Pages Router: check for app/ directory
103
- const hasAppDir =
104
- existsSync(join(CWD, 'app')) ||
105
- existsSync(join(CWD, 'src', 'app'));
106
- detected.framework = hasAppDir ? 'nextjs-app' : 'nextjs-pages';
59
+ const nextConfigs = ['next.config.js', 'next.config.cjs', 'next.config.mjs', 'next.config.ts'];
60
+ const hasNext = Boolean(dependencies.next) || nextConfigs.some((file) => existsSync(join(CWD, file)));
61
+ if (hasNext) {
62
+ const hasApp = existsSync(join(CWD, 'app')) || existsSync(join(CWD, 'src', 'app'));
63
+ detected.framework = hasApp ? 'nextjs-app' : 'nextjs-pages';
107
64
  }
108
65
 
109
- // ORM detection via project structure (if not already found in deps)
110
66
  if (!detected.orm) {
111
- if (existsSync(join(CWD, 'prisma', 'schema.prisma'))) {
112
- detected.orm = 'prisma';
113
- } else {
114
- const drizzleConfigs = ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs'];
115
- if (drizzleConfigs.some((f) => existsSync(join(CWD, f)))) {
116
- detected.orm = 'drizzle';
117
- }
67
+ if (existsSync(join(CWD, 'prisma', 'schema.prisma'))) detected.orm = 'prisma';
68
+ else if (['ts', 'js', 'mjs', 'cjs'].some((ext) => existsSync(join(CWD, `drizzle.config.${ext}`)))) {
69
+ detected.orm = 'drizzle';
118
70
  }
119
71
  }
120
-
121
72
  return detected;
122
73
  }
123
74
 
124
- // ---------------------------------------------------------------------------
125
- // Readline helpers
126
- // ---------------------------------------------------------------------------
127
-
128
75
  function createRL() {
129
- return createInterface({
130
- input: process.stdin,
131
- output: process.stdout,
132
- });
76
+ return createInterface({ input: process.stdin, output: process.stdout });
133
77
  }
134
-
135
78
  function ask(rl, question) {
136
- return new Promise((resolve) => {
137
- rl.question(question, (answer) => resolve(answer.trim()));
138
- });
79
+ return new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
139
80
  }
140
-
141
- async function askRequired(rl, question, label) {
142
- let value = '';
143
- while (!value) {
144
- value = await ask(rl, question);
145
- if (!value) {
146
- console.log(red(` ${label} is required.`));
147
- }
81
+ async function askValidated(rl, question, label, validate) {
82
+ while (true) {
83
+ const value = await ask(rl, question);
84
+ const problem = validate(value);
85
+ if (!problem) return value;
86
+ console.log(red(` ${label}: ${problem}`));
148
87
  }
149
- return value;
150
88
  }
151
-
152
89
  async function askChoice(rl, question, choices, defaultIndex = 0) {
153
- const lines = choices.map((c, i) => ` ${i + 1}) ${c}`).join('\n');
154
- const prompt = `${question}\n${lines}\n Choice [${defaultIndex + 1}]: `;
90
+ const lines = choices.map((choice, index) => ` ${index + 1}) ${choice}`).join('\n');
155
91
  while (true) {
156
- const raw = await ask(rl, prompt);
92
+ const raw = await ask(rl, `${question}\n${lines}\n Choice [${defaultIndex + 1}]: `);
157
93
  if (!raw) return defaultIndex;
158
- const n = parseInt(raw, 10);
159
- if (!isNaN(n) && n >= 1 && n <= choices.length) return n - 1;
160
- console.log(yellow(` Please enter a number between 1 and ${choices.length}.`));
94
+ const selected = Number.parseInt(raw, 10);
95
+ if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) return selected - 1;
96
+ console.log(yellow(` Enter a number from 1-${choices.length}.`));
161
97
  }
162
98
  }
163
-
164
- async function askCheckboxes(rl, question, options, defaults = []) {
165
- const defaultStr = defaults.length ? ` [default: ${defaults.map((i) => i + 1).join(',')}]` : '';
166
- const lines = options.map((o, i) => ` ${i + 1}) ${o}`).join('\n');
167
- const prompt = `${question}${defaultStr}\n${lines}\n Enter numbers separated by commas (or press Enter for default): `;
168
-
99
+ async function askCheckboxes(rl, question, options, defaults) {
100
+ const lines = options.map((option, index) => ` ${index + 1}) ${option}`).join('\n');
101
+ const defaultText = defaults.map((index) => index + 1).join(',');
169
102
  while (true) {
170
- const raw = await ask(rl, prompt);
103
+ const raw = await ask(
104
+ rl,
105
+ `${question} [default: ${defaultText}]\n${lines}\n Enter comma-separated numbers: `,
106
+ );
171
107
  if (!raw) return defaults;
172
- const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
173
- const indices = [];
174
- let valid = true;
175
- for (const p of parts) {
176
- const n = parseInt(p, 10);
177
- if (isNaN(n) || n < 1 || n > options.length) {
178
- console.log(yellow(` Invalid option: ${p}. Enter numbers from 1-${options.length}.`));
179
- valid = false;
180
- break;
181
- }
182
- indices.push(n - 1);
108
+ const values = raw.split(',').map((value) => Number.parseInt(value.trim(), 10));
109
+ if (values.every((value) => Number.isInteger(value) && value >= 1 && value <= options.length)) {
110
+ return [...new Set(values.map((value) => value - 1))];
183
111
  }
184
- if (valid) return [...new Set(indices)];
112
+ console.log(yellow(` Enter only numbers from 1-${options.length}.`));
185
113
  }
186
114
  }
187
-
188
115
  async function askYesNo(rl, question, defaultYes = true) {
189
- const hint = defaultYes ? '[Y/n]' : '[y/N]';
190
- const answer = await ask(rl, `${question} ${hint}: `);
191
- if (!answer) return defaultYes;
192
- return answer.toLowerCase().startsWith('y');
116
+ const answer = await ask(rl, `${question} ${defaultYes ? '[Y/n]' : '[y/N]'}: `);
117
+ return answer ? answer.toLowerCase().startsWith('y') : defaultYes;
193
118
  }
194
119
 
195
- // ---------------------------------------------------------------------------
196
- // Template rendering
197
- // ---------------------------------------------------------------------------
120
+ function javascriptStringLiteral(value) {
121
+ return JSON.stringify(String(value))
122
+ .replaceAll('\u2028', '\\u2028')
123
+ .replaceAll('\u2029', '\\u2029');
124
+ }
125
+ function javascriptTemplateLiteralContent(value) {
126
+ return JSON.stringify(String(value))
127
+ .slice(1, -1)
128
+ .replaceAll('`', '\\`')
129
+ .replaceAll('${', '\\${')
130
+ .replaceAll('\u2028', '\\u2028')
131
+ .replaceAll('\u2029', '\\u2029');
132
+ }
133
+ function commentTemplateValue(value) {
134
+ return String(value)
135
+ .replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ')
136
+ .replaceAll('*/', '* /');
137
+ }
138
+ function tenantSlug(value) {
139
+ const slug = String(value).toLowerCase().normalize('NFKD')
140
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
141
+ return slug || 'ndpr-tenant';
142
+ }
143
+ function createTemplateVars({ orgName, dpoEmail, framework, orm }) {
144
+ return {
145
+ ORG_NAME_COMMENT: commentTemplateValue(orgName),
146
+ ORG_NAME_LITERAL: javascriptStringLiteral(orgName),
147
+ DPO_EMAIL_LITERAL: javascriptStringLiteral(dpoEmail),
148
+ ORG_NAME_TEMPLATE: javascriptTemplateLiteralContent(orgName),
149
+ DPO_EMAIL_TEMPLATE: javascriptTemplateLiteralContent(dpoEmail),
150
+ TENANT_ID: tenantSlug(orgName),
151
+ TOOLKIT_VERSION,
152
+ FRAMEWORK: framework,
153
+ ORM: orm,
154
+ };
155
+ }
198
156
 
199
- /**
200
- * Render a template with `{{KEY}}` variable substitution and minimal
201
- * conditional-block support.
202
- *
203
- * Conditional syntax:
204
- *
205
- * // {{#if ORM=prisma}}
206
- * import { PrismaClient } from '@prisma/client';
207
- * // {{/if}}
208
- *
209
- * // {{#if ORM=drizzle}}
210
- * import { db } from '@/drizzle';
211
- * // {{/if}}
212
- *
213
- * // {{#if ORM=none}}
214
- * // TODO: wire to your storage of choice
215
- * const store = new Map();
216
- * // {{/if}}
217
- *
218
- * The block is kept when `vars[KEY] === value` (e.g. `vars.ORM === 'prisma'`),
219
- * otherwise the block and its markers are stripped. Marker comments (with
220
- * optional leading `//` and any trailing whitespace) are always removed.
221
- */
222
157
  function renderTemplate(templateName, vars) {
223
158
  const templatePath = join(TEMPLATES_DIR, templateName);
224
- if (!existsSync(templatePath)) {
225
- throw new Error(`Template not found: ${templateName}`);
226
- }
159
+ if (!existsSync(templatePath)) throw new Error(`Template not found: ${templateName}`);
227
160
  let content = readFileSync(templatePath, 'utf8');
228
-
229
- // Conditional blocks first (so variable substitution doesn't munge the
230
- // marker syntax). Pattern allows an optional `// ` prefix on the markers
231
- // so templates can comment them out and remain valid TypeScript.
232
161
  const ifPattern =
233
162
  /(?:[ \t]*\/\/[ \t]*)?\{\{#if[ \t]+([A-Z_]+)=([a-zA-Z0-9_-]+)\}\}\s*([\s\S]*?)(?:[ \t]*\/\/[ \t]*)?\{\{\/if\}\}[ \t]*\n?/g;
234
- content = content.replace(ifPattern, (_match, key, expected, body) => {
235
- if (vars[key] === expected) {
236
- // Keep the body; strip a trailing newline if it leaves a blank line
237
- return body.endsWith('\n') ? body : body + '\n';
163
+ content = content.replace(ifPattern, (_match, key, expected, body) =>
164
+ vars[key] === expected ? (body.endsWith('\n') ? body : `${body}\n`) : '',
165
+ );
166
+ const unresolvedDirective = content.match(/\{\{(?:#if|\/if)[^}]*\}\}/);
167
+ if (unresolvedDirective) {
168
+ throw new Error(`Unresolved template directive ${unresolvedDirective[0]} in ${templateName}`);
169
+ }
170
+ content = content.replace(/\{\{([A-Z_]+)\}\}/g, (placeholder, key) => {
171
+ if (!Object.hasOwn(vars, key)) {
172
+ throw new Error(`Unresolved template variable ${placeholder} in ${templateName}`);
238
173
  }
239
- return '';
174
+ return String(vars[key]);
240
175
  });
241
-
242
- for (const [key, value] of Object.entries(vars)) {
243
- content = content.replaceAll(`{{${key}}}`, value);
244
- }
245
176
  return content;
246
177
  }
247
178
 
248
- function writeFile(filePath, content) {
249
- const dir = dirname(filePath);
250
- if (!existsSync(dir)) {
251
- mkdirSync(dir, { recursive: true });
179
+ const GENERATED_FILES = [];
180
+ const SKIPPED_FILES = [];
181
+ function skip(relativePath, reason) {
182
+ SKIPPED_FILES.push(relativePath);
183
+ console.log(` ${dim('-')} ${dim(relativePath)} ${dim(`(skipped — ${reason})`)}`);
184
+ }
185
+ function writeGenerated(relativePath, content, { neverOverwrite = false } = {}) {
186
+ const destination = join(CWD, relativePath);
187
+ const shouldOverwrite = FORCE && !neverOverwrite;
188
+ const directory = dirname(destination);
189
+ mkdirSync(directory, { recursive: true });
190
+ try {
191
+ writeFileSync(destination, content, {
192
+ encoding: 'utf8',
193
+ flag: shouldOverwrite ? 'w' : 'wx',
194
+ });
195
+ } catch (error) {
196
+ if (error && error.code === 'EEXIST') {
197
+ skip(
198
+ relativePath,
199
+ neverOverwrite
200
+ ? 'existing file must be merged manually'
201
+ : 'already exists; pass --force to replace',
202
+ );
203
+ return false;
204
+ }
205
+ throw error;
252
206
  }
253
- writeFileSync(filePath, content, 'utf8');
207
+ GENERATED_FILES.push(relativePath);
208
+ console.log(` ${green('+')} ${relativePath}`);
209
+ return true;
254
210
  }
255
-
256
- // ---------------------------------------------------------------------------
257
- // File generation
258
- // ---------------------------------------------------------------------------
259
-
260
- const GENERATED_FILES = [];
261
-
262
- function generate(destRelative, templateName, vars = {}) {
263
- const destPath = join(CWD, destRelative);
264
- const content = renderTemplate(templateName, vars);
265
- writeFile(destPath, content);
266
- GENERATED_FILES.push(destRelative);
267
- console.log(` ${green('+')} ${destRelative}`);
211
+ function generate(relativePath, templateName, vars, options) {
212
+ return writeGenerated(relativePath, renderTemplate(templateName, vars), options);
268
213
  }
269
214
 
270
- function generateRaw(destRelative, content) {
271
- const destPath = join(CWD, destRelative);
272
- writeFile(destPath, content);
273
- GENERATED_FILES.push(destRelative);
274
- console.log(` ${green('+')} ${destRelative}`);
215
+ const PAGES_METHODS = {
216
+ consent: ['GET', 'POST', 'DELETE'],
217
+ dsr: ['GET', 'POST'],
218
+ breach: ['GET', 'POST'],
219
+ dpia: ['GET', 'POST', 'PUT', 'DELETE'],
220
+ 'lawful-basis': ['GET', 'POST', 'PUT', 'DELETE'],
221
+ 'cross-border': ['GET', 'POST', 'PUT', 'DELETE'],
222
+ };
223
+ function renderPagesRoute(templateName, vars, methods) {
224
+ let content = renderTemplate(templateName, vars)
225
+ .replace('Next.js App Router', 'Next.js Pages Router')
226
+ .replace(
227
+ "import { NextRequest, NextResponse } from 'next/server';",
228
+ "import { NextRequest, NextResponse } from 'next/server';\nimport type { NextApiRequest, NextApiResponse } from 'next';",
229
+ );
230
+ const map = methods.map((method) => `${method}`).join(', ');
231
+ content += `\n\ntype AppRouteHandler = (request: NextRequest) => Promise<Response>;\nconst pageHandlers: Record<string, AppRouteHandler> = { ${map} };\n\nexport default async function handler(req: NextApiRequest, res: NextApiResponse) {\n const method = (req.method ?? '').toUpperCase();\n const routeHandler = pageHandlers[method];\n if (!routeHandler) {\n res.setHeader('Allow', Object.keys(pageHandlers));\n return res.status(405).json({ error: 'Method not allowed' });\n }\n\n const headers = new Headers();\n for (const [name, value] of Object.entries(req.headers)) {\n if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));\n else if (value !== undefined) headers.set(name, value);\n }\n headers.delete('content-length');\n const init: Omit<RequestInit, 'signal'> = { method, headers };\n if (method !== 'GET' && method !== 'HEAD') {\n init.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {});\n if (!headers.has('content-type')) headers.set('content-type', 'application/json');\n }\n\n const request = new NextRequest(new URL(req.url ?? '/', 'http://localhost'), init);\n const response = await routeHandler(request);\n response.headers.forEach((value, name) => res.setHeader(name, value));\n const responseBody = await response.text();\n if (!responseBody) return res.status(response.status).end();\n return res.status(response.status).send(responseBody);\n}\n`;
232
+ return content;
275
233
  }
276
234
 
277
- function skip(destRelative, reason) {
278
- console.log(` ${dim('-')} ${dim(destRelative)} ${dim(`(skipped — ${reason})`)}`);
235
+ function renderExpressIndex(selectedModules) {
236
+ const routeNames = {
237
+ consent: 'consentRouter', dsr: 'dsrRouter', breach: 'breachRouter',
238
+ dpia: 'dpiaRouter', 'lawful-basis': 'lawfulBasisRouter',
239
+ 'cross-border': 'crossBorderRouter',
240
+ };
241
+ const enabled = selectedModules.filter((module) => routeNames[module]);
242
+ const imports = enabled.map((module) =>
243
+ `import { ${routeNames[module]} } from './routes/${module}';`,
244
+ ).join('\n');
245
+ const mounts = enabled.map((module) =>
246
+ ` router.use('/${module}', ${routeNames[module]});`,
247
+ ).join('\n');
248
+ return `import { Router } from 'express';\n${imports}\n\nexport function createNDPRRouter(): Router {\n const router = Router();\n${mounts}\n return router;\n}\n`;
279
249
  }
280
250
 
281
- // ---------------------------------------------------------------------------
282
- // Main
283
- // ---------------------------------------------------------------------------
251
+ const NEXT_TEMPLATES = {
252
+ consent: 'nextjs-consent-route.ts', dsr: 'nextjs-dsr-route.ts',
253
+ breach: 'nextjs-breach-route.ts', dpia: 'nextjs-dpia-route.ts',
254
+ 'lawful-basis': 'nextjs-lawful-basis-route.ts',
255
+ 'cross-border': 'nextjs-cross-border-route.ts',
256
+ };
257
+ const EXPRESS_TEMPLATES = {
258
+ consent: 'express-consent-route.ts', dsr: 'express-dsr-route.ts',
259
+ breach: 'express-breach-route.ts', dpia: 'express-dpia-route.ts',
260
+ 'lawful-basis': 'express-lawful-basis-route.ts',
261
+ 'cross-border': 'express-cross-border-route.ts',
262
+ };
284
263
 
285
- async function main() {
286
- printBanner();
264
+ /**
265
+ * Render the exact integration files produced by the interactive CLI without
266
+ * reading prompts or writing to disk. The verifier consumes this same API.
267
+ */
268
+ function renderIntegrationFiles({
269
+ framework,
270
+ orm,
271
+ selectedModules,
272
+ vars,
273
+ useSrcDirectory = false,
274
+ }) {
275
+ if (!['nextjs-app', 'nextjs-pages', 'express', 'none'].includes(framework)) {
276
+ throw new Error(`Unsupported framework: ${framework}`);
277
+ }
278
+ if (!['prisma', 'drizzle', 'none'].includes(orm)) {
279
+ throw new Error(`Unsupported ORM: ${orm}`);
280
+ }
287
281
 
288
- // --- Detect stack ---
289
- const detected = detectStack();
282
+ const templateVars = { ...vars, FRAMEWORK: framework, ORM: orm };
283
+ const modules = [...new Set(selectedModules)];
284
+ const files = [];
285
+ const addTemplate = (relativePath, templateName, routeVars = templateVars, options) => {
286
+ files.push({
287
+ relativePath,
288
+ content: renderTemplate(templateName, routeVars),
289
+ ...(options ? { options } : {}),
290
+ });
291
+ };
290
292
 
291
- console.log(bold(' Detected project setup:'));
292
- if (detected.framework) {
293
- const label = {
294
- 'nextjs-app': 'Next.js (App Router)',
295
- 'nextjs-pages': 'Next.js (Pages Router)',
296
- 'express': 'Express',
297
- }[detected.framework];
298
- console.log(` ${cyan('Framework:')} ${label}`);
299
- } else {
300
- console.log(` ${cyan('Framework:')} ${yellow('Not detected')}`);
293
+ if (orm === 'prisma') {
294
+ addTemplate(
295
+ 'prisma/schema.prisma',
296
+ 'prisma-schema.prisma',
297
+ templateVars,
298
+ { neverOverwrite: true },
299
+ );
300
+ } else if (orm === 'drizzle') {
301
+ addTemplate('src/drizzle/ndpr-schema.ts', 'drizzle-schema.ts');
302
+ addTemplate('src/drizzle/index.ts', 'drizzle-client.ts');
301
303
  }
302
304
 
303
- if (detected.orm) {
304
- console.log(` ${cyan('ORM:')} ${detected.orm === 'prisma' ? 'Prisma' : 'Drizzle'}`);
305
- } else {
306
- console.log(` ${cyan('ORM:')} ${dim('Not detected')}`);
305
+ if (framework === 'nextjs-app' || framework === 'nextjs-pages') {
306
+ const sourceRoot = useSrcDirectory ? 'src/' : '';
307
+ const appDir = `${sourceRoot}app`;
308
+ const pagesDir = `${sourceRoot}pages`;
309
+ const contextPath = `${sourceRoot}ndpr/request-context.ts`;
310
+ const drizzleBase = framework === 'nextjs-app'
311
+ ? (useSrcDirectory ? '../../../drizzle' : '../../../src/drizzle')
312
+ : (useSrcDirectory ? '../../drizzle' : '../../src/drizzle');
313
+ addTemplate(contextPath, 'nextjs-request-context.ts');
314
+
315
+ if (modules.includes('consent')) {
316
+ addTemplate(
317
+ framework === 'nextjs-app'
318
+ ? `${appDir}/ndpr-layout.tsx`
319
+ : `${pagesDir}/ndpr-provider.tsx`,
320
+ 'nextjs-layout.tsx',
321
+ );
322
+ }
323
+
324
+ for (const [moduleName, template] of Object.entries(NEXT_TEMPLATES)) {
325
+ if (!modules.includes(moduleName)) continue;
326
+ const routeVars = {
327
+ ...templateVars,
328
+ NDPR_CONTEXT_IMPORT: framework === 'nextjs-app'
329
+ ? '../../../ndpr/request-context'
330
+ : '../../ndpr/request-context',
331
+ NDPR_DB_IMPORT: drizzleBase,
332
+ NDPR_SCHEMA_IMPORT: `${drizzleBase}/ndpr-schema`,
333
+ };
334
+ if (framework === 'nextjs-app') {
335
+ addTemplate(`${appDir}/api/${moduleName}/route.ts`, template, routeVars);
336
+ } else {
337
+ files.push({
338
+ relativePath: `${pagesDir}/api/${moduleName}.ts`,
339
+ content: renderPagesRoute(template, routeVars, PAGES_METHODS[moduleName]),
340
+ });
341
+ }
342
+ }
343
+ } else if (framework === 'express') {
344
+ addTemplate('src/ndpr/request-context.ts', 'express-request-context.ts');
345
+ for (const [moduleName, template] of Object.entries(EXPRESS_TEMPLATES)) {
346
+ if (!modules.includes(moduleName)) continue;
347
+ addTemplate(`src/ndpr/routes/${moduleName}.ts`, template, {
348
+ ...templateVars,
349
+ NDPR_CONTEXT_IMPORT: '../request-context',
350
+ NDPR_DB_IMPORT: '../../drizzle',
351
+ NDPR_SCHEMA_IMPORT: '../../drizzle/ndpr-schema',
352
+ });
353
+ }
354
+ files.push({
355
+ relativePath: 'src/ndpr/index.ts',
356
+ content: renderExpressIndex(modules),
357
+ });
307
358
  }
359
+
360
+ return files;
361
+ }
362
+
363
+ async function main() {
364
+ printBanner();
365
+ const detected = detectStack();
366
+ console.log(bold(' Detected project setup:'));
367
+ console.log(` ${cyan('Framework:')} ${detected.framework ?? dim('not detected')}`);
368
+ console.log(` ${cyan('ORM:')} ${detected.orm ?? dim('not detected')}`);
308
369
  console.log();
309
370
 
310
371
  const rl = createRL();
311
-
312
372
  try {
313
- // --- Organisation details ---
314
- console.log(bold(' Organisation details'));
315
- console.log(dim(' These are embedded in the generated files.'));
316
- console.log();
317
-
318
- const orgName = await askRequired(
373
+ const orgName = await askValidated(
319
374
  rl,
320
- ` ${cyan('Organisation name')} (e.g. Acme Corp Nigeria Ltd): `,
375
+ ` ${cyan('Organisation name')}: `,
321
376
  'Organisation name',
377
+ (value) => !value ? 'is required' : value.length > 120 ? 'must be at most 120 characters' : /[\u0000-\u001f]/.test(value) ? 'contains control characters' : null,
322
378
  );
323
-
324
- const dpoEmail = await askRequired(
379
+ const dpoEmail = await askValidated(
325
380
  rl,
326
- ` ${cyan('DPO email address')} (e.g. dpo@acmecorp.ng): `,
381
+ ` ${cyan('DPO email address')}: `,
327
382
  'DPO email',
383
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'must be a valid email address',
328
384
  );
329
- console.log();
330
-
331
- // --- Framework confirmation/override ---
332
- const frameworkLabels = [
333
- 'Next.js — App Router',
334
- 'Next.js — Pages Router',
335
- 'Express',
336
- 'None (generate shared files only)',
337
- ];
338
- const frameworkValues = ['nextjs-app', 'nextjs-pages', 'express', 'none'];
339
385
 
340
- let detectedFrameworkIdx = frameworkValues.indexOf(detected.framework ?? 'none');
341
- if (detectedFrameworkIdx === -1) detectedFrameworkIdx = 3;
342
-
343
- console.log(bold(' Framework'));
344
- const frameworkIdx = await askChoice(
345
- rl,
346
- ` ${cyan('Which framework are you using?')}`,
347
- frameworkLabels,
348
- detectedFrameworkIdx,
349
- );
350
- const framework = frameworkValues[frameworkIdx];
351
- console.log();
352
-
353
- // --- Modules ---
354
- const moduleOptions = [
355
- 'consent — NDPA §25-26 consent management',
356
- 'dsr — Data Subject Rights requests (§34-38)',
357
- 'breach — Breach notification workflow (§40)',
358
- 'policy — Privacy policy generation',
359
- 'dpia — Data Protection Impact Assessment',
360
- 'lawful-basis — Lawful basis register',
361
- 'cross-border — Cross-border transfer management',
362
- 'ropa — Record of Processing Activities',
386
+ const frameworkLabels = ['Next.js — App Router', 'Next.js Pages Router', 'Express', 'None'];
387
+ const frameworkValues = ['nextjs-app', 'nextjs-pages', 'express', 'none'];
388
+ const frameworkDefault = Math.max(0, frameworkValues.indexOf(detected.framework ?? 'none'));
389
+ const frameworkIndex = await askChoice(rl, ` ${cyan('Framework')}`, frameworkLabels, frameworkDefault);
390
+ const framework = frameworkValues[frameworkIndex];
391
+
392
+ const moduleLabels = [
393
+ 'consent — NDPA §25-26', 'dsr — data-subject rights', 'breach — notification',
394
+ 'policy — privacy policy', 'dpia — impact assessment',
395
+ 'lawful-basis — processing register', 'cross-border — transfer register', 'ropa — processing activities',
363
396
  ];
364
397
  const moduleValues = ['consent', 'dsr', 'breach', 'policy', 'dpia', 'lawful-basis', 'cross-border', 'ropa'];
398
+ const moduleIndices = await askCheckboxes(rl, ` ${cyan('Modules')}`, moduleLabels, [0, 1, 2]);
399
+ const selectedModules = moduleIndices.map((index) => moduleValues[index]);
365
400
 
366
- console.log(bold(' Compliance modules'));
367
- const selectedModuleIndices = await askCheckboxes(
368
- rl,
369
- ` ${cyan('Which modules do you want to include?')}`,
370
- moduleOptions,
371
- [0, 1, 2], // consent, dsr, breach by default
372
- );
373
- const selectedModules = selectedModuleIndices.map((i) => moduleValues[i]);
374
- console.log();
375
-
376
- // --- ORM ---
377
- const ormLabels = ['Prisma', 'Drizzle', 'None (skip database schema)'];
401
+ const ormLabels = ['Prisma', 'Drizzle', 'None (development stores only)'];
378
402
  const ormValues = ['prisma', 'drizzle', 'none'];
379
-
380
- let detectedOrmIdx = ormValues.indexOf(detected.orm ?? 'none');
381
- if (detectedOrmIdx === -1) detectedOrmIdx = 0;
382
-
383
- console.log(bold(' Database / ORM'));
384
- const ormIdx = await askChoice(
385
- rl,
386
- ` ${cyan('Which ORM are you using?')}`,
387
- ormLabels,
388
- detectedOrmIdx,
389
- );
390
- const orm = ormValues[ormIdx];
391
- console.log();
392
-
393
- // --- Confirm ---
394
- console.log(bold(' Summary'));
395
- console.log(` ${cyan('Organisation:')} ${orgName}`);
396
- console.log(` ${cyan('DPO email:')} ${dpoEmail}`);
397
- console.log(` ${cyan('Framework:')} ${frameworkLabels[frameworkIdx]}`);
398
- console.log(` ${cyan('ORM:')} ${ormLabels[ormIdx]}`);
399
- console.log(` ${cyan('Modules:')} ${selectedModules.join(', ')}`);
400
- console.log();
401
-
402
- const confirmed = await askYesNo(rl, ` ${cyan('Generate files?')}`, true);
403
- if (!confirmed) {
403
+ const ormDefault = Math.max(0, ormValues.indexOf(detected.orm ?? 'none'));
404
+ const ormIndex = await askChoice(rl, ` ${cyan('ORM')}`, ormLabels, ormDefault);
405
+ const orm = ormValues[ormIndex];
406
+
407
+ console.log(`\n ${cyan('Organisation:')} ${orgName}`);
408
+ console.log(` ${cyan('Framework:')} ${frameworkLabels[frameworkIndex]}`);
409
+ console.log(` ${cyan('ORM:')} ${ormLabels[ormIndex]}`);
410
+ console.log(` ${cyan('Modules:')} ${selectedModules.join(', ') || 'none'}\n`);
411
+ if (!await askYesNo(rl, ` ${cyan('Generate files?')}`)) {
404
412
  console.log(yellow('\n Aborted. No files were written.\n'));
405
- process.exit(0);
413
+ return;
406
414
  }
407
- console.log();
408
415
 
409
- // -------------------------------------------------------------------------
410
- // File generation
411
- // -------------------------------------------------------------------------
412
-
413
- console.log(bold(' Generating files...'));
414
- console.log();
415
-
416
- const vars = {
417
- ORG_NAME: orgName,
418
- DPO_EMAIL: dpoEmail,
419
- // Drives `{{#if ORM=prisma}}` / `{{#if ORM=drizzle}}` / `{{#if ORM=none}}`
420
- // conditional blocks in the route templates.
421
- ORM: orm,
422
- FRAMEWORK: framework,
423
- };
424
-
425
- // .env.example
416
+ const vars = createTemplateVars({ orgName, dpoEmail, framework, orm });
417
+ console.log(`\n${bold(' Generating files...')}\n`);
426
418
  generate('.env.example', 'env-example', vars);
427
419
 
428
- // ORM schema
429
- if (orm === 'prisma') {
430
- if (existsSync(join(CWD, 'prisma', 'schema.prisma'))) {
431
- skip('prisma/schema.prisma', 'already exists — merge the NDPR models manually');
432
- console.log(dim(` See: ${TEMPLATES_DIR}/prisma-schema.prisma`));
433
- } else {
434
- generate('prisma/schema.prisma', 'prisma-schema.prisma', vars);
435
- }
436
- } else if (orm === 'drizzle') {
437
- if (existsSync(join(CWD, 'src', 'drizzle', 'ndpr-schema.ts'))) {
438
- skip('src/drizzle/ndpr-schema.ts', 'already exists');
439
- } else {
440
- generate('src/drizzle/ndpr-schema.ts', 'drizzle-schema.ts', vars);
441
- }
442
- }
443
-
444
- // Routes that already support all 3 ORMs via per-{{#if ORM=...}} blocks.
445
- // Add a template to this set as it gets ORM-aware. Other route templates
446
- // still hard-code Prisma, so we skip them when orm === 'none' to avoid
447
- // emitting code with a broken `@prisma/client` import.
448
- //
449
- // All Next.js and Express route templates went ORM-aware in 3.6.2.
450
- // The express-setup.ts wrapper still assumes Prisma — see the
451
- // separate skip handling below.
452
- const ORM_AWARE_TEMPLATES = new Set([
453
- 'nextjs-consent-route.ts',
454
- 'nextjs-dsr-route.ts',
455
- 'nextjs-breach-route.ts',
456
- 'nextjs-dpia-route.ts',
457
- 'nextjs-lawful-basis-route.ts',
458
- 'nextjs-cross-border-route.ts',
459
- 'express-consent-route.ts',
460
- 'express-dpia-route.ts',
461
- 'express-lawful-basis-route.ts',
462
- 'express-cross-border-route.ts',
463
- ]);
464
- const generateRoute = (dest, templateName) => {
465
- if (orm === 'none' && !ORM_AWARE_TEMPLATES.has(templateName)) {
466
- skip(
467
- dest,
468
- `template hardcodes Prisma; skipping because you chose ORM=none. ` +
469
- `Re-run create-ndpr with ORM=prisma|drizzle or copy the template from ` +
470
- `https://github.com/mr-tanta/ndpr-toolkit/tree/main/packages/create-ndpr/templates/${templateName} ` +
471
- `and adapt it to your storage layer.`,
472
- );
473
- return;
474
- }
475
- generate(dest, templateName, vars);
476
- };
477
-
478
- // Framework-specific files
479
- if (framework === 'nextjs-app') {
480
- const appDir = existsSync(join(CWD, 'src', 'app')) ? 'src/app' : 'app';
481
-
482
- // Layout wrapper
483
- generate(`${appDir}/ndpr-layout.tsx`, 'nextjs-layout.tsx', vars);
484
-
485
- // API routes per selected module
486
- if (selectedModules.includes('consent')) {
487
- generateRoute(`${appDir}/api/consent/route.ts`, 'nextjs-consent-route.ts');
488
- }
489
- if (selectedModules.includes('dsr')) {
490
- generateRoute(`${appDir}/api/dsr/route.ts`, 'nextjs-dsr-route.ts');
491
- }
492
- if (selectedModules.includes('breach')) {
493
- generateRoute(`${appDir}/api/breach/route.ts`, 'nextjs-breach-route.ts');
494
- }
495
- if (selectedModules.includes('dpia')) {
496
- generateRoute(`${appDir}/api/dpia/route.ts`, 'nextjs-dpia-route.ts');
497
- }
498
- if (selectedModules.includes('lawful-basis')) {
499
- generateRoute(`${appDir}/api/lawful-basis/route.ts`, 'nextjs-lawful-basis-route.ts');
500
- }
501
- if (selectedModules.includes('cross-border')) {
502
- generateRoute(`${appDir}/api/cross-border/route.ts`, 'nextjs-cross-border-route.ts');
503
- }
504
- } else if (framework === 'nextjs-pages') {
505
- console.log(yellow(' Note: Pages Router API routes generated under pages/api/'));
506
- if (selectedModules.includes('consent')) {
507
- generateRoute('pages/api/consent.ts', 'nextjs-consent-route.ts');
508
- }
509
- if (selectedModules.includes('dsr')) {
510
- generateRoute('pages/api/dsr.ts', 'nextjs-dsr-route.ts');
511
- }
512
- if (selectedModules.includes('breach')) {
513
- generateRoute('pages/api/breach.ts', 'nextjs-breach-route.ts');
514
- }
515
- if (selectedModules.includes('dpia')) {
516
- generateRoute('pages/api/dpia.ts', 'nextjs-dpia-route.ts');
517
- }
518
- if (selectedModules.includes('lawful-basis')) {
519
- generateRoute('pages/api/lawful-basis.ts', 'nextjs-lawful-basis-route.ts');
520
- }
521
- if (selectedModules.includes('cross-border')) {
522
- generateRoute('pages/api/cross-border.ts', 'nextjs-cross-border-route.ts');
523
- }
524
- } else if (framework === 'express') {
525
- // express-setup.ts wires up a PrismaClient directly and is not yet
526
- // ORM-aware, so it only produces working code for Prisma. Skip it (with
527
- // a pointer) for Drizzle and None rather than emit a broken import.
528
- if (orm === 'prisma') {
529
- generate('src/ndpr/index.ts', 'express-setup.ts', vars);
530
- } else {
531
- skip(
532
- 'src/ndpr/index.ts',
533
- `Express setup template currently assumes Prisma; skipping for ORM=${orm}. ` +
534
- 'See https://github.com/mr-tanta/ndpr-toolkit/tree/main/packages/create-ndpr/templates/express-setup.ts',
535
- );
536
- }
537
- if (selectedModules.includes('consent')) {
538
- generateRoute('src/ndpr/routes/consent.ts', 'express-consent-route.ts');
539
- }
540
- if (selectedModules.includes('dpia')) {
541
- generateRoute('src/ndpr/routes/dpia.ts', 'express-dpia-route.ts');
542
- }
543
- if (selectedModules.includes('lawful-basis')) {
544
- generateRoute('src/ndpr/routes/lawful-basis.ts', 'express-lawful-basis-route.ts');
545
- }
546
- if (selectedModules.includes('cross-border')) {
547
- generateRoute('src/ndpr/routes/cross-border.ts', 'express-cross-border-route.ts');
548
- }
420
+ const useSrcDirectory = framework === 'nextjs-app'
421
+ ? existsSync(join(CWD, 'src', 'app'))
422
+ : framework === 'nextjs-pages'
423
+ ? existsSync(join(CWD, 'src', 'pages'))
424
+ : false;
425
+ const integrationFiles = renderIntegrationFiles({
426
+ framework,
427
+ orm,
428
+ selectedModules,
429
+ vars,
430
+ useSrcDirectory,
431
+ });
432
+ for (const file of integrationFiles) {
433
+ writeGenerated(file.relativePath, file.content, file.options);
549
434
  }
550
435
 
551
- // -------------------------------------------------------------------------
552
- // Compliance-as-code: ndpr audit config + CI gate (GAID 2025)
553
- // -------------------------------------------------------------------------
554
- // Always scaffolded, regardless of framework/ORM — the audit runs against a
555
- // declarative config, not your database, so it works for every project.
556
- if (existsSync(join(CWD, 'ndpr.audit.json'))) {
557
- skip('ndpr.audit.json', 'already exists — keep your edits');
558
- } else {
559
- generateRaw('ndpr.audit.json', readFileSync(join(TEMPLATES_DIR, 'ndpr-audit.json'), 'utf8'));
560
- }
561
- if (existsSync(join(CWD, '.github', 'workflows', 'ndpr-audit.yml'))) {
562
- skip('.github/workflows/ndpr-audit.yml', 'already exists');
563
- } else {
564
- generate('.github/workflows/ndpr-audit.yml', 'github-ndpr-audit.yml', vars);
565
- }
566
-
567
- // -------------------------------------------------------------------------
568
- // Summary
569
- // -------------------------------------------------------------------------
570
-
571
- console.log();
572
- console.log(bold(green(' Done! Files generated:')));
573
- for (const f of GENERATED_FILES) {
574
- console.log(` ${green('✓')} ${f}`);
575
- }
576
- console.log();
436
+ generate('ndpr.audit.json', 'ndpr-audit.json', vars);
437
+ generate('.github/workflows/ndpr-audit.yml', 'github-ndpr-audit.yml', vars);
577
438
 
578
- // Next steps
439
+ console.log(`\n${bold(green(' Done.'))} Generated ${GENERATED_FILES.length} file(s); skipped ${SKIPPED_FILES.length}.\n`);
579
440
  console.log(bold(' Next steps:'));
580
- console.log();
581
-
582
- if (orm === 'prisma') {
583
- console.log(` ${cyan('1.')} Set your database URL in .env:`);
584
- console.log(dim(' DATABASE_URL="postgresql://user:password@localhost:5432/mydb_dev"'));
585
- console.log();
586
- console.log(` ${cyan('2.')} Install the Prisma client and run migrations:`);
587
- console.log(dim(' pnpm add @prisma/client'));
588
- console.log(dim(' pnpm add -D prisma'));
589
- console.log(dim(' pnpm prisma migrate dev --name ndpr-init'));
590
- console.log();
591
- } else if (orm === 'drizzle') {
592
- console.log(` ${cyan('1.')} Set your database URL in .env:`);
593
- console.log(dim(' DATABASE_URL="postgresql://user:password@localhost:5432/mydb_dev"'));
594
- console.log();
595
- console.log(` ${cyan('2.')} Install Drizzle and push the schema:`);
596
- console.log(dim(' pnpm add drizzle-orm @paralleldrive/cuid2'));
597
- console.log(dim(' pnpm add -D drizzle-kit'));
598
- console.log(dim(' pnpm drizzle-kit push'));
599
- console.log();
441
+ console.log(dim(` 1. Install the exact toolkit version: pnpm add @tantainnovative/ndpr-toolkit@${TOOLKIT_VERSION}`));
442
+ console.log(dim(' 2. Set DATABASE_URL and NDPR_TENANT_ID in your server environment.'));
443
+ console.log(dim(' 3. Connect the generated request-context resolver to verified authentication.'));
444
+ if (orm === 'prisma') console.log(dim(' 4. Run: pnpm prisma migrate dev --name ndpr-init'));
445
+ if (orm === 'drizzle') {
446
+ console.log(dim(' 4. Pass your Drizzle instance to configureNDPRDatabase() during server startup.'));
447
+ console.log(dim(' Then run your pinned-compatible drizzle-kit migration command.'));
600
448
  }
601
-
602
- console.log(` ${cyan(`${orm === 'none' ? '1' : '3'}`.padStart(1))}. Install the ndpr-toolkit:`);
603
- console.log(dim(' pnpm add @tantainnovative/ndpr-toolkit@^5.5.1'));
604
- console.log();
605
-
606
- if (framework === 'nextjs-app' || framework === 'nextjs-pages') {
607
- const appDir = existsSync(join(CWD, 'src', 'app')) ? 'src/app' : 'app';
608
- console.log(` ${cyan(`${orm === 'none' ? '2' : '4'}`.padStart(1))}. Wrap your root layout with NDPRLayout:`);
609
- console.log(dim(` // ${appDir}/layout.tsx`));
610
- console.log(dim(` import NDPRLayout from './${appDir.includes('src') ? '' : ''}ndpr-layout';`));
611
- console.log(dim(' // Render <NDPRLayout>{children}</NDPRLayout> inside your <body>'));
612
- console.log();
449
+ if (framework === 'nextjs-pages' && selectedModules.includes('consent')) {
450
+ console.log(dim(' 5. Wrap Component with NDPRClientProvider from pages/ndpr-provider in pages/_app.tsx.'));
451
+ } else if (framework === 'nextjs-app' && selectedModules.includes('consent')) {
452
+ console.log(dim(' 5. Render NDPRClientProvider from app/ndpr-layout.tsx inside app/layout.tsx.'));
613
453
  } else if (framework === 'express') {
614
- console.log(` ${cyan(`${orm === 'none' ? '2' : '4'}`.padStart(1))}. Mount the NDPR router in your Express app:`);
615
- console.log(dim(" import { createNDPRRouter } from './src/ndpr';"));
616
- console.log(dim(" app.use('/api/ndpr', createNDPRRouter());"));
617
- console.log();
454
+ console.log(dim(" 5. Mount createNDPRRouter() at your chosen '/api/ndpr' path."));
618
455
  }
619
-
620
- const auditStep = framework === 'none' ? (orm === 'none' ? 3 : 5) : orm === 'none' ? 3 : 5;
621
- console.log(` ${cyan(`${auditStep}`)}. Keep compliance from regressing — edit ${bold('ndpr.audit.json')} to match`);
622
- console.log(dim(' your real posture, then run the audit (also wired into CI for you):'));
623
- console.log(dim(' npx ndpr audit --min-score 70'));
624
- console.log();
625
-
626
- console.log(dim(' Documentation: https://ndprtoolkit.com.ng'));
627
- console.log(dim(' Repository: https://github.com/mr-tanta/ndpr-toolkit'));
628
- console.log();
629
-
456
+ console.log(dim(' Review every generated TODO and do not deploy in-memory stores as evidence.\n'));
630
457
  } finally {
631
458
  rl.close();
632
459
  }
633
460
  }
634
461
 
635
- main().catch((err) => {
636
- console.error(red(`\n Error: ${err.message}\n`));
637
- process.exit(1);
638
- });
462
+ export {
463
+ createTemplateVars,
464
+ renderIntegrationFiles,
465
+ renderPagesRoute,
466
+ renderTemplate,
467
+ };
468
+
469
+ const isDirectExecution = Boolean(
470
+ process.argv[1]
471
+ && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)),
472
+ );
473
+ if (isDirectExecution) {
474
+ main().catch((error) => {
475
+ console.error(red(`\n Error: ${error instanceof Error ? error.message : String(error)}\n`));
476
+ process.exitCode = 1;
477
+ });
478
+ }