@xbg.solutions/create-frontend 1.1.1

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/src/setup.cjs ADDED
@@ -0,0 +1,1049 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Interactive Setup Wizard — XBG SvelteKit Boilerplate
5
+ *
6
+ * Covers:
7
+ * 1. Context detection (standalone vs mono-repo)
8
+ * 2. Project identity → writes .env
9
+ * 3. Firebase → writes .env, updates firebase.json + .firebaserc
10
+ * 4. API / Backend → writes .env
11
+ * 5. RBAC / Roles → writes app.config.ts SETUP:roles block
12
+ * 6. Custom JWT attrs → documents extra claims in .env comment
13
+ * 7. Feature flags → writes app.config.ts SETUP:features block
14
+ * 8. Generate & validate
15
+ *
16
+ * Usage: npm run setup
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const readline = require('readline');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const { execSync } = require('child_process');
25
+
26
+ // ─── ANSI colours ────────────────────────────────────────────────────────────
27
+ const C = {
28
+ reset: '\x1b[0m',
29
+ bold: '\x1b[1m',
30
+ green: '\x1b[32m',
31
+ blue: '\x1b[34m',
32
+ yellow: '\x1b[33m',
33
+ red: '\x1b[31m',
34
+ cyan: '\x1b[36m',
35
+ dim: '\x1b[2m',
36
+ };
37
+
38
+ const log = {
39
+ ok: (m) => console.log(`${C.green}✓${C.reset} ${m}`),
40
+ err: (m) => console.log(`${C.red}✗${C.reset} ${m}`),
41
+ info: (m) => console.log(`${C.blue}ℹ${C.reset} ${m}`),
42
+ warn: (m) => console.log(`${C.yellow}⚠${C.reset} ${m}`),
43
+ title: (m) => console.log(`\n${C.bold}${C.blue}${m}${C.reset}\n`),
44
+ dim: (m) => console.log(`${C.dim}${m}${C.reset}`),
45
+ };
46
+
47
+ // ─── readline ────────────────────────────────────────────────────────────────
48
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
49
+
50
+ function ask(question, defaultVal = '') {
51
+ const hint = defaultVal ? ` ${C.dim}[${defaultVal}]${C.reset}` : '';
52
+ return new Promise((resolve) => {
53
+ rl.question(`${C.cyan}${question}${hint}:${C.reset} `, (ans) => {
54
+ resolve(ans.trim() || defaultVal);
55
+ });
56
+ });
57
+ }
58
+
59
+ function askYN(question, defaultYes = true) {
60
+ const hint = defaultYes ? 'Y/n' : 'y/N';
61
+ return new Promise((resolve) => {
62
+ rl.question(`${C.cyan}${question} (${hint})${C.reset} `, (ans) => {
63
+ const a = ans.trim().toLowerCase();
64
+ if (a === '') resolve(defaultYes);
65
+ else resolve(a === 'y' || a === 'yes');
66
+ });
67
+ });
68
+ }
69
+
70
+ // ─── Paths ───────────────────────────────────────────────────────────────────
71
+ const ROOT = process.cwd();
72
+ const PARENT = path.resolve(ROOT, '..');
73
+ const ENV_FILE = path.join(ROOT, '.env');
74
+ const ENV_EX = path.join(ROOT, '.env.example');
75
+ const FB_JSON = path.join(ROOT, 'firebase.json');
76
+ const FB_RC = path.join(ROOT, '.firebaserc');
77
+ const APP_CFG = path.join(ROOT, 'src', 'lib', 'config', 'app.config.ts');
78
+
79
+ // ─── Context detection ────────────────────────────────────────────────────────
80
+ function detectContext() {
81
+ const hasParentFunctions = fs.existsSync(path.join(PARENT, 'functions'));
82
+ const hasParentFirebase = fs.existsSync(path.join(PARENT, 'firebase.json'));
83
+ const hasParentFirebaseRc = fs.existsSync(path.join(PARENT, '.firebaserc'));
84
+ const isInFrontendDir = path.basename(ROOT) === 'frontend';
85
+
86
+ return {
87
+ isMonoRepo: hasParentFunctions || (hasParentFirebase && isInFrontendDir),
88
+ hasParentFirebase,
89
+ hasParentFirebaseRc,
90
+ isInFrontendDir,
91
+ };
92
+ }
93
+
94
+ // ─── Existing .env loader ─────────────────────────────────────────────────────
95
+ function loadExistingEnv() {
96
+ const existing = {};
97
+ if (!fs.existsSync(ENV_FILE)) return existing;
98
+ const lines = fs.readFileSync(ENV_FILE, 'utf8').split('\n');
99
+ for (const line of lines) {
100
+ const m = line.match(/^([A-Z_][A-Z0-9_]*)=["']?(.+?)["']?\s*$/);
101
+ if (m) existing[m[1]] = m[2];
102
+ }
103
+ return existing;
104
+ }
105
+
106
+ // ─── firebase.json helpers ────────────────────────────────────────────────────
107
+ function readFirebaseJson() {
108
+ if (!fs.existsSync(FB_JSON)) return null;
109
+ try { return JSON.parse(fs.readFileSync(FB_JSON, 'utf8')); } catch { return null; }
110
+ }
111
+
112
+ function writeFirebaseJson(data) {
113
+ fs.writeFileSync(FB_JSON, JSON.stringify(data, null, 2) + '\n');
114
+ }
115
+
116
+ function readFirebaseRc() {
117
+ if (!fs.existsSync(FB_RC)) return null;
118
+ try { return JSON.parse(fs.readFileSync(FB_RC, 'utf8')); } catch { return null; }
119
+ }
120
+
121
+ function writeFirebaseRc(data) {
122
+ fs.writeFileSync(FB_RC, JSON.stringify(data, null, 2) + '\n');
123
+ }
124
+
125
+ // ─── app.config.ts block replacement ─────────────────────────────────────────
126
+ function replaceSetupBlock(content, blockName, newContent) {
127
+ const startMarker = `/* SETUP:start:${blockName} */`;
128
+ const endMarker = `/* SETUP:end:${blockName} */`;
129
+ const startIdx = content.indexOf(startMarker);
130
+ const endIdx = content.indexOf(endMarker);
131
+
132
+ if (startIdx === -1 || endIdx === -1) {
133
+ log.warn(`SETUP markers for '${blockName}' not found in app.config.ts — skipping.`);
134
+ return content;
135
+ }
136
+
137
+ // Determine indentation from the line containing the start marker
138
+ const lineStart = content.lastIndexOf('\n', startIdx) + 1;
139
+ const indent = content.slice(lineStart, startIdx).match(/^(\s*)/)[1];
140
+
141
+ return (
142
+ content.slice(0, startIdx + startMarker.length) +
143
+ '\n' + newContent.split('\n').map(l => l ? indent + l : l).join('\n') + '\n' + indent +
144
+ content.slice(endIdx)
145
+ );
146
+ }
147
+
148
+ // ─── TypeScript block generators ─────────────────────────────────────────────
149
+ function generateRolesBlock(cfg) {
150
+ const { roles, roleHierarchy, permissions, claimMap } = cfg;
151
+
152
+ const rolesLines = roles.map(r =>
153
+ ` ${r.key}: '${r.value}',`
154
+ ).join('\n');
155
+
156
+ const hierarchyLines = Object.entries(roleHierarchy)
157
+ .map(([role, inherits]) =>
158
+ ` ${role}: ${JSON.stringify(inherits)},`
159
+ ).join('\n');
160
+
161
+ const permissionsLines = Object.entries(permissions)
162
+ .map(([role, perms]) =>
163
+ ` ${role}: ${JSON.stringify(perms)},`
164
+ ).join('\n');
165
+
166
+ const claimLines = Object.entries(claimMap)
167
+ .map(([role, claim]) => ` ${role}: '${claim}',`)
168
+ .join('\n');
169
+
170
+ return [
171
+ `roles: {`,
172
+ rolesLines,
173
+ `} as Record<string, string>,`,
174
+ ``,
175
+ `// Higher roles inherit all permissions of the roles they list`,
176
+ `roleHierarchy: {`,
177
+ hierarchyLines,
178
+ `} as Record<string, string[]>,`,
179
+ ``,
180
+ `permissions: {`,
181
+ permissionsLines,
182
+ `} as Record<string, string[]>,`,
183
+ ``,
184
+ `// Maps role value → boolean JWT claim key`,
185
+ `claimMap: {`,
186
+ claimLines,
187
+ `} as Record<string, string>,`,
188
+ ].join('\n');
189
+ }
190
+
191
+ function generateFeaturesBlock(features) {
192
+ return Object.entries(features)
193
+ .map(([k, v]) => `${k}: ${v},`)
194
+ .join('\n');
195
+ }
196
+
197
+ // ─── .env writer ─────────────────────────────────────────────────────────────
198
+ function buildEnvContent(cfg) {
199
+ const { app, firebase, api, features, analytics, customAttrs } = cfg;
200
+
201
+ const shortSlug = app.shortName.toLowerCase().replace(/[^a-z0-9]/g, '_');
202
+
203
+ const lines = [
204
+ `# =============================================================================`,
205
+ `# ${app.name.toUpperCase()} — ENVIRONMENT CONFIGURATION`,
206
+ `# Generated by npm run setup on ${new Date().toISOString().slice(0, 10)}`,
207
+ `# =============================================================================`,
208
+ ``,
209
+ `# ── Project identity ─────────────────────────────────────────────────────────`,
210
+ `VITE_APP_NAME="${app.name}"`,
211
+ `VITE_APP_SHORT_NAME="${app.shortName}"`,
212
+ `VITE_APP_DESCRIPTION="${app.description}"`,
213
+ `VITE_APP_VERSION="1.0.0"`,
214
+ `VITE_APP_DOMAIN="${app.domain}"`,
215
+ `VITE_SUPPORT_EMAIL="${app.supportEmail}"`,
216
+ ``,
217
+ `# ── Firebase ─────────────────────────────────────────────────────────────────`,
218
+ `# Get these from Firebase Console → Project Settings → General`,
219
+ `VITE_FIREBASE_PROJECT_ID="${firebase.projectId}"`,
220
+ `VITE_FIREBASE_API_KEY="${firebase.apiKey}"`,
221
+ `VITE_FIREBASE_AUTH_DOMAIN="${firebase.authDomain}"`,
222
+ `VITE_FIREBASE_STORAGE_BUCKET="${firebase.storageBucket}"`,
223
+ `VITE_FIREBASE_MESSAGING_SENDER_ID="${firebase.messagingSenderId}"`,
224
+ `VITE_FIREBASE_APP_ID="${firebase.appId}"`,
225
+ firebase.measurementId
226
+ ? `VITE_FIREBASE_MEASUREMENT_ID="${firebase.measurementId}"`
227
+ : `# VITE_FIREBASE_MEASUREMENT_ID="" # Optional — Google Analytics`,
228
+ ``,
229
+ `# Firebase Emulators (uncomment for local dev)`,
230
+ `# VITE_FIREBASE_AUTH_EMULATOR_HOST="localhost"`,
231
+ `# VITE_FIREBASE_AUTH_EMULATOR_PORT="9099"`,
232
+ `# VITE_USE_EMULATORS="true"`,
233
+ ``,
234
+ `# ── API / Backend ─────────────────────────────────────────────────────────────`,
235
+ `VITE_API_BASE_URL_DEV="${api.devUrl}"`,
236
+ `VITE_API_BASE_URL_PROD="${api.prodUrl}"`,
237
+ `VITE_API_TIMEOUT="30000"`,
238
+ `VITE_API_RETRY_COUNT="2"`,
239
+ `VITE_API_RETRY_DELAY="1000"`,
240
+ ``,
241
+ `# ── Auth ─────────────────────────────────────────────────────────────────────`,
242
+ `VITE_AUTH_PERSISTENCE="local" # local | session | none`,
243
+ features.phoneVerification
244
+ ? `VITE_RECAPTCHA_SITE_KEY="" # Required for phone auth — https://console.cloud.google.com/security/recaptcha`
245
+ : `# VITE_RECAPTCHA_SITE_KEY="" # Required only if phoneVerification is enabled`,
246
+ ``,
247
+ ];
248
+
249
+ if (customAttrs && customAttrs.length > 0) {
250
+ lines.push(
251
+ `# ── Custom JWT claims / token attributes ─────────────────────────────────────`,
252
+ `# These keys are expected in Firebase custom claims set by your backend.`,
253
+ `# They are documented here for reference; the claim names are set in`,
254
+ `# app.config.ts auth.claimMap and your backend's token-minting logic.`,
255
+ `#`,
256
+ ...customAttrs.map(a => `# ${a.claimKey} → ${a.description}`),
257
+ ``,
258
+ );
259
+ }
260
+
261
+ lines.push(
262
+ `# ── SEO ─────────────────────────────────────────────────────────────────────`,
263
+ `VITE_SEO_DEFAULT_TITLE="${app.name}"`,
264
+ `VITE_SEO_DEFAULT_DESCRIPTION="${app.description}"`,
265
+ `VITE_SEO_DEFAULT_IMAGE="/og-image.jpg"`,
266
+ `VITE_SEO_DEFAULT_KEYWORDS=""`,
267
+ `# VITE_SEO_TWITTER_HANDLE=""`,
268
+ ``,
269
+ `# ── Feature flags ─────────────────────────────────────────────────────────────`,
270
+ `# Structural flags live in app.config.ts features block (edit or re-run setup).`,
271
+ `# Analytics IDs go here:`,
272
+ features.analytics
273
+ ? `VITE_GA_MEASUREMENT_ID="${analytics.gaId || ''}"`
274
+ : `# VITE_GA_MEASUREMENT_ID=""`,
275
+ `VITE_GA_ENABLED="${features.analytics}"`,
276
+ ``,
277
+ `# ── Development ───────────────────────────────────────────────────────────────`,
278
+ `NODE_ENV="development"`,
279
+ );
280
+
281
+ return lines.join('\n') + '\n';
282
+ }
283
+
284
+ // ─── .env.example writer ──────────────────────────────────────────────────────
285
+ function buildEnvExample(envContent) {
286
+ // Blank out secrets
287
+ return envContent
288
+ .replace(/(VITE_FIREBASE_API_KEY=)".+?"/, '$1"your-firebase-api-key"')
289
+ .replace(/(VITE_FIREBASE_APP_ID=)".+?"/, '$1"1:000000000000:web:000000000000000000000000"')
290
+ .replace(/(VITE_FIREBASE_MESSAGING_SENDER_ID=)".+?"/, '$1"000000000000"')
291
+ .replace(/(VITE_RECAPTCHA_SITE_KEY=)".+?"/, '$1"your-recaptcha-site-key"');
292
+ }
293
+
294
+ // ─── STEP 1 — Context ────────────────────────────────────────────────────────
295
+ async function stepContext(ctx) {
296
+ log.title('⬡ Context Detection');
297
+
298
+ if (ctx.isMonoRepo) {
299
+ log.info('Mono-repo detected (sibling functions/ directory or parent firebase.json found).');
300
+ log.info(`Running from: ${ROOT}`);
301
+ log.info(`Parent: ${PARENT}`);
302
+ console.log();
303
+ log.warn('After setup, some root-level files should live in the project parent, not here.');
304
+ log.warn('A monorepo-setup.sh script will be generated to help with that tidy-up.');
305
+ } else {
306
+ log.info('Standalone project detected.');
307
+ }
308
+ console.log();
309
+ }
310
+
311
+ // ─── STEP 2 — Project identity ────────────────────────────────────────────────
312
+ async function stepApp(existing) {
313
+ log.title('📱 Step 1 — Project Identity');
314
+
315
+ const name = await ask('App name (e.g. "Acme Dashboard")', existing.VITE_APP_NAME || '');
316
+ const shortDef = existing.VITE_APP_SHORT_NAME || name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 12);
317
+ const shortName = await ask('Short name (used for localStorage prefix, icons)', shortDef);
318
+ const desc = await ask('Description (SEO)', existing.VITE_APP_DESCRIPTION || `${name} — built with SvelteKit`);
319
+ const domain = await ask('Production domain (e.g. acme.com)', existing.VITE_APP_DOMAIN || '');
320
+ const email = await ask('Support email', existing.VITE_SUPPORT_EMAIL || `support@${domain || 'example.com'}`);
321
+
322
+ log.ok('Project identity captured.');
323
+ return { name, shortName, description: desc, domain, supportEmail: email };
324
+ }
325
+
326
+ // ─── STEP 3 — Firebase ────────────────────────────────────────────────────────
327
+ async function stepFirebase(app, existing) {
328
+ log.title('🔥 Step 2 — Firebase Configuration');
329
+ log.info('Values are in Firebase Console → Project Settings → General → Your apps → Web app.');
330
+ console.log();
331
+
332
+ let firebase = {
333
+ projectId: existing.VITE_FIREBASE_PROJECT_ID || '',
334
+ apiKey: existing.VITE_FIREBASE_API_KEY || '',
335
+ authDomain: existing.VITE_FIREBASE_AUTH_DOMAIN || '',
336
+ storageBucket: existing.VITE_FIREBASE_STORAGE_BUCKET || '',
337
+ messagingSenderId: existing.VITE_FIREBASE_MESSAGING_SENDER_ID || '',
338
+ appId: existing.VITE_FIREBASE_APP_ID || '',
339
+ measurementId: existing.VITE_FIREBASE_MEASUREMENT_ID || '',
340
+ };
341
+
342
+ // Try to read from .firebaserc first
343
+ const rc = readFirebaseRc();
344
+ if (rc?.projects?.default && !firebase.projectId) {
345
+ firebase.projectId = rc.projects.default;
346
+ log.ok(`Found project ID from .firebaserc: ${firebase.projectId}`);
347
+ }
348
+
349
+ const tryCli = await askYN('Try to fetch web config via Firebase CLI?', false);
350
+ if (tryCli) {
351
+ firebase = await fetchFirebaseCli(firebase);
352
+ }
353
+
354
+ // Manual entry for any blanks
355
+ firebase.projectId = await ask('Firebase Project ID', firebase.projectId);
356
+ firebase.apiKey = await ask('Firebase API Key', firebase.apiKey);
357
+
358
+ const authDomainDef = firebase.authDomain || `${firebase.projectId}.firebaseapp.com`;
359
+ const storageBucketDef = firebase.storageBucket || `${firebase.projectId}.appspot.com`;
360
+
361
+ firebase.authDomain = await ask('Auth Domain', authDomainDef);
362
+ firebase.storageBucket = await ask('Storage Bucket', storageBucketDef);
363
+ firebase.messagingSenderId = await ask('Messaging Sender ID', firebase.messagingSenderId);
364
+ firebase.appId = await ask('App ID', firebase.appId);
365
+ firebase.measurementId = await ask('Measurement ID (optional, for GA)', firebase.measurementId);
366
+
367
+ log.ok('Firebase configuration captured.');
368
+
369
+ // Update firebase.json
370
+ await updateFirebaseJson(app, firebase);
371
+
372
+ // Update .firebaserc
373
+ await updateFirebaseRc(app, firebase);
374
+
375
+ return firebase;
376
+ }
377
+
378
+ async function fetchFirebaseCli(firebase) {
379
+ try {
380
+ execSync('firebase --version', { stdio: 'ignore' });
381
+ } catch {
382
+ log.warn('Firebase CLI not found. Install with: npm install -g firebase-tools');
383
+ return firebase;
384
+ }
385
+ try {
386
+ const raw = execSync(`firebase apps:sdkconfig WEB --project ${firebase.projectId} 2>/dev/null`, { encoding: 'utf8' });
387
+ const apiKeyMatch = raw.match(/apiKey:\s*["']([^"']+)/);
388
+ const appIdMatch = raw.match(/appId:\s*["']([^"']+)/);
389
+ const senderIdMatch = raw.match(/messagingSenderId:\s*["']([^"']+)/);
390
+ const measureMatch = raw.match(/measurementId:\s*["']([^"']+)/);
391
+ if (apiKeyMatch) firebase.apiKey = apiKeyMatch[1];
392
+ if (appIdMatch) firebase.appId = appIdMatch[1];
393
+ if (senderIdMatch) firebase.messagingSenderId = senderIdMatch[1];
394
+ if (measureMatch) firebase.measurementId = measureMatch[1];
395
+ log.ok('Fetched web config from Firebase CLI.');
396
+ } catch {
397
+ log.warn('CLI fetch failed — enter values manually.');
398
+ }
399
+ return firebase;
400
+ }
401
+
402
+ async function updateFirebaseJson(app, firebase) {
403
+ const fbJson = readFirebaseJson();
404
+ if (!fbJson) {
405
+ log.warn('firebase.json not found — skipping update.');
406
+ return;
407
+ }
408
+
409
+ const targetAlias = app.shortName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
410
+
411
+ // Update hosting target name
412
+ if (fbJson.hosting) {
413
+ fbJson.hosting.target = targetAlias;
414
+ }
415
+
416
+ // Ask about hosting region
417
+ const region = await ask(
418
+ 'Firebase hosting/functions region',
419
+ fbJson.hosting?.frameworksBackend?.region || 'us-central1'
420
+ );
421
+ if (fbJson.hosting?.frameworksBackend) {
422
+ fbJson.hosting.frameworksBackend.region = region;
423
+ }
424
+
425
+ writeFirebaseJson(fbJson);
426
+ log.ok(`firebase.json updated (target: ${targetAlias}, region: ${region}).`);
427
+
428
+ // Persist for .firebaserc use
429
+ app._targetAlias = targetAlias;
430
+ app._region = region;
431
+ }
432
+
433
+ async function updateFirebaseRc(app, firebase) {
434
+ const targetAlias = app._targetAlias || app.shortName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
435
+ const projectId = firebase.projectId;
436
+
437
+ let rc = readFirebaseRc() || { projects: {}, targets: {}, etags: {} };
438
+
439
+ rc.projects = rc.projects || {};
440
+ rc.projects.default = projectId;
441
+
442
+ rc.targets = rc.targets || {};
443
+ rc.targets[projectId] = rc.targets[projectId] || {};
444
+ rc.targets[projectId].hosting = rc.targets[projectId].hosting || {};
445
+ // Only set if not already pointing somewhere
446
+ if (!rc.targets[projectId].hosting[targetAlias]) {
447
+ rc.targets[projectId].hosting[targetAlias] = [targetAlias];
448
+ }
449
+
450
+ writeFirebaseRc(rc);
451
+ log.ok(`.firebaserc updated (project: ${projectId}, target alias: ${targetAlias}).`);
452
+ }
453
+
454
+ // ─── STEP 4 — API / Backend ───────────────────────────────────────────────────
455
+ async function stepApi(app, firebase, existing) {
456
+ log.title('🌐 Step 3 — API / Backend');
457
+
458
+ const defaultDev = existing.VITE_API_BASE_URL_DEV
459
+ || `http://localhost:5001/${firebase.projectId}/us-central1/api`;
460
+ const defaultProd = existing.VITE_API_BASE_URL_PROD
461
+ || `https://us-central1-${firebase.projectId}.cloudfunctions.net/api`;
462
+
463
+ const hasCustomBackend = await askYN(
464
+ 'Do you have a separate backend API (not Firebase Functions)?', false
465
+ );
466
+
467
+ let devUrl, prodUrl;
468
+ if (hasCustomBackend) {
469
+ devUrl = await ask('Development API URL', defaultDev);
470
+ prodUrl = await ask('Production API URL', defaultProd);
471
+ } else {
472
+ log.info(`Using Firebase Functions URLs (edit VITE_API_BASE_URL_* in .env to change).`);
473
+ devUrl = defaultDev;
474
+ prodUrl = defaultProd;
475
+ }
476
+
477
+ log.ok('API configuration captured.');
478
+ return { devUrl, prodUrl };
479
+ }
480
+
481
+ // ─── STEP 5 — RBAC / Roles ───────────────────────────────────────────────────
482
+ async function stepRbac() {
483
+ log.title('🔑 Step 4 — Roles & Access Control');
484
+
485
+ log.info('Define the roles your app uses. Every app has a base "user" role.');
486
+ log.info('For each additional role you can specify:');
487
+ log.info(' • A role value (e.g. "admin") used in code + Firebase custom claims');
488
+ log.info(' • A boolean JWT claim key (e.g. "isAdmin") — used if your backend');
489
+ log.info(' also sets boolean flags in the token alongside a roles array.');
490
+ log.info(' • Which lower roles it inherits');
491
+ log.info(' • Permissions it grants');
492
+ console.log();
493
+
494
+ const useDefaults = await askYN(
495
+ 'Use the default role set (user, client, consultant, admin, sysadmin)?', true
496
+ );
497
+
498
+ if (useDefaults) {
499
+ return defaultRoles();
500
+ }
501
+
502
+ return await customRoles();
503
+ }
504
+
505
+ function defaultRoles() {
506
+ return {
507
+ roles: [
508
+ { key: 'USER', value: 'user' },
509
+ { key: 'CLIENT', value: 'client' },
510
+ { key: 'CONSULTANT', value: 'consultant' },
511
+ { key: 'ADMIN', value: 'admin' },
512
+ { key: 'SYS_ADMIN', value: 'sysadmin' },
513
+ ],
514
+ roleHierarchy: {
515
+ sysadmin: ['admin', 'consultant', 'client', 'user'],
516
+ admin: ['consultant', 'client', 'user'],
517
+ consultant: ['client', 'user'],
518
+ client: ['user'],
519
+ },
520
+ permissions: {
521
+ user: ['editOwnProfile'],
522
+ client: ['editOwnProfile', 'viewClientDashboard'],
523
+ consultant: ['editOwnProfile', 'viewClientDashboard', 'viewConsultantDashboard', 'viewClients'],
524
+ admin: ['editOwnProfile', 'viewClientDashboard', 'viewConsultantDashboard', 'viewClients', 'viewAdminDashboard', 'manageUsers'],
525
+ sysadmin: ['editOwnProfile', 'viewClientDashboard', 'viewConsultantDashboard', 'viewClients', 'viewAdminDashboard', 'manageUsers', 'viewSysAdminDashboard', 'manageSystem'],
526
+ },
527
+ claimMap: {
528
+ client: 'isClient',
529
+ consultant: 'isConsultant',
530
+ admin: 'isAdmin',
531
+ sysadmin: 'isSysAdmin',
532
+ },
533
+ };
534
+ }
535
+
536
+ async function customRoles() {
537
+ const roles = [{ key: 'USER', value: 'user' }];
538
+ const claimMap = {};
539
+ const roleHierarchy = {};
540
+ const permissions = { user: ['editOwnProfile'] };
541
+
542
+ log.info('Enter additional roles one at a time. Leave the role value blank to finish.');
543
+ console.log();
544
+
545
+ while (true) {
546
+ const value = await ask('Role value (e.g. "admin", blank to finish)', '');
547
+ if (!value) break;
548
+
549
+ const key = await ask(`Constant name for "${value}" (e.g. ADMIN)`, value.toUpperCase().replace(/[^A-Z0-9]/g, '_'));
550
+ const claim = await ask(`Boolean JWT claim key for "${value}" (blank = none)`, `is${value.charAt(0).toUpperCase()}${value.slice(1)}`);
551
+ if (claim) claimMap[value] = claim;
552
+
553
+ roles.push({ key, value });
554
+
555
+ const inheritsRaw = await ask(
556
+ `Which roles does "${value}" inherit? (comma-separated values, blank = none)`, ''
557
+ );
558
+ if (inheritsRaw) {
559
+ roleHierarchy[value] = inheritsRaw.split(',').map(s => s.trim()).filter(Boolean);
560
+ }
561
+
562
+ const permsRaw = await ask(
563
+ `Permissions for "${value}" (comma-separated, blank = editOwnProfile only)`, ''
564
+ );
565
+ permissions[value] = permsRaw
566
+ ? permsRaw.split(',').map(s => s.trim()).filter(Boolean)
567
+ : ['editOwnProfile'];
568
+ }
569
+
570
+ return { roles, roleHierarchy, permissions, claimMap };
571
+ }
572
+
573
+ // ─── STEP 6 — Custom JWT attributes ──────────────────────────────────────────
574
+ async function stepCustomAttrs() {
575
+ log.title('🎫 Step 5 — Custom Token Attributes');
576
+
577
+ log.info('Beyond roles, your backend may set extra custom claims in the Firebase JWT');
578
+ log.info('(e.g. tenantId, orgId, subscriptionTier, accountId).');
579
+ log.info('Document them here so the .env carries a reference for devs and agents.');
580
+ console.log();
581
+
582
+ const hasAttrs = await askYN('Do you have extra custom JWT claim attributes?', false);
583
+ if (!hasAttrs) return [];
584
+
585
+ const attrs = [];
586
+ log.info('Enter each attribute (blank claim key to finish).');
587
+ console.log();
588
+
589
+ while (true) {
590
+ const claimKey = await ask('Claim key in JWT (e.g. tenantId, blank to finish)', '');
591
+ if (!claimKey) break;
592
+ const description = await ask(`Description for "${claimKey}"`, '');
593
+ attrs.push({ claimKey, description });
594
+ }
595
+
596
+ if (attrs.length > 0) {
597
+ log.ok(`Documented ${attrs.length} custom attribute(s).`);
598
+ }
599
+ return attrs;
600
+ }
601
+
602
+ // ─── STEP 7 — Feature flags ───────────────────────────────────────────────────
603
+ async function stepFeatures() {
604
+ log.title('✨ Step 6 — Feature Flags');
605
+
606
+ const emailVerification = await askYN('Enable email verification?', true);
607
+ const phoneVerification = await askYN('Enable phone authentication (SMS)?', false);
608
+ const multiTenant = await askYN('Enable multi-tenant support?', false);
609
+ const realTimeUpdates = await askYN('Enable real-time updates (Firestore listeners)?', true);
610
+ const analytics = await askYN('Enable analytics (Google Analytics)?', false);
611
+
612
+ let gaId = '';
613
+ if (analytics) {
614
+ gaId = await ask('Google Analytics Measurement ID (G-XXXXXXXXXX)', '');
615
+ }
616
+
617
+ log.ok('Feature flags captured.');
618
+ return {
619
+ features: {
620
+ authentication: true,
621
+ userProfiles: true,
622
+ emailVerification,
623
+ phoneVerification,
624
+ multiTenant,
625
+ realTimeUpdates,
626
+ analytics,
627
+ },
628
+ analytics: analytics ? { gaId } : null,
629
+ };
630
+ }
631
+
632
+ // ─── STEP 8 — Generate & validate ────────────────────────────────────────────
633
+ async function stepGenerate(cfg) {
634
+ log.title('📝 Step 7 — Generating Configuration');
635
+
636
+ // Write .env
637
+ const envContent = buildEnvContent(cfg);
638
+ fs.writeFileSync(ENV_FILE, envContent);
639
+ log.ok('.env written');
640
+
641
+ // Write .env.example
642
+ fs.writeFileSync(ENV_EX, buildEnvExample(envContent));
643
+ log.ok('.env.example written');
644
+
645
+ // Update app.config.ts
646
+ if (fs.existsSync(APP_CFG)) {
647
+ let content = fs.readFileSync(APP_CFG, 'utf8');
648
+
649
+ const rolesBlock = generateRolesBlock(cfg.rbac);
650
+ content = replaceSetupBlock(content, 'roles', rolesBlock);
651
+
652
+ const featuresBlock = generateFeaturesBlock({
653
+ authentication: true,
654
+ userProfiles: true,
655
+ emailVerification: cfg.features.emailVerification,
656
+ phoneVerification: cfg.features.phoneVerification,
657
+ multiTenant: cfg.features.multiTenant,
658
+ realTimeUpdates: cfg.features.realTimeUpdates,
659
+ analytics: cfg.features.analytics,
660
+ });
661
+ content = replaceSetupBlock(content, 'features', featuresBlock);
662
+
663
+ fs.writeFileSync(APP_CFG, content);
664
+ log.ok('app.config.ts updated (roles + features blocks)');
665
+ } else {
666
+ log.warn('app.config.ts not found — skipping update');
667
+ }
668
+
669
+ // Mono-repo setup script
670
+ if (cfg.ctx.isMonoRepo) {
671
+ generateMonoRepoScript(cfg);
672
+ }
673
+ }
674
+
675
+ function generateMonoRepoScript(cfg) {
676
+ const scriptPath = path.join(ROOT, '__scripts__', 'monorepo-setup.sh');
677
+ const parentRel = path.relative(ROOT, PARENT);
678
+
679
+ const script = [
680
+ `#!/usr/bin/env bash`,
681
+ `# Mono-repo tidy-up script`,
682
+ `# Generated by npm run setup on ${new Date().toISOString().slice(0, 10)}`,
683
+ `#`,
684
+ `# Run this from the frontend directory (${path.basename(ROOT)}) once after`,
685
+ `# copying the boilerplate into a mono-repo structure.`,
686
+ `# Review each command before running — it moves files to the project root.`,
687
+ `#`,
688
+ `# Usage: bash __scripts__/monorepo-setup.sh`,
689
+ ``,
690
+ `set -e`,
691
+ `FRONTEND_DIR="$(cd "$(dirname "$0")/.." && pwd)"`,
692
+ `PROJECT_ROOT="$(cd "$FRONTEND_DIR/.." && pwd)"`,
693
+ ``,
694
+ `echo "Moving shared files from frontend → project root..."`,
695
+ ``,
696
+ `# Move .claude skills/config to project root`,
697
+ `if [ -d "$FRONTEND_DIR/.claude" ] && [ ! -d "$PROJECT_ROOT/.claude" ]; then`,
698
+ ` mv "$FRONTEND_DIR/.claude" "$PROJECT_ROOT/.claude"`,
699
+ ` echo " ✓ .claude/ → project root"`,
700
+ `fi`,
701
+ ``,
702
+ `# Move __docs__ if present`,
703
+ `if [ -d "$FRONTEND_DIR/__docs__" ] && [ ! -d "$PROJECT_ROOT/__docs__" ]; then`,
704
+ ` mv "$FRONTEND_DIR/__docs__" "$PROJECT_ROOT/__docs__"`,
705
+ ` echo " ✓ __docs__/ → project root"`,
706
+ `fi`,
707
+ ``,
708
+ `# Merge .gitignore — append frontend's gitignore to parent if parent has one`,
709
+ `if [ -f "$FRONTEND_DIR/.gitignore" ] && [ -f "$PROJECT_ROOT/.gitignore" ]; then`,
710
+ ` echo "" >> "$PROJECT_ROOT/.gitignore"`,
711
+ ` echo "# frontend/" >> "$PROJECT_ROOT/.gitignore"`,
712
+ ` cat "$FRONTEND_DIR/.gitignore" | sed 's|^|frontend/|' | grep -v "^frontend/#" >> "$PROJECT_ROOT/.gitignore"`,
713
+ ` echo " ✓ .gitignore entries merged into project root"`,
714
+ `elif [ -f "$FRONTEND_DIR/.gitignore" ]; then`,
715
+ ` cp "$FRONTEND_DIR/.gitignore" "$PROJECT_ROOT/.gitignore"`,
716
+ ` echo " ✓ .gitignore copied to project root"`,
717
+ `fi`,
718
+ ``,
719
+ `echo ""`,
720
+ `echo "Remaining in frontend/:"`,
721
+ `echo " package.json, src/, vite.config.ts, svelte.config.js, etc."`,
722
+ `echo ""`,
723
+ `echo "Remaining in project root (or functions/):"`,
724
+ `echo " firebase.json, .firebaserc, firestore.rules, storage.rules, cors.json"`,
725
+ `echo ""`,
726
+ `echo "Done. Review git status before committing."`,
727
+ ].join('\n') + '\n';
728
+
729
+ fs.writeFileSync(scriptPath, script);
730
+ fs.chmodSync(scriptPath, '755');
731
+ log.ok('__scripts__/monorepo-setup.sh generated — review before running.');
732
+ }
733
+
734
+ // ─── STEP 9 — Validate ───────────────────────────────────────────────────────
735
+ async function stepValidate(cfg) {
736
+ log.title('✅ Step 8 — Validation');
737
+
738
+ let ok = true;
739
+
740
+ // .env exists
741
+ if (fs.existsSync(ENV_FILE)) {
742
+ log.ok('.env file exists');
743
+ } else {
744
+ log.err('.env missing');
745
+ ok = false;
746
+ }
747
+
748
+ // Firebase not placeholder
749
+ if (cfg.firebase.projectId && cfg.firebase.projectId !== 'your-project-id') {
750
+ log.ok(`Firebase project: ${cfg.firebase.projectId}`);
751
+ } else {
752
+ log.warn('Firebase project ID still looks like a placeholder');
753
+ }
754
+
755
+ if (cfg.firebase.apiKey && cfg.firebase.apiKey !== 'your-api-key') {
756
+ log.ok('Firebase API key present');
757
+ } else {
758
+ log.warn('Firebase API key is empty or placeholder');
759
+ }
760
+
761
+ // node_modules
762
+ if (fs.existsSync(path.join(ROOT, 'node_modules'))) {
763
+ log.ok('node_modules found');
764
+ } else {
765
+ log.warn('Run npm install before starting the dev server');
766
+ }
767
+
768
+ return ok;
769
+ }
770
+
771
+ // ─── Summary ─────────────────────────────────────────────────────────────────
772
+ function displaySummary(cfg) {
773
+ log.title('🎉 Setup Complete!');
774
+
775
+ console.log(`${C.bold}Configuration:${C.reset}`);
776
+ console.log(` App: ${C.green}${cfg.app.name}${C.reset} (${cfg.app.shortName})`);
777
+ console.log(` Domain: ${C.green}${cfg.app.domain}${C.reset}`);
778
+ console.log(` Firebase: ${C.green}${cfg.firebase.projectId}${C.reset}`);
779
+ console.log(` API (dev): ${C.green}${cfg.api.devUrl}${C.reset}`);
780
+ console.log(` API (prod): ${C.green}${cfg.api.prodUrl}${C.reset}`);
781
+ console.log(` Roles: ${C.green}${cfg.rbac.roles.map(r => r.value).join(', ')}${C.reset}`);
782
+ console.log();
783
+
784
+ console.log(`${C.bold}Files written:${C.reset}`);
785
+ console.log(` ${C.green}✓${C.reset} .env`);
786
+ console.log(` ${C.green}✓${C.reset} .env.example`);
787
+ console.log(` ${C.green}✓${C.reset} src/lib/config/app.config.ts`);
788
+ console.log(` ${C.green}✓${C.reset} firebase.json`);
789
+ console.log(` ${C.green}✓${C.reset} .firebaserc`);
790
+ if (cfg.ctx.isMonoRepo) {
791
+ console.log(` ${C.green}✓${C.reset} __scripts__/monorepo-setup.sh`);
792
+ }
793
+ console.log();
794
+
795
+ console.log(`${C.bold}Next steps:${C.reset}`);
796
+ console.log(` 1. ${C.cyan}npm install${C.reset} Install dependencies`);
797
+ console.log(` 2. ${C.cyan}npm run dev${C.reset} Start dev server (http://localhost:5173)`);
798
+ console.log(` 3. ${C.cyan}npm run validate${C.reset} Full validation (build + tests)`);
799
+ if (cfg.ctx.isMonoRepo) {
800
+ console.log(` 4. ${C.cyan}bash __scripts__/monorepo-setup.sh${C.reset} Tidy mono-repo structure`);
801
+ }
802
+ console.log();
803
+ console.log(` ${C.yellow}⚠${C.reset} Never commit .env to git`);
804
+ console.log(` ${C.yellow}⚠${C.reset} Share .env.example with your team`);
805
+ console.log();
806
+ console.log(`${C.bold}${C.blue}Happy coding! 🚀${C.reset}`);
807
+ }
808
+
809
+ // ─── Non-interactive config file support ─────────────────────────────────────
810
+
811
+ function getConfigArg() {
812
+ const idx = process.argv.indexOf('--config');
813
+ if (idx === -1 || idx + 1 >= process.argv.length) return null;
814
+ return process.argv[idx + 1];
815
+ }
816
+
817
+ function loadConfigFile(configPath) {
818
+ if (!fs.existsSync(configPath)) {
819
+ log.err(`Config file not found: ${configPath}`);
820
+ process.exit(1);
821
+ }
822
+ try {
823
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
824
+ } catch (err) {
825
+ log.err(`Failed to parse config file: ${err.message}`);
826
+ process.exit(1);
827
+ }
828
+ }
829
+
830
+ function validateConfig(config) {
831
+ const errors = [];
832
+
833
+ // app (all required)
834
+ if (!config.app) errors.push('Missing "app" section');
835
+ if (!config.app?.name) errors.push('Missing app.name');
836
+ if (!config.app?.shortName) errors.push('Missing app.shortName');
837
+ if (!config.app?.description) errors.push('Missing app.description');
838
+ if (!config.app?.domain) errors.push('Missing app.domain');
839
+ if (!config.app?.supportEmail) errors.push('Missing app.supportEmail');
840
+
841
+ // firebase (all required except measurementId and region)
842
+ if (!config.firebase) errors.push('Missing "firebase" section');
843
+ if (!config.firebase?.projectId) errors.push('Missing firebase.projectId');
844
+ if (!config.firebase?.apiKey) errors.push('Missing firebase.apiKey');
845
+ if (!config.firebase?.authDomain) errors.push('Missing firebase.authDomain');
846
+ if (!config.firebase?.storageBucket) errors.push('Missing firebase.storageBucket');
847
+ if (!config.firebase?.messagingSenderId) errors.push('Missing firebase.messagingSenderId');
848
+ if (!config.firebase?.appId) errors.push('Missing firebase.appId');
849
+
850
+ // api
851
+ if (!config.api) errors.push('Missing "api" section');
852
+ if (!config.api?.devUrl) errors.push('Missing api.devUrl');
853
+ if (!config.api?.prodUrl) errors.push('Missing api.prodUrl');
854
+
855
+ // rbac
856
+ if (!config.rbac) errors.push('Missing "rbac" section');
857
+ if (config.rbac && !config.rbac.useDefaults && !Array.isArray(config.rbac.roles)) {
858
+ errors.push('rbac must have either useDefaults: true or a roles array');
859
+ }
860
+
861
+ // features
862
+ if (!config.features) errors.push('Missing "features" section');
863
+ const featureKeys = ['emailVerification', 'phoneVerification', 'multiTenant', 'realTimeUpdates', 'analytics'];
864
+ for (const key of featureKeys) {
865
+ if (config.features && typeof config.features[key] !== 'boolean') {
866
+ errors.push(`features.${key} must be a boolean`);
867
+ }
868
+ }
869
+
870
+ if (errors.length > 0) {
871
+ log.err('Config file validation failed:');
872
+ errors.forEach(e => log.err(` • ${e}`));
873
+ process.exit(1);
874
+ }
875
+ }
876
+
877
+ function buildCfgFromConfig(config, ctx) {
878
+ const app = {
879
+ name: config.app.name,
880
+ shortName: config.app.shortName,
881
+ description: config.app.description,
882
+ domain: config.app.domain,
883
+ supportEmail: config.app.supportEmail,
884
+ };
885
+
886
+ const firebase = {
887
+ projectId: config.firebase.projectId,
888
+ apiKey: config.firebase.apiKey,
889
+ authDomain: config.firebase.authDomain,
890
+ storageBucket: config.firebase.storageBucket,
891
+ messagingSenderId: config.firebase.messagingSenderId,
892
+ appId: config.firebase.appId,
893
+ measurementId: config.firebase.measurementId || '',
894
+ };
895
+
896
+ const api = {
897
+ devUrl: config.api.devUrl,
898
+ prodUrl: config.api.prodUrl,
899
+ };
900
+
901
+ let rbac;
902
+ if (config.rbac.useDefaults) {
903
+ rbac = defaultRoles();
904
+ } else {
905
+ rbac = {
906
+ roles: config.rbac.roles,
907
+ roleHierarchy: {},
908
+ permissions: {},
909
+ claimMap: {},
910
+ };
911
+ for (const role of config.rbac.roles) {
912
+ if (role.claimKey) rbac.claimMap[role.value] = role.claimKey;
913
+ if (role.inherits) rbac.roleHierarchy[role.value] = role.inherits;
914
+ if (role.permissions) rbac.permissions[role.value] = role.permissions;
915
+ }
916
+ }
917
+
918
+ const features = {
919
+ authentication: true,
920
+ userProfiles: true,
921
+ emailVerification: config.features.emailVerification,
922
+ phoneVerification: config.features.phoneVerification,
923
+ multiTenant: config.features.multiTenant,
924
+ realTimeUpdates: config.features.realTimeUpdates,
925
+ analytics: config.features.analytics,
926
+ };
927
+
928
+ const analytics = config.features.analytics
929
+ ? { gaId: config.features.gaId || '' }
930
+ : null;
931
+
932
+ const customAttrs = config.customAttributes || [];
933
+
934
+ return { ctx, app, firebase, api, rbac, features, analytics, customAttrs };
935
+ }
936
+
937
+ function updateFirebaseJsonNonInteractive(app, firebase, region) {
938
+ const fbJson = readFirebaseJson();
939
+ if (!fbJson) {
940
+ log.warn('firebase.json not found — skipping update.');
941
+ return;
942
+ }
943
+
944
+ const targetAlias = app.shortName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
945
+
946
+ if (fbJson.hosting) {
947
+ fbJson.hosting.target = targetAlias;
948
+ }
949
+
950
+ if (fbJson.hosting?.frameworksBackend) {
951
+ fbJson.hosting.frameworksBackend.region = region;
952
+ }
953
+
954
+ writeFirebaseJson(fbJson);
955
+ log.ok(`firebase.json updated (target: ${targetAlias}, region: ${region}).`);
956
+
957
+ app._targetAlias = targetAlias;
958
+ app._region = region;
959
+ }
960
+
961
+ function updateFirebaseRcNonInteractive(app, firebase) {
962
+ const targetAlias = app._targetAlias || app.shortName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
963
+ const projectId = firebase.projectId;
964
+
965
+ let rc = readFirebaseRc() || { projects: {}, targets: {}, etags: {} };
966
+
967
+ rc.projects = rc.projects || {};
968
+ rc.projects.default = projectId;
969
+
970
+ rc.targets = rc.targets || {};
971
+ rc.targets[projectId] = rc.targets[projectId] || {};
972
+ rc.targets[projectId].hosting = rc.targets[projectId].hosting || {};
973
+ if (!rc.targets[projectId].hosting[targetAlias]) {
974
+ rc.targets[projectId].hosting[targetAlias] = [targetAlias];
975
+ }
976
+
977
+ writeFirebaseRc(rc);
978
+ log.ok(`.firebaserc updated (project: ${projectId}, target alias: ${targetAlias}).`);
979
+ }
980
+
981
+ // ─── Main ────────────────────────────────────────────────────────────────────
982
+ async function main() {
983
+ const configPath = getConfigArg();
984
+
985
+ if (configPath) {
986
+ // ── Non-interactive mode ──────────────────────────────────────────────────
987
+ log.title('XBG SvelteKit Boilerplate — Non-Interactive Setup');
988
+ log.info(`Reading config from: ${configPath}`);
989
+ console.log();
990
+
991
+ const config = loadConfigFile(configPath);
992
+ validateConfig(config);
993
+
994
+ const ctx = detectContext();
995
+ const cfg = buildCfgFromConfig(config, ctx);
996
+
997
+ // Update firebase.json and .firebaserc (these are separate from stepGenerate)
998
+ const region = config.firebase.region || 'us-central1';
999
+ updateFirebaseJsonNonInteractive(cfg.app, cfg.firebase, region);
1000
+ updateFirebaseRcNonInteractive(cfg.app, cfg.firebase);
1001
+
1002
+ await stepGenerate(cfg);
1003
+ await stepValidate(cfg);
1004
+ displaySummary(cfg);
1005
+ return;
1006
+ }
1007
+
1008
+ // ── Interactive mode (original) ──────────────────────────────────────────────
1009
+ console.clear();
1010
+ console.log(`${C.bold}${C.blue}╔══════════════════════════════════════════════╗${C.reset}`);
1011
+ console.log(`${C.bold}${C.blue}║ XBG SvelteKit Boilerplate — Setup Wizard ║${C.reset}`);
1012
+ console.log(`${C.bold}${C.blue}╚══════════════════════════════════════════════╝${C.reset}`);
1013
+ console.log();
1014
+
1015
+ const ctx = detectContext();
1016
+ const existing = loadExistingEnv();
1017
+
1018
+ await stepContext(ctx);
1019
+
1020
+ const app = await stepApp(existing);
1021
+ const firebase = await stepFirebase(app, existing);
1022
+ const api = await stepApi(app, firebase, existing);
1023
+ const rbac = await stepRbac();
1024
+ const customAttrs = await stepCustomAttrs();
1025
+ const { features, analytics } = await stepFeatures();
1026
+
1027
+ const cfg = { ctx, app, firebase, api, rbac, features, analytics, customAttrs };
1028
+
1029
+ await stepGenerate(cfg);
1030
+ await stepValidate(cfg);
1031
+ displaySummary(cfg);
1032
+
1033
+ rl.close();
1034
+ }
1035
+
1036
+ // ─── Graceful exit ────────────────────────────────────────────────────────────
1037
+ process.on('SIGINT', () => {
1038
+ console.log('\n');
1039
+ log.warn('Setup cancelled. Run npm run setup to start again.');
1040
+ rl.close();
1041
+ process.exit(0);
1042
+ });
1043
+
1044
+ main().catch((err) => {
1045
+ log.err(`Setup failed: ${err.message}`);
1046
+ if (process.env.DEBUG) console.error(err);
1047
+ rl.close();
1048
+ process.exit(1);
1049
+ });