@tantainnovative/create-ndpr 0.1.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/README.md +130 -0
- package/bin/index.mjs +503 -0
- package/package.json +31 -0
- package/templates/drizzle-schema.ts +163 -0
- package/templates/env-example +4 -0
- package/templates/express-consent-route.ts +105 -0
- package/templates/express-setup.ts +250 -0
- package/templates/nextjs-breach-route.ts +107 -0
- package/templates/nextjs-consent-route.ts +100 -0
- package/templates/nextjs-dsr-route.ts +80 -0
- package/templates/nextjs-layout.tsx +64 -0
- package/templates/prisma-schema.prisma +117 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# create-ndpr
|
|
2
|
+
|
|
3
|
+
CLI scaffolder for [NDPA](https://ndpr.gov.ng/) compliance using [`@tantainnovative/ndpr-toolkit`](https://github.com/tantainnovative/ndpr-toolkit).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @tantainnovative/create-ndpr
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with the short alias (once published to npm):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx create-ndpr
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run this from the root of an existing project. The CLI detects your stack and generates the right files with no manual copy-pasting.
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
1. **Detects your project setup** — checks for `next.config.*`, `express` in `package.json`, an `app/` directory (App Router vs Pages Router), `prisma/schema.prisma`, and `drizzle.config.*`.
|
|
22
|
+
|
|
23
|
+
2. **Prompts for a few details:**
|
|
24
|
+
- Organisation name
|
|
25
|
+
- DPO (Data Protection Officer) email address
|
|
26
|
+
- Framework (auto-detected, can override)
|
|
27
|
+
- ORM: Prisma / Drizzle / None
|
|
28
|
+
- Which compliance modules to include
|
|
29
|
+
|
|
30
|
+
3. **Generates files** tailored to your stack.
|
|
31
|
+
|
|
32
|
+
## What it generates
|
|
33
|
+
|
|
34
|
+
| File | When |
|
|
35
|
+
|------|------|
|
|
36
|
+
| `.env.example` | Always |
|
|
37
|
+
| `prisma/schema.prisma` | ORM = Prisma (skips if already exists) |
|
|
38
|
+
| `src/drizzle/ndpr-schema.ts` | ORM = Drizzle |
|
|
39
|
+
| `app/ndpr-layout.tsx` | Next.js App Router |
|
|
40
|
+
| `app/api/consent/route.ts` | Next.js + consent module |
|
|
41
|
+
| `app/api/dsr/route.ts` | Next.js + dsr module |
|
|
42
|
+
| `app/api/breach/route.ts` | Next.js + breach module |
|
|
43
|
+
| `pages/api/consent.ts` | Next.js Pages Router + consent |
|
|
44
|
+
| `pages/api/dsr.ts` | Next.js Pages Router + dsr |
|
|
45
|
+
| `pages/api/breach.ts` | Next.js Pages Router + breach |
|
|
46
|
+
| `src/ndpr/index.ts` | Express |
|
|
47
|
+
| `src/ndpr/routes/consent.ts` | Express + consent module |
|
|
48
|
+
|
|
49
|
+
All generated files use `{{ORG_NAME}}` and `{{DPO_EMAIL}}` substituted with your answers.
|
|
50
|
+
|
|
51
|
+
## Modules
|
|
52
|
+
|
|
53
|
+
| Module | NDPA reference | Description |
|
|
54
|
+
|--------|---------------|-------------|
|
|
55
|
+
| `consent` | §25–26 | Consent collection, storage, and withdrawal |
|
|
56
|
+
| `dsr` | §34–38 | Data subject rights request intake and tracking |
|
|
57
|
+
| `breach` | §40 | 72-hour breach notification workflow |
|
|
58
|
+
| `policy` | — | Privacy policy scaffolding |
|
|
59
|
+
| `dpia` | — | Data Protection Impact Assessment |
|
|
60
|
+
| `lawful-basis` | §25 | Lawful basis register |
|
|
61
|
+
| `cross-border` | §43 | Cross-border data transfer management |
|
|
62
|
+
| `ropa` | Accountability | Record of Processing Activities |
|
|
63
|
+
|
|
64
|
+
## After generation
|
|
65
|
+
|
|
66
|
+
### With Prisma
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Copy .env.example → .env and set DATABASE_URL
|
|
70
|
+
cp .env.example .env
|
|
71
|
+
|
|
72
|
+
# Install dependencies
|
|
73
|
+
pnpm add @prisma/client @tantainnovative/ndpr-toolkit
|
|
74
|
+
pnpm add -D prisma
|
|
75
|
+
|
|
76
|
+
# Run migrations
|
|
77
|
+
pnpm prisma migrate dev --name ndpr-init
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### With Drizzle
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cp .env.example .env
|
|
84
|
+
|
|
85
|
+
pnpm add drizzle-orm @paralleldrive/cuid2 @tantainnovative/ndpr-toolkit
|
|
86
|
+
pnpm add -D drizzle-kit
|
|
87
|
+
|
|
88
|
+
pnpm drizzle-kit push
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Next.js — wire up the layout
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// app/layout.tsx
|
|
95
|
+
import NDPRLayout from '@/app/ndpr-layout';
|
|
96
|
+
|
|
97
|
+
export default async function RootLayout({ children }) {
|
|
98
|
+
return (
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<body>
|
|
101
|
+
<NDPRLayout>
|
|
102
|
+
{children}
|
|
103
|
+
</NDPRLayout>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Express — mount the router
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import express from 'express';
|
|
114
|
+
import { createNDPRRouter } from './src/ndpr';
|
|
115
|
+
|
|
116
|
+
const app = express();
|
|
117
|
+
app.use(express.json());
|
|
118
|
+
app.use('/api/ndpr', createNDPRRouter());
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Requirements
|
|
122
|
+
|
|
123
|
+
- Node.js 18+
|
|
124
|
+
- Zero external dependencies — works with `npx` out of the box
|
|
125
|
+
|
|
126
|
+
## Links
|
|
127
|
+
|
|
128
|
+
- Toolkit docs: https://ndpr-toolkit.tantainnovative.com
|
|
129
|
+
- GitHub: https://github.com/tantainnovative/ndpr-toolkit
|
|
130
|
+
- NDPC: https://ndpr.gov.ng
|
package/bin/index.mjs
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'readline';
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
statSync,
|
|
11
|
+
} from 'fs';
|
|
12
|
+
import { join, dirname, resolve } from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const TEMPLATES_DIR = join(__dirname, '..', 'templates');
|
|
17
|
+
const CWD = process.cwd();
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// ANSI colour helpers (no external deps)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
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',
|
|
34
|
+
};
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function printBanner() {
|
|
48
|
+
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'));
|
|
58
|
+
console.log();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Stack detection
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
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
|
|
73
|
+
const pkgPath = join(CWD, 'package.json');
|
|
74
|
+
if (existsSync(pkgPath)) {
|
|
75
|
+
detected.hasPackageJson = true;
|
|
76
|
+
try {
|
|
77
|
+
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
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// ignore parse errors
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
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';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ORM detection via project structure (if not already found in deps)
|
|
110
|
+
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
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return detected;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Readline helpers
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function createRL() {
|
|
129
|
+
return createInterface({
|
|
130
|
+
input: process.stdin,
|
|
131
|
+
output: process.stdout,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function ask(rl, question) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
rl.question(question, (answer) => resolve(answer.trim()));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
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
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
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}]: `;
|
|
155
|
+
while (true) {
|
|
156
|
+
const raw = await ask(rl, prompt);
|
|
157
|
+
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}.`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
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
|
+
|
|
169
|
+
while (true) {
|
|
170
|
+
const raw = await ask(rl, prompt);
|
|
171
|
+
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);
|
|
183
|
+
}
|
|
184
|
+
if (valid) return [...new Set(indices)];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
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');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Template rendering
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function renderTemplate(templateName, vars) {
|
|
200
|
+
const templatePath = join(TEMPLATES_DIR, templateName);
|
|
201
|
+
if (!existsSync(templatePath)) {
|
|
202
|
+
throw new Error(`Template not found: ${templateName}`);
|
|
203
|
+
}
|
|
204
|
+
let content = readFileSync(templatePath, 'utf8');
|
|
205
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
206
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
207
|
+
}
|
|
208
|
+
return content;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function writeFile(filePath, content) {
|
|
212
|
+
const dir = dirname(filePath);
|
|
213
|
+
if (!existsSync(dir)) {
|
|
214
|
+
mkdirSync(dir, { recursive: true });
|
|
215
|
+
}
|
|
216
|
+
writeFileSync(filePath, content, 'utf8');
|
|
217
|
+
}
|
|
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}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
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}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function skip(destRelative, reason) {
|
|
241
|
+
console.log(` ${dim('-')} ${dim(destRelative)} ${dim(`(skipped — ${reason})`)}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Main
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async function main() {
|
|
249
|
+
printBanner();
|
|
250
|
+
|
|
251
|
+
// --- Detect stack ---
|
|
252
|
+
const detected = detectStack();
|
|
253
|
+
|
|
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')}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (detected.orm) {
|
|
267
|
+
console.log(` ${cyan('ORM:')} ${detected.orm === 'prisma' ? 'Prisma' : 'Drizzle'}`);
|
|
268
|
+
} else {
|
|
269
|
+
console.log(` ${cyan('ORM:')} ${dim('Not detected')}`);
|
|
270
|
+
}
|
|
271
|
+
console.log();
|
|
272
|
+
|
|
273
|
+
const rl = createRL();
|
|
274
|
+
|
|
275
|
+
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(
|
|
282
|
+
rl,
|
|
283
|
+
` ${cyan('Organisation name')} (e.g. Acme Corp Nigeria Ltd): `,
|
|
284
|
+
'Organisation name',
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const dpoEmail = await askRequired(
|
|
288
|
+
rl,
|
|
289
|
+
` ${cyan('DPO email address')} (e.g. dpo@acmecorp.ng): `,
|
|
290
|
+
'DPO email',
|
|
291
|
+
);
|
|
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
|
+
|
|
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',
|
|
326
|
+
];
|
|
327
|
+
const moduleValues = ['consent', 'dsr', 'breach', 'policy', 'dpia', 'lawful-basis', 'cross-border', 'ropa'];
|
|
328
|
+
|
|
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)'];
|
|
341
|
+
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) {
|
|
367
|
+
console.log(yellow('\n Aborted. No files were written.\n'));
|
|
368
|
+
process.exit(0);
|
|
369
|
+
}
|
|
370
|
+
console.log();
|
|
371
|
+
|
|
372
|
+
// -------------------------------------------------------------------------
|
|
373
|
+
// File generation
|
|
374
|
+
// -------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
console.log(bold(' Generating files...'));
|
|
377
|
+
console.log();
|
|
378
|
+
|
|
379
|
+
const vars = {
|
|
380
|
+
ORG_NAME: orgName,
|
|
381
|
+
DPO_EMAIL: dpoEmail,
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// .env.example
|
|
385
|
+
generate('.env.example', 'env-example', vars);
|
|
386
|
+
|
|
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
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
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();
|
|
448
|
+
|
|
449
|
+
// Next steps
|
|
450
|
+
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();
|
|
471
|
+
}
|
|
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();
|
|
484
|
+
} 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();
|
|
489
|
+
}
|
|
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
|
+
|
|
495
|
+
} finally {
|
|
496
|
+
rl.close();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
main().catch((err) => {
|
|
501
|
+
console.error(red(`\n Error: ${err.message}\n`));
|
|
502
|
+
process.exit(1);
|
|
503
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tantainnovative/create-ndpr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI scaffolder for NDPA compliance with @tantainnovative/ndpr-toolkit",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Abraham Esandayinze Tanta",
|
|
8
|
+
"url": "https://linkedin.com/in/mr-tanta"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"create-ndpr": "./bin/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"templates/"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ndpa",
|
|
19
|
+
"ndpr",
|
|
20
|
+
"cli",
|
|
21
|
+
"scaffold",
|
|
22
|
+
"nigeria",
|
|
23
|
+
"data-protection"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
31
|
+
}
|