@tantainnovative/create-ndpr 0.1.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,503 +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
157
  function renderTemplate(templateName, vars) {
200
158
  const templatePath = join(TEMPLATES_DIR, templateName);
201
- if (!existsSync(templatePath)) {
202
- throw new Error(`Template not found: ${templateName}`);
203
- }
159
+ if (!existsSync(templatePath)) throw new Error(`Template not found: ${templateName}`);
204
160
  let content = readFileSync(templatePath, 'utf8');
205
- for (const [key, value] of Object.entries(vars)) {
206
- content = content.replaceAll(`{{${key}}}`, value);
161
+ const ifPattern =
162
+ /(?:[ \t]*\/\/[ \t]*)?\{\{#if[ \t]+([A-Z_]+)=([a-zA-Z0-9_-]+)\}\}\s*([\s\S]*?)(?:[ \t]*\/\/[ \t]*)?\{\{\/if\}\}[ \t]*\n?/g;
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}`);
207
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}`);
173
+ }
174
+ return String(vars[key]);
175
+ });
208
176
  return content;
209
177
  }
210
178
 
211
- function writeFile(filePath, content) {
212
- const dir = dirname(filePath);
213
- if (!existsSync(dir)) {
214
- 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;
215
206
  }
216
- writeFileSync(filePath, content, 'utf8');
207
+ GENERATED_FILES.push(relativePath);
208
+ console.log(` ${green('+')} ${relativePath}`);
209
+ return true;
217
210
  }
218
-
219
- // ---------------------------------------------------------------------------
220
- // File generation
221
- // ---------------------------------------------------------------------------
222
-
223
- const GENERATED_FILES = [];
224
-
225
- function generate(destRelative, templateName, vars = {}) {
226
- const destPath = join(CWD, destRelative);
227
- const content = renderTemplate(templateName, vars);
228
- writeFile(destPath, content);
229
- GENERATED_FILES.push(destRelative);
230
- console.log(` ${green('+')} ${destRelative}`);
211
+ function generate(relativePath, templateName, vars, options) {
212
+ return writeGenerated(relativePath, renderTemplate(templateName, vars), options);
231
213
  }
232
214
 
233
- function generateRaw(destRelative, content) {
234
- const destPath = join(CWD, destRelative);
235
- writeFile(destPath, content);
236
- GENERATED_FILES.push(destRelative);
237
- 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;
238
233
  }
239
234
 
240
- function skip(destRelative, reason) {
241
- 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`;
242
249
  }
243
250
 
244
- // ---------------------------------------------------------------------------
245
- // Main
246
- // ---------------------------------------------------------------------------
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
+ };
247
263
 
248
- async function main() {
249
- 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
+ }
250
281
 
251
- // --- Detect stack ---
252
- 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
+ };
253
292
 
254
- console.log(bold(' Detected project setup:'));
255
- if (detected.framework) {
256
- const label = {
257
- 'nextjs-app': 'Next.js (App Router)',
258
- 'nextjs-pages': 'Next.js (Pages Router)',
259
- 'express': 'Express',
260
- }[detected.framework];
261
- console.log(` ${cyan('Framework:')} ${label}`);
262
- } else {
263
- 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');
264
303
  }
265
304
 
266
- if (detected.orm) {
267
- console.log(` ${cyan('ORM:')} ${detected.orm === 'prisma' ? 'Prisma' : 'Drizzle'}`);
268
- } else {
269
- 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
+ });
270
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')}`);
271
369
  console.log();
272
370
 
273
371
  const rl = createRL();
274
-
275
372
  try {
276
- // --- Organisation details ---
277
- console.log(bold(' Organisation details'));
278
- console.log(dim(' These are embedded in the generated files.'));
279
- console.log();
280
-
281
- const orgName = await askRequired(
373
+ const orgName = await askValidated(
282
374
  rl,
283
- ` ${cyan('Organisation name')} (e.g. Acme Corp Nigeria Ltd): `,
375
+ ` ${cyan('Organisation name')}: `,
284
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,
285
378
  );
286
-
287
- const dpoEmail = await askRequired(
379
+ const dpoEmail = await askValidated(
288
380
  rl,
289
- ` ${cyan('DPO email address')} (e.g. dpo@acmecorp.ng): `,
381
+ ` ${cyan('DPO email address')}: `,
290
382
  'DPO email',
383
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'must be a valid email address',
291
384
  );
292
- console.log();
293
-
294
- // --- Framework confirmation/override ---
295
- const frameworkLabels = [
296
- 'Next.js — App Router',
297
- 'Next.js — Pages Router',
298
- 'Express',
299
- 'None (generate shared files only)',
300
- ];
301
- const frameworkValues = ['nextjs-app', 'nextjs-pages', 'express', 'none'];
302
385
 
303
- let detectedFrameworkIdx = frameworkValues.indexOf(detected.framework ?? 'none');
304
- if (detectedFrameworkIdx === -1) detectedFrameworkIdx = 3;
305
-
306
- console.log(bold(' Framework'));
307
- const frameworkIdx = await askChoice(
308
- rl,
309
- ` ${cyan('Which framework are you using?')}`,
310
- frameworkLabels,
311
- detectedFrameworkIdx,
312
- );
313
- const framework = frameworkValues[frameworkIdx];
314
- console.log();
315
-
316
- // --- Modules ---
317
- const moduleOptions = [
318
- 'consent — NDPA §25-26 consent management',
319
- 'dsr — Data Subject Rights requests (§34-38)',
320
- 'breach — Breach notification workflow (§40)',
321
- 'policy — Privacy policy generation',
322
- 'dpia — Data Protection Impact Assessment',
323
- 'lawful-basis — Lawful basis register',
324
- 'cross-border — Cross-border transfer management',
325
- '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',
326
396
  ];
327
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]);
328
400
 
329
- console.log(bold(' Compliance modules'));
330
- const selectedModuleIndices = await askCheckboxes(
331
- rl,
332
- ` ${cyan('Which modules do you want to include?')}`,
333
- moduleOptions,
334
- [0, 1, 2], // consent, dsr, breach by default
335
- );
336
- const selectedModules = selectedModuleIndices.map((i) => moduleValues[i]);
337
- console.log();
338
-
339
- // --- ORM ---
340
- const ormLabels = ['Prisma', 'Drizzle', 'None (skip database schema)'];
401
+ const ormLabels = ['Prisma', 'Drizzle', 'None (development stores only)'];
341
402
  const ormValues = ['prisma', 'drizzle', 'none'];
342
-
343
- let detectedOrmIdx = ormValues.indexOf(detected.orm ?? 'none');
344
- if (detectedOrmIdx === -1) detectedOrmIdx = 0;
345
-
346
- console.log(bold(' Database / ORM'));
347
- const ormIdx = await askChoice(
348
- rl,
349
- ` ${cyan('Which ORM are you using?')}`,
350
- ormLabels,
351
- detectedOrmIdx,
352
- );
353
- const orm = ormValues[ormIdx];
354
- console.log();
355
-
356
- // --- Confirm ---
357
- console.log(bold(' Summary'));
358
- console.log(` ${cyan('Organisation:')} ${orgName}`);
359
- console.log(` ${cyan('DPO email:')} ${dpoEmail}`);
360
- console.log(` ${cyan('Framework:')} ${frameworkLabels[frameworkIdx]}`);
361
- console.log(` ${cyan('ORM:')} ${ormLabels[ormIdx]}`);
362
- console.log(` ${cyan('Modules:')} ${selectedModules.join(', ')}`);
363
- console.log();
364
-
365
- const confirmed = await askYesNo(rl, ` ${cyan('Generate files?')}`, true);
366
- 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?')}`)) {
367
412
  console.log(yellow('\n Aborted. No files were written.\n'));
368
- process.exit(0);
413
+ return;
369
414
  }
370
- console.log();
371
-
372
- // -------------------------------------------------------------------------
373
- // File generation
374
- // -------------------------------------------------------------------------
375
-
376
- console.log(bold(' Generating files...'));
377
- console.log();
378
415
 
379
- const vars = {
380
- ORG_NAME: orgName,
381
- DPO_EMAIL: dpoEmail,
382
- };
383
-
384
- // .env.example
416
+ const vars = createTemplateVars({ orgName, dpoEmail, framework, orm });
417
+ console.log(`\n${bold(' Generating files...')}\n`);
385
418
  generate('.env.example', 'env-example', vars);
386
419
 
387
- // ORM schema
388
- if (orm === 'prisma') {
389
- if (existsSync(join(CWD, 'prisma', 'schema.prisma'))) {
390
- skip('prisma/schema.prisma', 'already exists — merge the NDPR models manually');
391
- console.log(dim(` See: ${TEMPLATES_DIR}/prisma-schema.prisma`));
392
- } else {
393
- generate('prisma/schema.prisma', 'prisma-schema.prisma', vars);
394
- }
395
- } else if (orm === 'drizzle') {
396
- if (existsSync(join(CWD, 'src', 'drizzle', 'ndpr-schema.ts'))) {
397
- skip('src/drizzle/ndpr-schema.ts', 'already exists');
398
- } else {
399
- generate('src/drizzle/ndpr-schema.ts', 'drizzle-schema.ts', vars);
400
- }
401
- }
402
-
403
- // Framework-specific files
404
- if (framework === 'nextjs-app') {
405
- const appDir = existsSync(join(CWD, 'src', 'app')) ? 'src/app' : 'app';
406
-
407
- // Layout wrapper
408
- generate(`${appDir}/ndpr-layout.tsx`, 'nextjs-layout.tsx', vars);
409
-
410
- // API routes per selected module
411
- if (selectedModules.includes('consent')) {
412
- generate(`${appDir}/api/consent/route.ts`, 'nextjs-consent-route.ts', vars);
413
- }
414
- if (selectedModules.includes('dsr')) {
415
- generate(`${appDir}/api/dsr/route.ts`, 'nextjs-dsr-route.ts', vars);
416
- }
417
- if (selectedModules.includes('breach')) {
418
- generate(`${appDir}/api/breach/route.ts`, 'nextjs-breach-route.ts', vars);
419
- }
420
- } else if (framework === 'nextjs-pages') {
421
- console.log(yellow(' Note: Pages Router API routes generated under pages/api/'));
422
- if (selectedModules.includes('consent')) {
423
- generate('pages/api/consent.ts', 'nextjs-consent-route.ts', vars);
424
- }
425
- if (selectedModules.includes('dsr')) {
426
- generate('pages/api/dsr.ts', 'nextjs-dsr-route.ts', vars);
427
- }
428
- if (selectedModules.includes('breach')) {
429
- generate('pages/api/breach.ts', 'nextjs-breach-route.ts', vars);
430
- }
431
- } else if (framework === 'express') {
432
- generate('src/ndpr/index.ts', 'express-setup.ts', vars);
433
- if (selectedModules.includes('consent')) {
434
- generate('src/ndpr/routes/consent.ts', 'express-consent-route.ts', vars);
435
- }
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);
436
434
  }
437
435
 
438
- // -------------------------------------------------------------------------
439
- // Summary
440
- // -------------------------------------------------------------------------
441
-
442
- console.log();
443
- console.log(bold(green(' Done! Files generated:')));
444
- for (const f of GENERATED_FILES) {
445
- console.log(` ${green('✓')} ${f}`);
446
- }
447
- console.log();
436
+ generate('ndpr.audit.json', 'ndpr-audit.json', vars);
437
+ generate('.github/workflows/ndpr-audit.yml', 'github-ndpr-audit.yml', vars);
448
438
 
449
- // Next steps
439
+ console.log(`\n${bold(green(' Done.'))} Generated ${GENERATED_FILES.length} file(s); skipped ${SKIPPED_FILES.length}.\n`);
450
440
  console.log(bold(' Next steps:'));
451
- console.log();
452
-
453
- if (orm === 'prisma') {
454
- console.log(` ${cyan('1.')} Set your database URL in .env:`);
455
- console.log(dim(' DATABASE_URL="postgresql://user:password@localhost:5432/mydb_dev"'));
456
- console.log();
457
- console.log(` ${cyan('2.')} Install the Prisma client and run migrations:`);
458
- console.log(dim(' pnpm add @prisma/client'));
459
- console.log(dim(' pnpm add -D prisma'));
460
- console.log(dim(' pnpm prisma migrate dev --name ndpr-init'));
461
- console.log();
462
- } else if (orm === 'drizzle') {
463
- console.log(` ${cyan('1.')} Set your database URL in .env:`);
464
- console.log(dim(' DATABASE_URL="postgresql://user:password@localhost:5432/mydb_dev"'));
465
- console.log();
466
- console.log(` ${cyan('2.')} Install Drizzle and push the schema:`);
467
- console.log(dim(' pnpm add drizzle-orm @paralleldrive/cuid2'));
468
- console.log(dim(' pnpm add -D drizzle-kit'));
469
- console.log(dim(' pnpm drizzle-kit push'));
470
- 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.'));
471
448
  }
472
-
473
- console.log(` ${cyan(`${orm === 'none' ? '1' : '3'}`.padStart(1))}. Install the ndpr-toolkit:`);
474
- console.log(dim(' pnpm add @tantainnovative/ndpr-toolkit'));
475
- console.log();
476
-
477
- if (framework === 'nextjs-app' || framework === 'nextjs-pages') {
478
- const appDir = existsSync(join(CWD, 'src', 'app')) ? 'src/app' : 'app';
479
- console.log(` ${cyan(`${orm === 'none' ? '2' : '4'}`.padStart(1))}. Wrap your root layout with NDPRLayout:`);
480
- console.log(dim(` // ${appDir}/layout.tsx`));
481
- console.log(dim(` import NDPRLayout from './${appDir.includes('src') ? '' : ''}ndpr-layout';`));
482
- console.log(dim(' // Render <NDPRLayout>{children}</NDPRLayout> inside your <body>'));
483
- 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.'));
484
453
  } else if (framework === 'express') {
485
- console.log(` ${cyan(`${orm === 'none' ? '2' : '4'}`.padStart(1))}. Mount the NDPR router in your Express app:`);
486
- console.log(dim(" import { createNDPRRouter } from './src/ndpr';"));
487
- console.log(dim(" app.use('/api/ndpr', createNDPRRouter());"));
488
- console.log();
454
+ console.log(dim(" 5. Mount createNDPRRouter() at your chosen '/api/ndpr' path."));
489
455
  }
490
-
491
- console.log(dim(' Documentation: https://ndpr-toolkit.tantainnovative.com'));
492
- console.log(dim(' Repository: https://github.com/tantainnovative/ndpr-toolkit'));
493
- console.log();
494
-
456
+ console.log(dim(' Review every generated TODO and do not deploy in-memory stores as evidence.\n'));
495
457
  } finally {
496
458
  rl.close();
497
459
  }
498
460
  }
499
461
 
500
- main().catch((err) => {
501
- console.error(red(`\n Error: ${err.message}\n`));
502
- process.exit(1);
503
- });
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
+ }