create-efc-app 0.7.0 → 0.8.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/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import * as p from "@clack/prompts";
5
5
  import pc from "picocolors";
6
6
  import { spawn } from "child_process";
7
+ import path3 from "path";
7
8
 
8
9
  // src/scaffold.ts
9
10
  import fs from "fs-extra";
@@ -30,12 +31,85 @@ var NO_USER_FEATURES = {
30
31
  apiAndBilling: false,
31
32
  support: false
32
33
  };
34
+ function usesOAuth(opts) {
35
+ return !!(opts.oauth?.google || opts.oauth?.apple);
36
+ }
37
+ function principals(opts) {
38
+ return {
39
+ user: !!opts.registry || opts.userPortal,
40
+ admin: !!opts.registry || opts.adminPortal
41
+ };
42
+ }
43
+ function sendsMail(opts) {
44
+ return !!opts.registry || opts.mailer;
45
+ }
46
+ function usesTables(opts) {
47
+ return opts.database === "postgresql" && opts.modelApi !== "defineModel";
48
+ }
49
+ function orm(opts) {
50
+ const t = usesTables(opts);
51
+ const where = (filter) => filter.replace(/\s/g, "") === "{}" ? "" : `{ where: ${filter} }`;
52
+ const byId = (id) => id === "id" ? "{ id }" : `{ id: ${id} }`;
53
+ return {
54
+ findOne: (m, filter) => t ? `${m}.findFirst({ where: ${filter} })` : `${m}.findOne(${filter})`,
55
+ findById: (m, id) => t ? `${m}.findUnique({ where: ${byId(id)} })` : `${m}.findById(${id})`,
56
+ find: (m, filter) => t ? `${m}.findMany(${where(filter)})` : `${m}.find(${filter})`,
57
+ count: (m, filter) => t ? `${m}.count(${where(filter)})` : `${m}.count(${filter})`,
58
+ create: (m, data) => t ? `${m}.create({ data: ${data} })` : `${m}.create(${data})`,
59
+ update: (m, id, data) => t ? `${m}.update({ where: ${byId(id)}, data: ${data} })` : `${m}.update(${id}, ${data})`,
60
+ delete: (m, id) => t ? `${m}.delete({ where: ${byId(id)} })` : `${m}.delete(${id})`
61
+ };
62
+ }
63
+ function generatedModels(opts) {
64
+ const uf = opts.userFeatures;
65
+ const af = opts.adminFeatures;
66
+ const user2 = opts.userPortal;
67
+ const admin2 = opts.adminPortal;
68
+ const m = /* @__PURE__ */ new Set();
69
+ if (user2 || admin2 && af.userManagement) m.add("User");
70
+ if (admin2) m.add("Admin");
71
+ if (opts.registry && (user2 || admin2)) m.add("User").add("Admin");
72
+ if (user2 && uf.accountSecurity) m.add("Session");
73
+ if (user2 && uf.notifications || admin2 && af.notificationsAndLogs) m.add("Notification");
74
+ if (user2 && uf.filesAndMedia) m.add("File");
75
+ if (user2 && uf.support || admin2 && af.supportManagement) m.add("SupportTicket");
76
+ if (admin2 && af.notificationsAndLogs) m.add("AuditLog");
77
+ if (user2 && uf.apiAndBilling) m.add("Subscription").add("Invoice").add("ApiKey");
78
+ if (usesOAuth(opts)) m.add("OAuthAccount");
79
+ if (admin2 && af.billingManagement) m.add("Plan").add("Coupon");
80
+ if (opts.rbac && admin2 && af.adminManagement) m.add("Role");
81
+ if (admin2 && af.contentManagement) m.add("FAQ").add("Blog").add("Category");
82
+ if (opts.registry && m.has("Subscription")) m.add("Plan");
83
+ return m;
84
+ }
85
+ function modelApi(opts) {
86
+ const name = usesTables(opts) ? "defineTable" : "defineModel";
87
+ return { name, import: `import { ${name} } from 'express-file-cluster';` };
88
+ }
89
+ function refField(opts, r) {
90
+ if (!usesTables(opts) || !generatedModels(opts).has(r.target)) return r.legacy;
91
+ const opts2 = `${r.required ? ", required: true" : ""}${r.onDelete === "restrict" ? "" : `, onDelete: '${r.onDelete}'`}`;
92
+ return ` ${(r.field + ":").padEnd(13)}{ type: 'uuid', ref: '${r.target}'${opts2} },`;
93
+ }
94
+ function relationsOption(opts, rels) {
95
+ if (!usesTables(opts) || opts.registry) return "";
96
+ const models = generatedModels(opts);
97
+ const live = rels.filter((r) => models.has(r.target));
98
+ if (live.length === 0) return "";
99
+ const lines = live.map(
100
+ (r) => ` ${r.name}: { ${r.kind}: '${r.target}', foreignKey: '${r.foreignKey}' },`
101
+ );
102
+ return `, {
103
+ relations: {
104
+ ${lines.join("\n")}
105
+ },
106
+ }`;
107
+ }
33
108
  async function scaffold(opts) {
34
109
  const dest = path.resolve(process.cwd(), opts.projectName);
35
110
  await fs.ensureDir(dest);
36
111
  const uf = opts.userFeatures;
37
- const af = opts.adminFeatures;
38
- const needsUserModel = opts.userPortal || opts.adminPortal && af.userManagement;
112
+ const models = generatedModels(opts);
39
113
  await writePackageJson(dest, opts);
40
114
  await writeTsConfig(dest, opts);
41
115
  await writeEfcConfig(dest, opts);
@@ -44,28 +118,32 @@ async function scaffold(opts) {
44
118
  await writeAgentsMd(dest, opts);
45
119
  await writeEnvFiles(dest, opts);
46
120
  await writeExampleRoute(dest, opts);
47
- if (needsUserModel) await writeUserModel(dest, opts);
48
- if (opts.adminPortal) await writeAdminModel(dest, opts);
49
- await writeAuthRoutes(dest, opts);
121
+ if (models.has("User")) await writeUserModel(dest, opts);
122
+ if (models.has("Admin")) await writeAdminModel(dest, opts);
123
+ if (!opts.registry || opts.userPortal || opts.adminPortal) await writeAuthRoutes(dest, opts);
50
124
  if (opts.adminPortal) await writeAdminRoutes(dest, opts);
51
125
  if (opts.userPortal && uf.profileViewing) await writeUserRoutes(dest, opts);
52
126
  if (opts.tasks) await writeExampleTask(dest, opts);
53
- if (opts.userPortal && uf.accountSecurity) await writeSessionModel(dest, opts);
54
- if (opts.userPortal && uf.notifications || opts.adminPortal && af.notificationsAndLogs)
55
- await writeNotificationModel(dest, opts);
56
- if (opts.userPortal && uf.filesAndMedia) await writeFileModel(dest, opts);
57
- if (opts.userPortal && uf.support || opts.adminPortal && af.supportManagement)
58
- await writeSupportTicketModel(dest, opts);
59
- if (opts.adminPortal && af.notificationsAndLogs) await writeAuditLogModel(dest, opts);
60
- if (opts.userPortal && uf.apiAndBilling) await writeSubscriptionModel(dest, opts);
61
- if (opts.adminPortal && af.billingManagement) await writePlanModel(dest, opts);
62
- if (opts.userPortal && uf.apiAndBilling) await writeInvoiceModel(dest, opts);
63
- if (opts.userPortal && uf.apiAndBilling) await writeApiKeyModel(dest, opts);
64
- if (opts.rbac && opts.adminPortal && af.adminManagement) await writeRoleModel(dest, opts);
65
- if (opts.adminPortal && af.contentManagement) await writeFAQModel(dest, opts);
66
- if (opts.adminPortal && af.contentManagement) await writeBlogModel(dest, opts);
67
- if (opts.adminPortal && af.contentManagement) await writeCategoryModel(dest, opts);
68
- if (opts.adminPortal && af.billingManagement) await writeCouponModel(dest, opts);
127
+ const extended = [
128
+ ["Session", writeSessionModel],
129
+ ["Notification", writeNotificationModel],
130
+ ["File", writeFileModel],
131
+ ["SupportTicket", writeSupportTicketModel],
132
+ ["AuditLog", writeAuditLogModel],
133
+ ["Subscription", writeSubscriptionModel],
134
+ ["Plan", writePlanModel],
135
+ ["Invoice", writeInvoiceModel],
136
+ ["ApiKey", writeApiKeyModel],
137
+ ["Role", writeRoleModel],
138
+ ["FAQ", writeFAQModel],
139
+ ["Blog", writeBlogModel],
140
+ ["Category", writeCategoryModel],
141
+ ["Coupon", writeCouponModel]
142
+ ];
143
+ for (const [name, write] of extended) if (models.has(name)) await write(dest, opts);
144
+ if (models.has("OAuthAccount")) await writeOAuthAccountModel(dest, opts);
145
+ if (usesOAuth(opts)) await writeOAuth(dest, opts);
146
+ if (usesTables(opts)) await writeModelIndex(dest, opts);
69
147
  if (opts.userPortal) await writeAuthExtendedRoutes(dest, opts);
70
148
  if (opts.userPortal) await writeUserExtendedRoutes(dest, opts);
71
149
  if (opts.userPortal && uf.apiAndBilling) await writeUserBillingRoutes(dest, opts);
@@ -75,7 +153,7 @@ async function scaffold(opts) {
75
153
  }
76
154
  async function writePackageJson(dest, opts) {
77
155
  const deps = {
78
- "express-file-cluster": "^0.7.0"
156
+ "express-file-cluster": "^0.8.0"
79
157
  };
80
158
  if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
81
159
  if (opts.database === "postgresql") {
@@ -85,7 +163,9 @@ async function writePackageJson(dest, opts) {
85
163
  if (opts.tasks && opts.taskBackend === "bullmq") deps["bullmq"] = "^5.0.0";
86
164
  if (opts.tasks && opts.taskBackend === "pg-boss") deps["pg-boss"] = "^12.0.0";
87
165
  if (opts.mailer) deps["nodemailer"] = "^6.9.0";
88
- deps["bcrypt"] = "^5.1.0";
166
+ if (usesOAuth(opts)) deps["jose"] = "^6.2.3";
167
+ const auth = !opts.registry || opts.userPortal || opts.adminPortal;
168
+ if (auth) deps["bcrypt"] = "^5.1.0";
89
169
  const devDeps = {
90
170
  vitest: "^5.0.3"
91
171
  };
@@ -96,7 +176,7 @@ async function writePackageJson(dest, opts) {
96
176
  devDeps["@types/express"] = "^4.17.21";
97
177
  devDeps["tsx"] = "^4.0.0";
98
178
  if (opts.mailer) devDeps["@types/nodemailer"] = "^6.4.0";
99
- devDeps["@types/bcrypt"] = "^5.0.0";
179
+ if (auth) devDeps["@types/bcrypt"] = "^5.0.0";
100
180
  if (opts.database === "postgresql") devDeps["@types/pg"] = "^8.11.0";
101
181
  }
102
182
  const pkg = {
@@ -229,11 +309,17 @@ Optional \u2014 the same docs over MCP, with search and scaffolding tools:
229
309
  - \`src/api/**\` \u2014 every file is a route (\`users/[id].${ext}\` \u2192 \`/v1/api/users/:id\`); export
230
310
  uppercase \`GET\`/\`POST\`/\u2026 handlers.
231
311
  - \`src/tasks/*.${ext}\` \u2014 background tasks; the file name is the task name (not recursive).
232
- - \`src/model/*.${ext}\` \u2014 \`defineModel\` models.
312
+ ${usesTables(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineTable\` models (PostgreSQL-native, Prisma-style queries:
313
+ \`findMany({ where, include })\`, \`create({ data })\`, \`update({ where, data })\`). A \`ref\` is a
314
+ foreign key; ${opts.registry ? "add back-relations to a model's `relations` when you need `include`" : "back-relations are in each model's `relations`"}. \`src/model/index.${ext}\` imports
315
+ them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
233
316
  - \`src/index.${ext}\` is the entry point; it imports \`efc.config.${ext}\` and passes it to \`ignite()\`.
234
317
  - Only three import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
235
318
  \`express-file-cluster/tasks\`.
236
-
319
+ ${opts.registry ? `- Ready-made features (auth, user/admin portals, billing, mailer\u2026) come from the registry:
320
+ \`npx efc list\`, then \`npx efc add <item> --yes\` \u2014 check it before hand-writing auth or CRUD
321
+ (\`${D}/cli/add.md\`). \`efc.json\` tracks what was added; commit it.
322
+ ` : ""}
237
323
  ## Commands
238
324
 
239
325
  \`\`\`bash
@@ -271,16 +357,48 @@ SMTP_PASS=${isGmail ? "your_16_char_app_password" : "your_smtp_password"}${passC
271
357
  SMTP_FROM=noreply@yourapp.com
272
358
  ` : "";
273
359
  const redis = opts.tasks && opts.taskBackend !== "pg-boss" ? "\nREDIS_URL=redis://localhost:6379" : "";
360
+ const oauth = opts.oauth ?? {};
361
+ const oauthVars = [
362
+ ...oauth.google ? [
363
+ ["GOOGLE_CLIENT_ID", "", "your-client-id.apps.googleusercontent.com"],
364
+ ["GOOGLE_CLIENT_SECRET", "", "your-client-secret"],
365
+ [
366
+ "GOOGLE_REDIRECT_URI",
367
+ "http://localhost:3000/v1/api/auth/google/callback",
368
+ "https://api.yourapp.com/v1/api/auth/google/callback"
369
+ ]
370
+ ] : [],
371
+ ...oauth.apple ? [
372
+ ["APPLE_CLIENT_ID", "", "com.yourapp.signin"],
373
+ ["APPLE_TEAM_ID", "", "ABCDE12345"],
374
+ ["APPLE_KEY_ID", "", "FGHIJ67890"],
375
+ [
376
+ "APPLE_PRIVATE_KEY",
377
+ "",
378
+ "-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----"
379
+ ],
380
+ [
381
+ "APPLE_REDIRECT_URI",
382
+ "https://api.yourapp.com/v1/api/auth/apple/callback",
383
+ "https://api.yourapp.com/v1/api/auth/apple/callback"
384
+ ]
385
+ ] : [],
386
+ ...usesOAuth(opts) ? [["OAUTH_SUCCESS_REDIRECT", "http://localhost:3000/", "https://yourapp.com/"]] : []
387
+ ];
388
+ const oauthEnv = oauthVars.map(([k, v]) => `
389
+ ${k}=${v}`).join("");
390
+ const oauthExample = oauthVars.map(([k, , ex]) => `
391
+ ${k}=${ex}`).join("");
274
392
  const dotenv = `PORT=3000
275
393
  NODE_ENV=development
276
394
  DATABASE_URL=${dbUrl}
277
395
  JWT_SECRET=${secret}${redis}
278
- CORS_ORIGINS=http://localhost:3000${smtpVars}`;
396
+ CORS_ORIGINS=http://localhost:3000${smtpVars}${oauthEnv}`;
279
397
  const example = `PORT=3000
280
398
  NODE_ENV=development
281
399
  DATABASE_URL=${dbExampleUrl}
282
400
  JWT_SECRET=<generate with: openssl rand -hex 64>${redis}
283
- CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}`;
401
+ CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}${oauthExample}`;
284
402
  await fs.outputFile(path.join(dest, ".env"), dotenv);
285
403
  await fs.outputFile(path.join(dest, ".env.example"), example);
286
404
  }
@@ -391,9 +509,9 @@ export default defineTask(async (payload) => {
391
509
  }
392
510
  async function writeUserModel(dest, opts) {
393
511
  const ext = opts.language === "typescript" ? "ts" : "js";
394
- const content = `import { defineModel } from 'express-file-cluster';
512
+ const content = `${modelApi(opts).import}
395
513
 
396
- export const User = defineModel('User', {
514
+ export const User = ${modelApi(opts).name}('User', {
397
515
  name: { type: 'string', required: true },
398
516
  email: { type: 'string', required: true, unique: true },
399
517
  password: { type: 'string', required: true },
@@ -406,15 +524,23 @@ export const User = defineModel('User', {
406
524
  resetTokenExpiry: { type: 'date' },
407
525
  refreshToken: { type: 'string' },
408
526
  refreshTokenExpiry: { type: 'date' },
409
- });
527
+ }${relationsOption(opts, [
528
+ { name: "sessions", kind: "hasMany", target: "Session", foreignKey: "user" },
529
+ { name: "notifications", kind: "hasMany", target: "Notification", foreignKey: "user" },
530
+ { name: "files", kind: "hasMany", target: "File", foreignKey: "user" },
531
+ { name: "tickets", kind: "hasMany", target: "SupportTicket", foreignKey: "user" },
532
+ { name: "subscriptions", kind: "hasMany", target: "Subscription", foreignKey: "user" },
533
+ { name: "invoices", kind: "hasMany", target: "Invoice", foreignKey: "user" },
534
+ { name: "apiKeys", kind: "hasMany", target: "ApiKey", foreignKey: "user" }
535
+ ])});
410
536
  `;
411
537
  await fs.outputFile(path.join(dest, "src", "model", `User.${ext}`), content);
412
538
  }
413
539
  async function writeAdminModel(dest, opts) {
414
540
  const ext = opts.language === "typescript" ? "ts" : "js";
415
- const content = `import { defineModel } from 'express-file-cluster';
541
+ const content = `${modelApi(opts).import}
416
542
 
417
- export const Admin = defineModel('Admin', {
543
+ export const Admin = ${modelApi(opts).name}('Admin', {
418
544
  name: { type: 'string', required: true },
419
545
  email: { type: 'string', required: true, unique: true },
420
546
  password: { type: 'string', required: true },
@@ -425,11 +551,16 @@ export const Admin = defineModel('Admin', {
425
551
  resetTokenExpiry: { type: 'date' },
426
552
  refreshToken: { type: 'string' },
427
553
  refreshTokenExpiry: { type: 'date' },
428
- });
554
+ }${relationsOption(opts, [
555
+ { name: "assignedTickets", kind: "hasMany", target: "SupportTicket", foreignKey: "assignedTo" },
556
+ { name: "auditLogs", kind: "hasMany", target: "AuditLog", foreignKey: "admin" },
557
+ { name: "posts", kind: "hasMany", target: "Blog", foreignKey: "author" }
558
+ ])});
429
559
  `;
430
560
  await fs.outputFile(path.join(dest, "src", "model", `Admin.${ext}`), content);
431
561
  }
432
562
  async function writeAuthRoutes(dest, opts) {
563
+ const db = orm(opts);
433
564
  const ext = opts.language === "typescript" ? "ts" : "js";
434
565
  const ts = opts.language === "typescript";
435
566
  const loginMeta = opts.routeDocs ? ts ? `import type { RouteMeta } from 'express-file-cluster';
@@ -451,15 +582,14 @@ export const meta: RouteMeta = {
451
582
  };
452
583
 
453
584
  ` : "";
454
- const hasUser = opts.userPortal;
455
- const hasAdmin = opts.adminPortal;
585
+ const { user: hasUser, admin: hasAdmin } = principals(opts);
456
586
  const hasModels = hasUser || hasAdmin;
457
587
  const loginDbImports = hasModels ? `import bcrypt from 'bcrypt';
458
588
  import crypto from 'node:crypto';
459
589
  ${hasUser ? `import { User } from '../../model/User.js';
460
590
  ` : ""}${hasAdmin ? `import { Admin } from '../../model/Admin.js';
461
591
  ` : ""}` : "";
462
- const loginBody = hasAdmin && hasUser ? ` const admin = await Admin.findOne({ email });
592
+ const loginBody = hasAdmin && hasUser ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
463
593
  if (admin) {
464
594
  const match = await bcrypt.compare(password, admin.password);
465
595
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
@@ -469,21 +599,21 @@ ${hasUser ? `import { User } from '../../model/User.js';
469
599
  return res.json({ message: 'Logged in as admin' });
470
600
  }
471
601
 
472
- const user = await User.findOne({ email });
602
+ const user = await ${db.findOne("User", `{ email }`)};
473
603
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
474
604
  const match = await bcrypt.compare(password, user.password);
475
605
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
476
606
  if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
477
607
  await issueToken(res, { id: user.id, role: user.role, email: user.email });
478
608
  await issueRefreshToken(res, User, user.id);
479
- res.json({ message: 'Logged in' });` : hasAdmin ? ` const admin = await Admin.findOne({ email });
609
+ res.json({ message: 'Logged in' });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
480
610
  if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
481
611
  const match = await bcrypt.compare(password, admin.password);
482
612
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
483
613
  if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
484
614
  await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
485
615
  await issueRefreshToken(res, Admin, admin.id);
486
- res.json({ message: 'Logged in as admin' });` : ` const user = await User.findOne({ email });
616
+ res.json({ message: 'Logged in as admin' });` : ` const user = await ${db.findOne("User", `{ email }`)};
487
617
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
488
618
  const match = await bcrypt.compare(password, user.password);
489
619
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
@@ -497,11 +627,11 @@ ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 *
497
627
 
498
628
  async function issueRefreshToken(
499
629
  res: Response,
500
- model: { update: (id: string, data: Record<string, unknown>) => Promise<unknown> },
630
+ model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
501
631
  id: string,
502
632
  ): Promise<void> {
503
633
  const refreshToken = crypto.randomBytes(40).toString('hex');
504
- await model.update(id, { refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) });
634
+ await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
505
635
  res.cookie('efc_refresh_token', refreshToken, {
506
636
  httpOnly: true,
507
637
  secure: process.env.NODE_ENV === 'production',
@@ -521,7 +651,7 @@ ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 *
521
651
 
522
652
  async function issueRefreshToken(res, model, id) {
523
653
  const refreshToken = crypto.randomBytes(40).toString('hex');
524
- await model.update(id, { refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) });
654
+ await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
525
655
  res.cookie('efc_refresh_token', refreshToken, {
526
656
  httpOnly: true,
527
657
  secure: process.env.NODE_ENV === 'production',
@@ -585,7 +715,7 @@ ${logoutMeta}export const POST = async (req, res) => {
585
715
  `;
586
716
  await fs.outputFile(path.join(dest, "src", "api", "auth", `login.${ext}`), loginContent);
587
717
  await fs.outputFile(path.join(dest, "src", "api", "auth", `logout.${ext}`), logoutContent);
588
- if (!opts.userPortal) return;
718
+ if (!hasUser) return;
589
719
  const registerMeta = opts.routeDocs ? ts ? `import type { RouteMeta } from 'express-file-cluster';
590
720
 
591
721
  export const meta: RouteMeta = {
@@ -607,9 +737,9 @@ export const meta: RouteMeta = {
607
737
  ` : "";
608
738
  const registerDbImports = `import bcrypt from 'bcrypt';
609
739
  import crypto from 'node:crypto';
610
- ${opts.mailer ? "import { enqueue } from 'express-file-cluster/tasks';\n" : ""}import { User } from '../../model/User.js';
740
+ ${sendsMail(opts) ? "import { enqueue } from 'express-file-cluster/tasks';\n" : ""}import { User } from '../../model/User.js';
611
741
  `;
612
- const sendVerifyEmail = opts.mailer ? `
742
+ const sendVerifyEmail = sendsMail(opts) ? `
613
743
  var appUrl = process.env.APP_URL || 'http://localhost:3000';
614
744
  await enqueue('SendEmail', {
615
745
  to: email,
@@ -625,11 +755,11 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
625
755
  if (!name || !email || !password) {
626
756
  return res.status(400).json({ error: 'name, email and password are required' });
627
757
  }
628
- const existing = await User.findOne({ email });
758
+ const existing = await ${db.findOne("User", `{ email }`)};
629
759
  if (existing) return res.status(409).json({ error: 'Email already in use' });
630
760
  const hashed = await bcrypt.hash(password, 10);
631
761
  const verifyToken = crypto.randomBytes(32).toString('hex');
632
- const user = await User.create({ name, email, password: hashed, verifyToken });
762
+ const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
633
763
  ${sendVerifyEmail}
634
764
  const { password: _, verifyToken: __, ...safe } = user;
635
765
  res.status(201).json({ message: 'Account created successfully', user: safe });
@@ -639,11 +769,11 @@ ${sendVerifyEmail}
639
769
  if (!name || !email || !password) {
640
770
  return res.status(400).json({ error: 'name, email and password are required' });
641
771
  }
642
- const existing = await User.findOne({ email });
772
+ const existing = await ${db.findOne("User", `{ email }`)};
643
773
  if (existing) return res.status(409).json({ error: 'Email already in use' });
644
774
  const hashed = await bcrypt.hash(password, 10);
645
775
  const verifyToken = crypto.randomBytes(32).toString('hex');
646
- const user = await User.create({ name, email, password: hashed, verifyToken });
776
+ const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
647
777
  ${sendVerifyEmail}
648
778
  const { password: _, verifyToken: __, ...safe } = user;
649
779
  res.status(201).json({ message: 'Account created successfully', user: safe });
@@ -685,6 +815,7 @@ ${meMeta}${meMiddlewares}export const GET = async (req, res) => {
685
815
  await fs.outputFile(path.join(dest, "src", "api", "auth", `me.${ext}`), meContent);
686
816
  }
687
817
  async function writeAdminRoutes(dest, opts) {
818
+ const db = orm(opts);
688
819
  const ext = opts.language === "typescript" ? "ts" : "js";
689
820
  const ts = opts.language === "typescript";
690
821
  const middlewares = opts.rbac ? `export const middlewares = [requireAuth('admin')];
@@ -718,7 +849,7 @@ export const meta: RouteMeta = {
718
849
  };
719
850
 
720
851
  ` : "";
721
- const dashboardHasUserModel = opts.adminFeatures.userManagement;
852
+ const dashboardHasUserModel = !!opts.registry || opts.adminFeatures.userManagement;
722
853
  const dashboardDbImport = dashboardHasUserModel ? `import { User } from '../../model/User.js';
723
854
  ` : "";
724
855
  const dashboardContent = dashboardHasUserModel ? ts ? `import { requireAuth } from 'express-file-cluster/auth';
@@ -726,9 +857,9 @@ import type { Request, Response } from 'express';
726
857
  ${dashboardDbImport}${dashboardMeta}${middlewares}
727
858
  export const GET = async (_req: Request, res: Response) => {
728
859
  ${roleGuard} const [totalUsers, activeUsers, verifiedUsers] = await Promise.all([
729
- User.count({}),
730
- User.count({ isActive: true }),
731
- User.count({ isVerified: true }),
860
+ ${db.count("User", `{}`)},
861
+ ${db.count("User", `{ isActive: true }`)},
862
+ ${db.count("User", `{ isVerified: true }`)},
732
863
  ]);
733
864
  res.json({ stats: { totalUsers, activeUsers, verifiedUsers } });
734
865
  };
@@ -736,9 +867,9 @@ ${roleGuard} const [totalUsers, activeUsers, verifiedUsers] = await Promise.all
736
867
  ${dashboardDbImport}${dashboardMeta}${middlewares}
737
868
  export const GET = async (_req, res) => {
738
869
  ${roleGuard} const [totalUsers, activeUsers, verifiedUsers] = await Promise.all([
739
- User.count({}),
740
- User.count({ isActive: true }),
741
- User.count({ isVerified: true }),
870
+ ${db.count("User", `{}`)},
871
+ ${db.count("User", `{ isActive: true }`)},
872
+ ${db.count("User", `{ isVerified: true }`)},
742
873
  ]);
743
874
  res.json({ stats: { totalUsers, activeUsers, verifiedUsers } });
744
875
  };
@@ -798,7 +929,7 @@ ${adminUsersDbImport}${usersListMeta}${middlewares}
798
929
  export const GET = async (req: Request, res: Response) => {
799
930
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
800
931
  const limit = Math.min(100, Number(req.query.limit) || 20);
801
- const [all, total] = await Promise.all([User.find({}), User.count({})]);
932
+ const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
802
933
  const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
803
934
  res.json({ users, total, page, limit });
804
935
  };
@@ -806,10 +937,10 @@ ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
806
937
  export const POST = async (req: Request, res: Response) => {
807
938
  ${roleGuard} const { name, email, password, role } = req.body;
808
939
  if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
809
- const existing = await User.findOne({ email });
940
+ const existing = await ${db.findOne("User", `{ email }`)};
810
941
  if (existing) return res.status(409).json({ error: 'Email already in use' });
811
942
  const hashed = await bcrypt.hash(password, 10);
812
- const user = await User.create({ name, email, password: hashed, role: role ?? 'user' });
943
+ const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
813
944
  const { password: _, ...safe } = user;
814
945
  res.status(201).json({ message: 'User created', user: safe });
815
946
  };
@@ -818,7 +949,7 @@ ${adminUsersDbImport}${usersListMeta}${middlewares}
818
949
  export const GET = async (req, res) => {
819
950
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
820
951
  const limit = Math.min(100, Number(req.query.limit) || 20);
821
- const [all, total] = await Promise.all([User.find({}), User.count({})]);
952
+ const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
822
953
  const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
823
954
  res.json({ users, total, page, limit });
824
955
  };
@@ -826,10 +957,10 @@ ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
826
957
  export const POST = async (req, res) => {
827
958
  ${roleGuard} const { name, email, password, role } = req.body;
828
959
  if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
829
- const existing = await User.findOne({ email });
960
+ const existing = await ${db.findOne("User", `{ email }`)};
830
961
  if (existing) return res.status(409).json({ error: 'Email already in use' });
831
962
  const hashed = await bcrypt.hash(password, 10);
832
- const user = await User.create({ name, email, password: hashed, role: role ?? 'user' });
963
+ const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
833
964
  const { password: _, ...safe } = user;
834
965
  res.status(201).json({ message: 'User created', user: safe });
835
966
  };
@@ -884,7 +1015,7 @@ import type { Request, Response } from 'express';
884
1015
  ${adminUserByIdDbImport}${userByIdMeta}${middlewares}
885
1016
  export const GET = async (req: Request, res: Response) => {
886
1017
  ${roleGuard} const { id } = req.params;
887
- const user = await User.findById(id);
1018
+ const user = await ${db.findById("User", `id`)};
888
1019
  if (!user) return res.status(404).json({ error: 'User not found' });
889
1020
  const { password: _, ...safe } = user;
890
1021
  res.json({ user: safe });
@@ -893,7 +1024,7 @@ ${roleGuard} const { id } = req.params;
893
1024
  export const PUT = async (req: Request, res: Response) => {
894
1025
  ${roleGuard} const { id } = req.params;
895
1026
  const { name, email, role, isActive } = req.body;
896
- const updated = await User.update(id, { name, email, role, isActive });
1027
+ const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
897
1028
  if (!updated) return res.status(404).json({ error: 'User not found' });
898
1029
  const { password: _, ...safe } = updated;
899
1030
  res.json({ message: 'User updated', user: safe });
@@ -901,16 +1032,16 @@ ${roleGuard} const { id } = req.params;
901
1032
 
902
1033
  export const DELETE = async (req: Request, res: Response) => {
903
1034
  ${roleGuard} const { id } = req.params;
904
- const user = await User.findById(id);
1035
+ const user = await ${db.findById("User", `id`)};
905
1036
  if (!user) return res.status(404).json({ error: 'User not found' });
906
- await User.delete(id);
1037
+ await ${db.delete("User", `id`)};
907
1038
  res.json({ message: \`User \${id} deleted\` });
908
1039
  };
909
1040
  ` : `import { requireAuth } from 'express-file-cluster/auth';
910
1041
  ${adminUserByIdDbImport}${userByIdMeta}${middlewares}
911
1042
  export const GET = async (req, res) => {
912
1043
  ${roleGuard} const { id } = req.params;
913
- const user = await User.findById(id);
1044
+ const user = await ${db.findById("User", `id`)};
914
1045
  if (!user) return res.status(404).json({ error: 'User not found' });
915
1046
  const { password: _, ...safe } = user;
916
1047
  res.json({ user: safe });
@@ -919,7 +1050,7 @@ ${roleGuard} const { id } = req.params;
919
1050
  export const PUT = async (req, res) => {
920
1051
  ${roleGuard} const { id } = req.params;
921
1052
  const { name, email, role, isActive } = req.body;
922
- const updated = await User.update(id, { name, email, role, isActive });
1053
+ const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
923
1054
  if (!updated) return res.status(404).json({ error: 'User not found' });
924
1055
  const { password: _, ...safe } = updated;
925
1056
  res.json({ message: 'User updated', user: safe });
@@ -927,9 +1058,9 @@ ${roleGuard} const { id } = req.params;
927
1058
 
928
1059
  export const DELETE = async (req, res) => {
929
1060
  ${roleGuard} const { id } = req.params;
930
- const user = await User.findById(id);
1061
+ const user = await ${db.findById("User", `id`)};
931
1062
  if (!user) return res.status(404).json({ error: 'User not found' });
932
- await User.delete(id);
1063
+ await ${db.delete("User", `id`)};
933
1064
  res.json({ message: \`User \${id} deleted\` });
934
1065
  };
935
1066
  `;
@@ -939,6 +1070,7 @@ ${roleGuard} const { id } = req.params;
939
1070
  );
940
1071
  }
941
1072
  async function writeUserRoutes(dest, opts) {
1073
+ const db = orm(opts);
942
1074
  const ext = opts.language === "typescript" ? "ts" : "js";
943
1075
  const ts = opts.language === "typescript";
944
1076
  const middlewares = opts.rbac ? `export const middlewares = [requireAuth('user', 'admin')];
@@ -978,7 +1110,7 @@ import type { Request, Response } from 'express';
978
1110
  ${profileDbImport}${profileMeta}${middlewares}
979
1111
  export const GET = async (req: Request, res: Response) => {
980
1112
  const { id } = (req as any).user;
981
- const user = await User.findById(id);
1113
+ const user = await ${db.findById("User", `id`)};
982
1114
  if (!user) return res.status(404).json({ error: 'User not found' });
983
1115
  const { password: _, ...safe } = user;
984
1116
  res.json({ user: safe });
@@ -987,7 +1119,7 @@ export const GET = async (req: Request, res: Response) => {
987
1119
  export const PUT = async (req: Request, res: Response) => {
988
1120
  const { id } = (req as any).user;
989
1121
  const { name, email } = req.body;
990
- const updated = await User.update(id, { name, email });
1122
+ const updated = await ${db.update("User", `id`, `{ name, email }`)};
991
1123
  if (!updated) return res.status(404).json({ error: 'User not found' });
992
1124
  const { password: _, ...safe } = updated;
993
1125
  res.json({ message: 'Profile updated', user: safe });
@@ -996,7 +1128,7 @@ export const PUT = async (req: Request, res: Response) => {
996
1128
  ${profileDbImport}${profileMeta}${middlewares}
997
1129
  export const GET = async (req, res) => {
998
1130
  const { id } = req.user;
999
- const user = await User.findById(id);
1131
+ const user = await ${db.findById("User", `id`)};
1000
1132
  if (!user) return res.status(404).json({ error: 'User not found' });
1001
1133
  const { password: _, ...safe } = user;
1002
1134
  res.json({ user: safe });
@@ -1005,7 +1137,7 @@ export const GET = async (req, res) => {
1005
1137
  export const PUT = async (req, res) => {
1006
1138
  const { id } = req.user;
1007
1139
  const { name, email } = req.body;
1008
- const updated = await User.update(id, { name, email });
1140
+ const updated = await ${db.update("User", `id`, `{ name, email }`)};
1009
1141
  if (!updated) return res.status(404).json({ error: 'User not found' });
1010
1142
  const { password: _, ...safe } = updated;
1011
1143
  res.json({ message: 'Profile updated', user: safe });
@@ -1045,10 +1177,10 @@ ${blocks}
1045
1177
  }
1046
1178
  async function writeSessionModel(dest, opts) {
1047
1179
  const ext = opts.language === "typescript" ? "ts" : "js";
1048
- const content = `import { defineModel } from 'express-file-cluster';
1180
+ const content = `${modelApi(opts).import}
1049
1181
 
1050
- export const Session = defineModel('Session', {
1051
- userId: { type: 'string', required: true },
1182
+ export const Session = ${modelApi(opts).name}('Session', {
1183
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1052
1184
  token: { type: 'string', required: true, unique: true },
1053
1185
  ip: { type: 'string', required: true },
1054
1186
  userAgent: { type: 'string', required: true },
@@ -1060,10 +1192,10 @@ export const Session = defineModel('Session', {
1060
1192
  }
1061
1193
  async function writeNotificationModel(dest, opts) {
1062
1194
  const ext = opts.language === "typescript" ? "ts" : "js";
1063
- const content = `import { defineModel } from 'express-file-cluster';
1195
+ const content = `${modelApi(opts).import}
1064
1196
 
1065
- export const Notification = defineModel('Notification', {
1066
- userId: { type: 'string', required: true },
1197
+ export const Notification = ${modelApi(opts).name}('Notification', {
1198
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1067
1199
  title: { type: 'string', required: true },
1068
1200
  message: { type: 'string', required: true },
1069
1201
  type: { type: 'string', required: true, default: 'info' },
@@ -1075,10 +1207,10 @@ export const Notification = defineModel('Notification', {
1075
1207
  }
1076
1208
  async function writeFileModel(dest, opts) {
1077
1209
  const ext = opts.language === "typescript" ? "ts" : "js";
1078
- const content = `import { defineModel } from 'express-file-cluster';
1210
+ const content = `${modelApi(opts).import}
1079
1211
 
1080
- export const File = defineModel('File', {
1081
- userId: { type: 'string', required: true },
1212
+ export const File = ${modelApi(opts).name}('File', {
1213
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1082
1214
  filename: { type: 'string', required: true },
1083
1215
  originalName: { type: 'string', required: true },
1084
1216
  mimeType: { type: 'string', required: true },
@@ -1091,25 +1223,25 @@ export const File = defineModel('File', {
1091
1223
  }
1092
1224
  async function writeSupportTicketModel(dest, opts) {
1093
1225
  const ext = opts.language === "typescript" ? "ts" : "js";
1094
- const content = `import { defineModel } from 'express-file-cluster';
1226
+ const content = `${modelApi(opts).import}
1095
1227
 
1096
- export const SupportTicket = defineModel('SupportTicket', {
1097
- userId: { type: 'string', required: true },
1228
+ export const SupportTicket = ${modelApi(opts).name}('SupportTicket', {
1229
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1098
1230
  subject: { type: 'string', required: true },
1099
1231
  message: { type: 'string', required: true },
1100
1232
  status: { type: 'string', required: true, default: 'open' },
1101
1233
  priority: { type: 'string', required: true, default: 'normal' },
1102
- assignedTo: { type: 'string' },
1234
+ ${refField(opts, { field: "assignedTo", target: "Admin", legacy: ` assignedTo: { type: 'string' },`, onDelete: "setNull" })}
1103
1235
  });
1104
1236
  `;
1105
1237
  await fs.outputFile(path.join(dest, "src", "model", `SupportTicket.${ext}`), content);
1106
1238
  }
1107
1239
  async function writeAuditLogModel(dest, opts) {
1108
1240
  const ext = opts.language === "typescript" ? "ts" : "js";
1109
- const content = `import { defineModel } from 'express-file-cluster';
1241
+ const content = `${modelApi(opts).import}
1110
1242
 
1111
- export const AuditLog = defineModel('AuditLog', {
1112
- adminId: { type: 'string', required: true },
1243
+ export const AuditLog = ${modelApi(opts).name}('AuditLog', {
1244
+ ${refField(opts, { field: "admin", target: "Admin", legacy: ` adminId: { type: 'string', required: true },`, onDelete: "setNull" })}
1113
1245
  action: { type: 'string', required: true },
1114
1246
  entity: { type: 'string', required: true },
1115
1247
  entityId: { type: 'string', required: true },
@@ -1121,41 +1253,41 @@ export const AuditLog = defineModel('AuditLog', {
1121
1253
  }
1122
1254
  async function writeSubscriptionModel(dest, opts) {
1123
1255
  const ext = opts.language === "typescript" ? "ts" : "js";
1124
- const content = `import { defineModel } from 'express-file-cluster';
1256
+ const content = `${modelApi(opts).import}
1125
1257
 
1126
- export const Subscription = defineModel('Subscription', {
1127
- userId: { type: 'string', required: true },
1128
- planId: { type: 'string', required: true },
1258
+ export const Subscription = ${modelApi(opts).name}('Subscription', {
1259
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1260
+ ${refField(opts, { field: "plan", target: "Plan", legacy: ` planId: { type: 'string', required: true },`, required: true, onDelete: "restrict" })}
1129
1261
  status: { type: 'string', required: true, default: 'active' },
1130
1262
  startDate: { type: 'date', required: true },
1131
1263
  endDate: { type: 'date', required: true },
1132
1264
  cancelledAt: { type: 'date' },
1133
- });
1265
+ }${relationsOption(opts, [{ name: "invoices", kind: "hasMany", target: "Invoice", foreignKey: "subscription" }])});
1134
1266
  `;
1135
1267
  await fs.outputFile(path.join(dest, "src", "model", `Subscription.${ext}`), content);
1136
1268
  }
1137
1269
  async function writePlanModel(dest, opts) {
1138
1270
  const ext = opts.language === "typescript" ? "ts" : "js";
1139
- const content = `import { defineModel } from 'express-file-cluster';
1271
+ const content = `${modelApi(opts).import}
1140
1272
 
1141
- export const Plan = defineModel('Plan', {
1273
+ export const Plan = ${modelApi(opts).name}('Plan', {
1142
1274
  name: { type: 'string', required: true },
1143
1275
  description: { type: 'string', required: true },
1144
1276
  price: { type: 'number', required: true },
1145
1277
  interval: { type: 'string', required: true, default: 'monthly' },
1146
1278
  features: { type: 'array', of: 'string', default: [] },
1147
1279
  isActive: { type: 'boolean', default: true },
1148
- });
1280
+ }${relationsOption(opts, [{ name: "subscriptions", kind: "hasMany", target: "Subscription", foreignKey: "plan" }])});
1149
1281
  `;
1150
1282
  await fs.outputFile(path.join(dest, "src", "model", `Plan.${ext}`), content);
1151
1283
  }
1152
1284
  async function writeInvoiceModel(dest, opts) {
1153
1285
  const ext = opts.language === "typescript" ? "ts" : "js";
1154
- const content = `import { defineModel } from 'express-file-cluster';
1286
+ const content = `${modelApi(opts).import}
1155
1287
 
1156
- export const Invoice = defineModel('Invoice', {
1157
- userId: { type: 'string', required: true },
1158
- subscriptionId: { type: 'string', required: true },
1288
+ export const Invoice = ${modelApi(opts).name}('Invoice', {
1289
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1290
+ ${refField(opts, { field: "subscription", target: "Subscription", legacy: ` subscriptionId: { type: 'string', required: true },`, onDelete: "setNull" })}
1159
1291
  amount: { type: 'number', required: true },
1160
1292
  currency: { type: 'string', required: true, default: 'USD' },
1161
1293
  status: { type: 'string', required: true, default: 'pending' },
@@ -1166,10 +1298,10 @@ export const Invoice = defineModel('Invoice', {
1166
1298
  }
1167
1299
  async function writeApiKeyModel(dest, opts) {
1168
1300
  const ext = opts.language === "typescript" ? "ts" : "js";
1169
- const content = `import { defineModel } from 'express-file-cluster';
1301
+ const content = `${modelApi(opts).import}
1170
1302
 
1171
- export const ApiKey = defineModel('ApiKey', {
1172
- userId: { type: 'string', required: true },
1303
+ export const ApiKey = ${modelApi(opts).name}('ApiKey', {
1304
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
1173
1305
  name: { type: 'string', required: true },
1174
1306
  key: { type: 'string', required: true, unique: true },
1175
1307
  lastUsed: { type: 'date' },
@@ -1181,9 +1313,9 @@ export const ApiKey = defineModel('ApiKey', {
1181
1313
  }
1182
1314
  async function writeRoleModel(dest, opts) {
1183
1315
  const ext = opts.language === "typescript" ? "ts" : "js";
1184
- const content = `import { defineModel } from 'express-file-cluster';
1316
+ const content = `${modelApi(opts).import}
1185
1317
 
1186
- export const Role = defineModel('Role', {
1318
+ export const Role = ${modelApi(opts).name}('Role', {
1187
1319
  name: { type: 'string', required: true, unique: true },
1188
1320
  description: { type: 'string', required: true },
1189
1321
  permissions: { type: 'array', of: 'string', default: [] },
@@ -1193,9 +1325,9 @@ export const Role = defineModel('Role', {
1193
1325
  }
1194
1326
  async function writeFAQModel(dest, opts) {
1195
1327
  const ext = opts.language === "typescript" ? "ts" : "js";
1196
- const content = `import { defineModel } from 'express-file-cluster';
1328
+ const content = `${modelApi(opts).import}
1197
1329
 
1198
- export const FAQ = defineModel('FAQ', {
1330
+ export const FAQ = ${modelApi(opts).name}('FAQ', {
1199
1331
  question: { type: 'string', required: true },
1200
1332
  answer: { type: 'string', required: true },
1201
1333
  category: { type: 'string', required: true, default: 'general' },
@@ -1207,13 +1339,13 @@ export const FAQ = defineModel('FAQ', {
1207
1339
  }
1208
1340
  async function writeBlogModel(dest, opts) {
1209
1341
  const ext = opts.language === "typescript" ? "ts" : "js";
1210
- const content = `import { defineModel } from 'express-file-cluster';
1342
+ const content = `${modelApi(opts).import}
1211
1343
 
1212
- export const Blog = defineModel('Blog', {
1344
+ export const Blog = ${modelApi(opts).name}('Blog', {
1213
1345
  title: { type: 'string', required: true },
1214
1346
  slug: { type: 'string', required: true, unique: true },
1215
1347
  content: { type: 'string', required: true },
1216
- authorId: { type: 'string', required: true },
1348
+ ${refField(opts, { field: "author", target: "Admin", legacy: ` authorId: { type: 'string', required: true },`, onDelete: "setNull" })}
1217
1349
  category: { type: 'string', required: true, default: 'general' },
1218
1350
  tags: { type: 'array', of: 'string', default: [] },
1219
1351
  status: { type: 'string', required: true, default: 'draft' },
@@ -1224,22 +1356,22 @@ export const Blog = defineModel('Blog', {
1224
1356
  }
1225
1357
  async function writeCategoryModel(dest, opts) {
1226
1358
  const ext = opts.language === "typescript" ? "ts" : "js";
1227
- const content = `import { defineModel } from 'express-file-cluster';
1359
+ const content = `${modelApi(opts).import}
1228
1360
 
1229
- export const Category = defineModel('Category', {
1361
+ export const Category = ${modelApi(opts).name}('Category', {
1230
1362
  name: { type: 'string', required: true },
1231
1363
  slug: { type: 'string', required: true, unique: true },
1232
1364
  description: { type: 'string' },
1233
- parentId: { type: 'string' },
1234
- });
1365
+ ${refField(opts, { field: "parent", target: "Category", legacy: ` parentId: { type: 'string' },`, onDelete: "setNull" })}
1366
+ }${relationsOption(opts, [{ name: "children", kind: "hasMany", target: "Category", foreignKey: "parent" }])});
1235
1367
  `;
1236
1368
  await fs.outputFile(path.join(dest, "src", "model", `Category.${ext}`), content);
1237
1369
  }
1238
1370
  async function writeCouponModel(dest, opts) {
1239
1371
  const ext = opts.language === "typescript" ? "ts" : "js";
1240
- const content = `import { defineModel } from 'express-file-cluster';
1372
+ const content = `${modelApi(opts).import}
1241
1373
 
1242
- export const Coupon = defineModel('Coupon', {
1374
+ export const Coupon = ${modelApi(opts).name}('Coupon', {
1243
1375
  code: { type: 'string', required: true, unique: true },
1244
1376
  type: { type: 'string', required: true, default: 'percent' },
1245
1377
  value: { type: 'number', required: true },
@@ -1252,6 +1384,7 @@ export const Coupon = defineModel('Coupon', {
1252
1384
  await fs.outputFile(path.join(dest, "src", "model", `Coupon.${ext}`), content);
1253
1385
  }
1254
1386
  async function writeAuthExtendedRoutes(dest, opts) {
1387
+ const db = orm(opts);
1255
1388
  const ext = opts.language === "typescript" ? "ts" : "js";
1256
1389
  const ts = opts.language === "typescript";
1257
1390
  const mwUser2 = opts.rbac ? `export const middlewares = [requireAuth('user', 'admin')];
@@ -1260,9 +1393,9 @@ async function writeAuthExtendedRoutes(dest, opts) {
1260
1393
  const mwUser3 = mwUser2;
1261
1394
  const RA = `import { requireAuth } from 'express-file-cluster/auth';
1262
1395
  `;
1263
- const hasAdmin = opts.adminPortal;
1396
+ const hasAdmin = principals(opts).admin;
1264
1397
  const uf = opts.userFeatures;
1265
- const sendResetEmail = opts.mailer ? ` var appUrl = process.env.APP_URL || 'http://localhost:3000';
1398
+ const sendResetEmail = sendsMail(opts) ? ` var appUrl = process.env.APP_URL || 'http://localhost:3000';
1266
1399
  await enqueue('SendEmail', {
1267
1400
  to: email,
1268
1401
  subject: 'Reset your password',
@@ -1270,7 +1403,7 @@ async function writeAuthExtendedRoutes(dest, opts) {
1270
1403
  });
1271
1404
  ` : ` // TODO: email this token to the user \u2014 enable the Mailer feature to auto-wire SendEmail
1272
1405
  `;
1273
- const sendVerifyEmailResend = opts.mailer ? ` var appUrl = process.env.APP_URL || 'http://localhost:3000';
1406
+ const sendVerifyEmailResend = sendsMail(opts) ? ` var appUrl = process.env.APP_URL || 'http://localhost:3000';
1274
1407
  await enqueue('SendEmail', {
1275
1408
  to: email,
1276
1409
  subject: 'Verify your email address',
@@ -1278,7 +1411,7 @@ async function writeAuthExtendedRoutes(dest, opts) {
1278
1411
  });
1279
1412
  ` : ` // TODO: email this token to the user \u2014 enable the Mailer feature to auto-wire SendEmail
1280
1413
  `;
1281
- const mailerTaskImport = opts.mailer ? `import { enqueue } from 'express-file-cluster/tasks';
1414
+ const mailerTaskImport = sendsMail(opts) ? `import { enqueue } from 'express-file-cluster/tasks';
1282
1415
  ` : "";
1283
1416
  if (uf.accountSecurity) {
1284
1417
  const refreshMeta = mkMeta(opts, {
@@ -1290,12 +1423,12 @@ async function writeAuthExtendedRoutes(dest, opts) {
1290
1423
  const refreshModelImports = `import { User } from '../../model/User.js';
1291
1424
  ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1292
1425
  ` : ""}`;
1293
- const refreshLookup = hasAdmin ? ` const user = await User.findOne({ refreshToken: token });
1294
- const admin = user ? null : await Admin.findOne({ refreshToken: token });
1295
- const account = user || admin;` : ` const user = await User.findOne({ refreshToken: token });
1426
+ const refreshLookup = hasAdmin ? ` const user = await ${db.findOne("User", `{ refreshToken: token }`)};
1427
+ const admin = user ? null : await ${db.findOne("Admin", `{ refreshToken: token }`)};
1428
+ const account = user || admin;` : ` const user = await ${db.findOne("User", `{ refreshToken: token }`)};
1296
1429
  const account = user;`;
1297
- const refreshPersist = hasAdmin ? ` if (user) await User.update(user.id, { refreshToken: newRefreshToken, refreshTokenExpiry });
1298
- else if (admin) await Admin.update(admin.id, { refreshToken: newRefreshToken, refreshTokenExpiry });` : ` await User.update(user.id, { refreshToken: newRefreshToken, refreshTokenExpiry });`;
1430
+ const refreshPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};
1431
+ else if (admin) await ${db.update("Admin", `admin.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};`;
1299
1432
  const refreshContent = ts ? `import { issueToken } from 'express-file-cluster/auth';
1300
1433
  import type { Request, Response } from 'express';
1301
1434
  import crypto from 'node:crypto';
@@ -1376,10 +1509,10 @@ ${veMeta}export const GET = async (req: Request, res: Response) => {
1376
1509
  const { token } = req.query;
1377
1510
  if (!token || typeof token !== 'string') return res.status(400).json({ error: 'token is required' });
1378
1511
 
1379
- const user = await User.findOne({ verifyToken: token });
1512
+ const user = await ${db.findOne("User", `{ verifyToken: token }`)};
1380
1513
  if (!user) return res.status(400).json({ error: 'Invalid or expired verification token' });
1381
1514
 
1382
- await User.update(user.id, { isVerified: true, verifyToken: '' });
1515
+ await ${db.update("User", `user.id`, `{ isVerified: true, verifyToken: '' }`)};
1383
1516
  res.json({ message: 'Email verified' });
1384
1517
  };
1385
1518
 
@@ -1387,10 +1520,10 @@ export const POST = async (req: Request, res: Response) => {
1387
1520
  const { email } = req.body;
1388
1521
  if (!email) return res.status(400).json({ error: 'email is required' });
1389
1522
 
1390
- const user = await User.findOne({ email });
1523
+ const user = await ${db.findOne("User", `{ email }`)};
1391
1524
  if (user && !user.isVerified) {
1392
1525
  const verifyToken = crypto.randomBytes(32).toString('hex');
1393
- await User.update(user.id, { verifyToken });
1526
+ await ${db.update("User", `user.id`, `{ verifyToken }`)};
1394
1527
  ${sendVerifyEmailResend} }
1395
1528
 
1396
1529
  res.json({ message: 'Verification email sent' });
@@ -1401,10 +1534,10 @@ ${veMeta}export const GET = async (req, res) => {
1401
1534
  const { token } = req.query;
1402
1535
  if (!token || typeof token !== 'string') return res.status(400).json({ error: 'token is required' });
1403
1536
 
1404
- const user = await User.findOne({ verifyToken: token });
1537
+ const user = await ${db.findOne("User", `{ verifyToken: token }`)};
1405
1538
  if (!user) return res.status(400).json({ error: 'Invalid or expired verification token' });
1406
1539
 
1407
- await User.update(user.id, { isVerified: true, verifyToken: '' });
1540
+ await ${db.update("User", `user.id`, `{ isVerified: true, verifyToken: '' }`)};
1408
1541
  res.json({ message: 'Email verified' });
1409
1542
  };
1410
1543
 
@@ -1412,10 +1545,10 @@ export const POST = async (req, res) => {
1412
1545
  const { email } = req.body;
1413
1546
  if (!email) return res.status(400).json({ error: 'email is required' });
1414
1547
 
1415
- const user = await User.findOne({ email });
1548
+ const user = await ${db.findOne("User", `{ email }`)};
1416
1549
  if (user && !user.isVerified) {
1417
1550
  const verifyToken = crypto.randomBytes(32).toString('hex');
1418
- await User.update(user.id, { verifyToken });
1551
+ await ${db.update("User", `user.id`, `{ verifyToken }`)};
1419
1552
  ${sendVerifyEmailResend} }
1420
1553
 
1421
1554
  res.json({ message: 'Verification email sent' });
@@ -1434,11 +1567,11 @@ ${sendVerifyEmailResend} }
1434
1567
  const fpModelImports = `import { User } from '../../model/User.js';
1435
1568
  ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1436
1569
  ` : ""}`;
1437
- const fpLookup = hasAdmin ? ` const user = await User.findOne({ email });
1438
- const admin = user ? null : await Admin.findOne({ email });` : ` const user = await User.findOne({ email });`;
1570
+ const fpLookup = hasAdmin ? ` const user = await ${db.findOne("User", `{ email }`)};
1571
+ const admin = user ? null : await ${db.findOne("Admin", `{ email }`)};` : ` const user = await ${db.findOne("User", `{ email }`)};`;
1439
1572
  const fpGuard = hasAdmin ? "user || admin" : "user";
1440
- const fpPersist = hasAdmin ? ` if (user) await User.update(user.id, { resetToken, resetTokenExpiry });
1441
- else if (admin) await Admin.update(admin.id, { resetToken, resetTokenExpiry });` : ` await User.update(user.id, { resetToken, resetTokenExpiry });`;
1573
+ const fpPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ resetToken, resetTokenExpiry }`)};
1574
+ else if (admin) await ${db.update("Admin", `admin.id`, `{ resetToken, resetTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ resetToken, resetTokenExpiry }`)};`;
1442
1575
  const fpContent = ts ? `import type { Request, Response } from 'express';
1443
1576
  import crypto from 'node:crypto';
1444
1577
  ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
@@ -1490,12 +1623,12 @@ ${sendResetEmail} }
1490
1623
  const rpModelImports = `import { User } from '../../model/User.js';
1491
1624
  ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1492
1625
  ` : ""}`;
1493
- const rpLookup = hasAdmin ? ` const user = await User.findOne({ resetToken: token });
1494
- const admin = user ? null : await Admin.findOne({ resetToken: token });
1495
- const account = user || admin;` : ` const user = await User.findOne({ resetToken: token });
1626
+ const rpLookup = hasAdmin ? ` const user = await ${db.findOne("User", `{ resetToken: token }`)};
1627
+ const admin = user ? null : await ${db.findOne("Admin", `{ resetToken: token }`)};
1628
+ const account = user || admin;` : ` const user = await ${db.findOne("User", `{ resetToken: token }`)};
1496
1629
  const account = user;`;
1497
- const rpPersist = hasAdmin ? ` if (user) await User.update(user.id, { password: hashed, resetToken: '' });
1498
- else if (admin) await Admin.update(admin.id, { password: hashed, resetToken: '' });` : ` await User.update(user.id, { password: hashed, resetToken: '' });`;
1630
+ const rpPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};
1631
+ else if (admin) await ${db.update("Admin", `admin.id`, `{ password: hashed, resetToken: '' }`)};` : ` await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};`;
1499
1632
  const rpContent = ts ? `import type { Request, Response } from 'express';
1500
1633
  import bcrypt from 'bcrypt';
1501
1634
  import { revokeAllSessions } from 'express-file-cluster/auth';
@@ -1716,7 +1849,7 @@ async function writeUserExtendedRoutes(dest, opts) {
1716
1849
  const mw3 = mw2;
1717
1850
  const RA = `import { requireAuth } from 'express-file-cluster/auth';
1718
1851
  `;
1719
- const user = ts ? `(req as any).user` : `req.user`;
1852
+ const user2 = ts ? `(req as any).user` : `req.user`;
1720
1853
  const uf = opts.userFeatures;
1721
1854
  if (uf.accountSettings) {
1722
1855
  const avatarMeta = mkMeta(opts, {
@@ -1803,7 +1936,7 @@ export const PUT = async (req, res) => {
1803
1936
  ${accountMeta}${mw2}
1804
1937
  export const GET = async (req: Request, res: Response) => {
1805
1938
  // TODO: compile and return personal data export
1806
- res.json({ data: { user: ${user}, exportedAt: new Date().toISOString() } });
1939
+ res.json({ data: { user: ${user2}, exportedAt: new Date().toISOString() } });
1807
1940
  };
1808
1941
 
1809
1942
  export const DELETE = async (req: Request, res: Response) => {
@@ -1815,7 +1948,7 @@ export const DELETE = async (req: Request, res: Response) => {
1815
1948
  ` : `${RA}${accountMeta}${mw2}
1816
1949
  export const GET = async (req, res) => {
1817
1950
  // TODO: compile and return personal data export
1818
- res.json({ data: { user: ${user}, exportedAt: new Date().toISOString() } });
1951
+ res.json({ data: { user: ${user2}, exportedAt: new Date().toISOString() } });
1819
1952
  };
1820
1953
 
1821
1954
  export const DELETE = async (req, res) => {
@@ -2204,7 +2337,10 @@ export const GET = async (req, res) => {
2204
2337
  }
2205
2338
  if (uf.apiAndBilling) {
2206
2339
  const akListMeta = mkMeta(opts, {
2207
- GET: { description: "List API keys for the authenticated user.", response: `{ apiKeys: [] }` },
2340
+ GET: {
2341
+ description: "List API keys for the authenticated user.",
2342
+ response: `{ apiKeys: [] }`
2343
+ },
2208
2344
  POST: {
2209
2345
  description: "Generate a new API key for the authenticated user.",
2210
2346
  request: `{ name: 'CI key' }`,
@@ -3447,12 +3583,54 @@ ${roleGuard} const { status, page = 1, limit = 20 } = req.query;
3447
3583
  );
3448
3584
  }
3449
3585
  }
3586
+ async function writeModelIndex(dest, opts) {
3587
+ const ts = opts.language === "typescript";
3588
+ const ext = ts ? "ts" : "js";
3589
+ const names = [...generatedModels(opts)].sort();
3590
+ if (names.length === 0) return;
3591
+ const exports = names.map((n) => `export { ${n} } from './${n}.js';`).join("\n");
3592
+ const tables = ts ? `
3593
+ ${names.map((n) => `import type { ${n} } from './${n}.js';`).join("\n")}
3594
+
3595
+ // Types \`include\` results: relations name models by string, this maps the names to models.
3596
+ declare module 'express-file-cluster' {
3597
+ interface Tables {
3598
+ ${names.map((n) => ` ${n}: typeof ${n};`).join("\n")}
3599
+ }
3600
+ }
3601
+ ` : "";
3602
+ const content = `// Every model, in one import: \`import { User } from '../model/index.js'\` also registers the
3603
+ // models its relations reach, which \`include\` needs.
3604
+ ${exports}
3605
+ ${tables}`;
3606
+ await fs.outputFile(path.join(dest, "src", "model", `index.${ext}`), content);
3607
+ }
3608
+ var MODEL_ORDER = [
3609
+ "User",
3610
+ "Admin",
3611
+ "Plan",
3612
+ "Category",
3613
+ "Role",
3614
+ "FAQ",
3615
+ "Coupon",
3616
+ "OAuthAccount",
3617
+ "Session",
3618
+ "Notification",
3619
+ "File",
3620
+ "SupportTicket",
3621
+ "AuditLog",
3622
+ "Subscription",
3623
+ "Invoice",
3624
+ "ApiKey",
3625
+ "Blog"
3626
+ ];
3450
3627
  async function writeInitMigration(dest, opts) {
3451
3628
  const modelDir = path.join(dest, "src", "model");
3452
3629
  if (!await fs.pathExists(modelDir)) return;
3453
3630
  const ts = opts.language === "typescript";
3454
3631
  const ext = ts ? "ts" : "js";
3455
- const names = (await fs.readdir(modelDir)).filter((f) => f.endsWith(`.${ext}`)).map((f) => f.slice(0, -(ext.length + 1))).sort();
3632
+ const rank = (n) => MODEL_ORDER.includes(n) ? MODEL_ORDER.indexOf(n) : MODEL_ORDER.length;
3633
+ const names = (await fs.readdir(modelDir)).filter((f) => f.endsWith(`.${ext}`) && f !== `index.${ext}`).map((f) => f.slice(0, -(ext.length + 1))).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
3456
3634
  if (names.length === 0) return;
3457
3635
  const imports = names.map((n) => `import { ${n} } from '../model/${n}.js';`).join("\n");
3458
3636
  const typeImport = ts ? "import type { MigrationContext } from 'express-file-cluster';\n" : "";
@@ -3471,6 +3649,845 @@ export async function down${sig} {
3471
3649
  `;
3472
3650
  await fs.outputFile(path.join(dest, "src", "migrations", `00000000000000_init.${ext}`), content);
3473
3651
  }
3652
+ async function writeOAuthAccountModel(dest, opts) {
3653
+ const ext = opts.language === "typescript" ? "ts" : "js";
3654
+ const content = `${modelApi(opts).import}
3655
+
3656
+ // One row per provider identity ("google:<sub>"), linked to the User it signs in as.
3657
+ export const OAuthAccount = ${modelApi(opts).name}('OAuthAccount', {
3658
+ key: { type: 'string', required: true, unique: true },
3659
+ provider: { type: 'string', required: true, enum: ['google', 'apple'] },
3660
+ providerId: { type: 'string', required: true },
3661
+ email: { type: 'string' },
3662
+ ${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
3663
+ });
3664
+ `;
3665
+ await fs.outputFile(path.join(dest, "src", "model", `OAuthAccount.${ext}`), content);
3666
+ }
3667
+ async function writeOAuth(dest, opts) {
3668
+ const ts = opts.language === "typescript";
3669
+ const ext = ts ? "ts" : "js";
3670
+ const db = orm(opts);
3671
+ const T = (s) => ts ? s : "";
3672
+ const userField = usesTables(opts) && generatedModels(opts).has("User") ? "user" : "userId";
3673
+ const lib = `import crypto from 'node:crypto';
3674
+ import bcrypt from 'bcrypt';
3675
+ import { HttpError } from 'express-file-cluster';
3676
+ import { issueToken } from 'express-file-cluster/auth';
3677
+ ${T("import type { Request, Response } from 'express';\n")}import { User } from '../model/User.js';
3678
+ import { OAuthAccount } from '../model/OAuthAccount.js';
3679
+
3680
+ // Shared by the OAuth sign-in routes (src/api/auth/<provider>/).
3681
+
3682
+ const STATE_TTL_MS = 10 * 60 * 1000; // a sign-in attempt has 10 minutes to come back
3683
+ ${T(`
3684
+ export interface OAuthState {
3685
+ state: string;
3686
+ nonce: string;
3687
+ verifier?: string;
3688
+ }
3689
+
3690
+ export interface OAuthProfile {
3691
+ provider: 'google' | 'apple';
3692
+ providerId: string;
3693
+ email?: string;
3694
+ emailVerified: boolean;
3695
+ name?: string;
3696
+ }
3697
+ `)}
3698
+ /** A random URL-safe string (state, nonce, PKCE verifier). */
3699
+ export function randomToken()${T(": string")} {
3700
+ return crypto.randomBytes(32).toString('base64url');
3701
+ }
3702
+
3703
+ /** The PKCE S256 challenge for a verifier. */
3704
+ export function pkceChallenge(verifier${T(": string")})${T(": string")} {
3705
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
3706
+ }
3707
+
3708
+ /**
3709
+ * Remembers a sign-in attempt in a short-lived cookie until the provider redirects back.
3710
+ * \`crossSite\`: the provider POSTs the callback from its own site (Apple), so the cookie must
3711
+ * be SameSite=None \u2014 which browsers only accept with Secure (https, or localhost).
3712
+ */
3713
+ export function rememberState(
3714
+ res${T(": Response")},
3715
+ provider${T(": string")},
3716
+ data${T(": OAuthState")},
3717
+ crossSite = false,
3718
+ )${T(": void")} {
3719
+ res.cookie(\`efc_oauth_\${provider}\`, JSON.stringify(data), {
3720
+ httpOnly: true,
3721
+ secure: crossSite || process.env.NODE_ENV === 'production',
3722
+ sameSite: crossSite ? 'none' : 'lax',
3723
+ maxAge: STATE_TTL_MS,
3724
+ path: '/',
3725
+ });
3726
+ }
3727
+
3728
+ /** The remembered attempt (cleared) \u2014 or undefined if \`state\` doesn't match it. */
3729
+ export function takeState(
3730
+ req${T(": Request")},
3731
+ res${T(": Response")},
3732
+ provider${T(": string")},
3733
+ state${T(": unknown")},
3734
+ )${T(": OAuthState | undefined")} {
3735
+ const name = \`efc_oauth_\${provider}\`;
3736
+ const raw = ${ts ? "(req as Request & { cookies?: Record<string, string> }).cookies?.[name]" : "req.cookies?.[name]"};
3737
+ res.clearCookie(name, { path: '/' });
3738
+ if (!raw || typeof state !== 'string') return undefined;
3739
+ try {
3740
+ const saved = JSON.parse(raw)${T(" as OAuthState")};
3741
+ const a = Buffer.from(String(saved.state));
3742
+ const b = Buffer.from(state);
3743
+ return a.length === b.length && crypto.timingSafeEqual(a, b) ? saved : undefined;
3744
+ } catch {
3745
+ return undefined;
3746
+ }
3747
+ }
3748
+
3749
+ /**
3750
+ * Signs in the user behind a provider identity: the account already linked to it, else the
3751
+ * user with the same (provider-verified) email, else a new user. New users get a random
3752
+ * password \u2014 they can set a real one with the forgot-password flow.
3753
+ */
3754
+ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<void>")} {
3755
+ const key = \`\${profile.provider}:\${profile.providerId}\`;
3756
+ const account = await ${db.findOne("OAuthAccount", "{ key }")};
3757
+ let user = account ? await ${db.findById("User", `account.${userField}`)} : null;
3758
+ if (!user) {
3759
+ if (!profile.email || !profile.emailVerified) {
3760
+ throw new HttpError(400, \`\${profile.provider} did not return a verified email address\`);
3761
+ }
3762
+ const email = profile.email;
3763
+ user =
3764
+ (await ${db.findOne("User", "{ email }")}) ??
3765
+ (await ${db.create(
3766
+ "User",
3767
+ `{
3768
+ name: profile.name || email.split('@')[0],
3769
+ email,
3770
+ password: await bcrypt.hash(randomToken(), 10),
3771
+ isVerified: true,
3772
+ }`
3773
+ )});
3774
+ if (account) await ${db.update("OAuthAccount", "account.id", `{ ${userField}: user.id }`)};
3775
+ else {
3776
+ await ${db.create(
3777
+ "OAuthAccount",
3778
+ `{
3779
+ key,
3780
+ provider: profile.provider,
3781
+ providerId: profile.providerId,
3782
+ email,
3783
+ ${userField}: user.id,
3784
+ }`
3785
+ )};
3786
+ }
3787
+ }
3788
+ if (!user.isActive) throw new HttpError(403, 'Account suspended');
3789
+ await issueToken(res, { id: user.id, role: user.role, email: user.email });
3790
+ }
3791
+
3792
+ /** Where the browser goes once signed in. */
3793
+ export function successRedirect()${T(": string")} {
3794
+ return process.env.OAUTH_SUCCESS_REDIRECT || '/';
3795
+ }
3796
+ `;
3797
+ const reqRes = (r) => ts ? `(${r}: Request, res: Response)` : `(${r}, res)`;
3798
+ const typeImport = T("import type { Request, Response } from 'express';\n");
3799
+ const meta = (method, description) => `${ts ? "import type { RouteMeta } from 'express-file-cluster';\n\n" : ""}export const meta${T(": RouteMeta")} = {
3800
+ ${method}: { description: '${description}' },
3801
+ };
3802
+
3803
+ `;
3804
+ const googleStart = `${typeImport}import { pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
3805
+ ${meta("GET", 'Start "Sign in with Google": redirects to Google.')}// Redirects to Google's consent screen; Google sends the browser back to the callback route
3806
+ // (GOOGLE_REDIRECT_URI \u2014 register that exact URL in the Google Cloud console).
3807
+ export const GET = async ${reqRes("_req")} => {
3808
+ const state = randomToken();
3809
+ const nonce = randomToken();
3810
+ const verifier = randomToken();
3811
+ rememberState(res, 'google', { state, nonce, verifier });
3812
+ const params = new URLSearchParams({
3813
+ client_id: process.env.GOOGLE_CLIENT_ID ?? '',
3814
+ redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
3815
+ response_type: 'code',
3816
+ scope: 'openid email profile',
3817
+ state,
3818
+ nonce,
3819
+ code_challenge: pkceChallenge(verifier),
3820
+ code_challenge_method: 'S256',
3821
+ prompt: 'select_account',
3822
+ });
3823
+ res.redirect(\`https://accounts.google.com/o/oauth2/v2/auth?\${params}\`);
3824
+ };
3825
+ `;
3826
+ const googleCallback = `import { HttpError } from 'express-file-cluster';
3827
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
3828
+ ${typeImport}import { signInWithOAuth, successRedirect, takeState } from '../../../lib/oauth.js';
3829
+ ${meta("GET", "Google redirects here after sign-in; signs the user in and redirects to OAUTH_SUCCESS_REDIRECT.")}const GOOGLE_KEYS = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));
3830
+
3831
+ export const GET = async ${reqRes("req")} => {
3832
+ const { code, state, error } = req.query;
3833
+ if (error) throw new HttpError(400, \`Google sign-in failed: \${String(error)}\`);
3834
+ const saved = takeState(req, res, 'google', state);
3835
+ if (!saved || typeof code !== 'string') {
3836
+ throw new HttpError(400, 'Sign-in attempt expired or invalid \u2014 start again');
3837
+ }
3838
+
3839
+ const tokens = await fetch('https://oauth2.googleapis.com/token', {
3840
+ method: 'POST',
3841
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
3842
+ body: new URLSearchParams({
3843
+ code,
3844
+ client_id: process.env.GOOGLE_CLIENT_ID ?? '',
3845
+ client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
3846
+ redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
3847
+ grant_type: 'authorization_code',
3848
+ code_verifier: saved.verifier ?? '',
3849
+ }),
3850
+ });
3851
+ if (!tokens.ok) throw new HttpError(502, \`Google rejected the sign-in (\${tokens.status})\`);
3852
+ const { id_token } = (await tokens.json())${T(" as { id_token: string }")};
3853
+
3854
+ const { payload } = await jwtVerify(id_token, GOOGLE_KEYS, {
3855
+ issuer: ['https://accounts.google.com', 'accounts.google.com'],
3856
+ audience: process.env.GOOGLE_CLIENT_ID ?? '',
3857
+ });
3858
+ if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
3859
+
3860
+ await signInWithOAuth(res, {
3861
+ provider: 'google',
3862
+ providerId: String(payload.sub),
3863
+ email: ${ts ? "payload.email as string | undefined" : "payload.email"},
3864
+ emailVerified: payload.email_verified === true,
3865
+ name: ${ts ? "payload.name as string | undefined" : "payload.name"},
3866
+ });
3867
+ res.redirect(successRedirect());
3868
+ };
3869
+ `;
3870
+ const appleStart = `${typeImport}import { randomToken, rememberState } from '../../../lib/oauth.js';
3871
+ ${meta("GET", 'Start "Sign in with Apple": redirects to Apple.')}// Redirects to Apple; Apple POSTs the result to the callback route (APPLE_REDIRECT_URI \u2014
3872
+ // register it on the Services ID; Apple only accepts https URLs on a verified domain).
3873
+ export const GET = async ${reqRes("_req")} => {
3874
+ const state = randomToken();
3875
+ const nonce = randomToken();
3876
+ rememberState(res, 'apple', { state, nonce }, true);
3877
+ const params = new URLSearchParams({
3878
+ client_id: process.env.APPLE_CLIENT_ID ?? '',
3879
+ redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
3880
+ response_type: 'code',
3881
+ response_mode: 'form_post',
3882
+ scope: 'name email',
3883
+ state,
3884
+ nonce,
3885
+ });
3886
+ res.redirect(\`https://appleid.apple.com/auth/authorize?\${params}\`);
3887
+ };
3888
+ `;
3889
+ const appleCallback = `import { HttpError } from 'express-file-cluster';
3890
+ import { SignJWT, createRemoteJWKSet, importPKCS8, jwtVerify } from 'jose';
3891
+ ${typeImport}import { signInWithOAuth, successRedirect, takeState } from '../../../lib/oauth.js';
3892
+ ${meta("POST", "Apple posts here after sign-in; signs the user in and redirects to OAUTH_SUCCESS_REDIRECT.")}const APPLE_KEYS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
3893
+
3894
+ // Apple's client secret is a short-lived ES256 JWT signed with your Sign in with Apple key
3895
+ // (APPLE_PRIVATE_KEY: the .p8 file's contents, newlines written as \\n).
3896
+ async function clientSecret()${T(": Promise<string>")} {
3897
+ const key = await importPKCS8((process.env.APPLE_PRIVATE_KEY ?? '').replace(/\\\\n/g, '\\n'), 'ES256');
3898
+ return new SignJWT({})
3899
+ .setProtectedHeader({ alg: 'ES256', kid: process.env.APPLE_KEY_ID ?? '' })
3900
+ .setIssuer(process.env.APPLE_TEAM_ID ?? '')
3901
+ .setSubject(process.env.APPLE_CLIENT_ID ?? '')
3902
+ .setAudience('https://appleid.apple.com')
3903
+ .setIssuedAt()
3904
+ .setExpirationTime('5m')
3905
+ .sign(key);
3906
+ }
3907
+
3908
+ export const POST = async ${reqRes("req")} => {
3909
+ const { code, state, user, error } = req.body ?? {};
3910
+ if (error) throw new HttpError(400, \`Apple sign-in failed: \${String(error)}\`);
3911
+ const saved = takeState(req, res, 'apple', state);
3912
+ if (!saved || typeof code !== 'string') {
3913
+ throw new HttpError(400, 'Sign-in attempt expired or invalid \u2014 start again');
3914
+ }
3915
+
3916
+ const tokens = await fetch('https://appleid.apple.com/auth/token', {
3917
+ method: 'POST',
3918
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
3919
+ body: new URLSearchParams({
3920
+ code,
3921
+ client_id: process.env.APPLE_CLIENT_ID ?? '',
3922
+ client_secret: await clientSecret(),
3923
+ redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
3924
+ grant_type: 'authorization_code',
3925
+ }),
3926
+ });
3927
+ if (!tokens.ok) throw new HttpError(502, \`Apple rejected the sign-in (\${tokens.status})\`);
3928
+ const { id_token } = (await tokens.json())${T(" as { id_token: string }")};
3929
+
3930
+ const { payload } = await jwtVerify(id_token, APPLE_KEYS, {
3931
+ issuer: 'https://appleid.apple.com',
3932
+ audience: process.env.APPLE_CLIENT_ID ?? '',
3933
+ });
3934
+ if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
3935
+
3936
+ // Apple sends the user's name once, on their first sign-in, as a JSON form field.
3937
+ let name${T(": string | undefined")};
3938
+ try {
3939
+ const n = typeof user === 'string' ? JSON.parse(user).name : undefined;
3940
+ name = [n?.firstName, n?.lastName].filter(Boolean).join(' ') || undefined;
3941
+ } catch {
3942
+ name = undefined;
3943
+ }
3944
+
3945
+ await signInWithOAuth(res, {
3946
+ provider: 'apple',
3947
+ providerId: String(payload.sub),
3948
+ email: ${ts ? "payload.email as string | undefined" : "payload.email"},
3949
+ emailVerified: payload.email_verified === true || payload.email_verified === 'true',
3950
+ name,
3951
+ });
3952
+ res.redirect(303, successRedirect());
3953
+ };
3954
+ `;
3955
+ const oauth = opts.oauth ?? {};
3956
+ await fs.outputFile(path.join(dest, "src", "lib", `oauth.${ext}`), lib);
3957
+ if (oauth.google) {
3958
+ await fs.outputFile(
3959
+ path.join(dest, "src", "api", "auth", "google", `index.${ext}`),
3960
+ googleStart
3961
+ );
3962
+ await fs.outputFile(
3963
+ path.join(dest, "src", "api", "auth", "google", `callback.${ext}`),
3964
+ googleCallback
3965
+ );
3966
+ }
3967
+ if (oauth.apple) {
3968
+ await fs.outputFile(path.join(dest, "src", "api", "auth", "apple", `index.${ext}`), appleStart);
3969
+ await fs.outputFile(
3970
+ path.join(dest, "src", "api", "auth", "apple", `callback.${ext}`),
3971
+ appleCallback
3972
+ );
3973
+ }
3974
+ }
3975
+
3976
+ // src/registry.ts
3977
+ import crypto2 from "crypto";
3978
+ import os from "os";
3979
+ import path2 from "path";
3980
+ import fs2 from "fs-extra";
3981
+ var VARIANTS = ["typescript", "javascript"].flatMap((language) => {
3982
+ const lang = language === "typescript" ? "ts" : "js";
3983
+ return [
3984
+ {
3985
+ key: `${lang}-mongo`,
3986
+ language,
3987
+ database: "mongodb",
3988
+ modelApi: "defineModel"
3989
+ },
3990
+ {
3991
+ key: `${lang}-pg-model`,
3992
+ language,
3993
+ database: "postgresql",
3994
+ modelApi: "defineModel"
3995
+ },
3996
+ {
3997
+ key: `${lang}-pg-table`,
3998
+ language,
3999
+ database: "postgresql",
4000
+ modelApi: "defineTable"
4001
+ }
4002
+ ];
4003
+ });
4004
+ var user = (flag) => (o) => {
4005
+ o.userPortal = true;
4006
+ o.userFeatures = { ...o.userFeatures, [flag]: true };
4007
+ };
4008
+ var admin = (flag) => (o) => {
4009
+ o.adminPortal = true;
4010
+ o.adminFeatures = { ...o.adminFeatures, [flag]: true };
4011
+ };
4012
+ var ITEMS = [
4013
+ {
4014
+ name: "auth",
4015
+ title: "Authentication",
4016
+ description: "User & Admin models; register, login, logout and me routes",
4017
+ group: "auth",
4018
+ registryDependencies: [],
4019
+ docs: "Tokens are issued per `authStrategy` in efc.config \u2014 'http-only' (cookie) or 'localStorage' (Bearer). Verification emails go through the SendEmail task; `efc add email-client` makes it send real mail.",
4020
+ enable: (o) => {
4021
+ o.userPortal = true;
4022
+ }
4023
+ },
4024
+ {
4025
+ name: "user-profile",
4026
+ title: "Profile",
4027
+ description: "GET/PUT /user/profile",
4028
+ group: "user",
4029
+ registryDependencies: ["auth"],
4030
+ enable: user("profileViewing")
4031
+ },
4032
+ {
4033
+ name: "forgot-password",
4034
+ title: "Forgot / reset password",
4035
+ description: "forgot-password and reset-password routes",
4036
+ group: "user",
4037
+ registryDependencies: ["auth"],
4038
+ enable: user("forgotPassword")
4039
+ },
4040
+ {
4041
+ name: "account-security",
4042
+ title: "Account security",
4043
+ description: "change password, 2FA, sessions, token refresh",
4044
+ group: "user",
4045
+ registryDependencies: ["auth"],
4046
+ enable: user("accountSecurity")
4047
+ },
4048
+ {
4049
+ name: "email-verification",
4050
+ title: "Email verification",
4051
+ description: "verify-email and resend-verification routes",
4052
+ group: "user",
4053
+ registryDependencies: ["auth"],
4054
+ enable: user("emailVerification")
4055
+ },
4056
+ {
4057
+ name: "account-settings",
4058
+ title: "Account settings",
4059
+ description: "avatar, settings, account, dashboard, activity",
4060
+ group: "user",
4061
+ registryDependencies: ["auth"],
4062
+ enable: user("accountSettings")
4063
+ },
4064
+ {
4065
+ name: "notifications",
4066
+ title: "Notifications",
4067
+ description: "Notification model + /user/notifications",
4068
+ group: "user",
4069
+ registryDependencies: ["auth"],
4070
+ enable: user("notifications")
4071
+ },
4072
+ {
4073
+ name: "files",
4074
+ title: "Files, favorites & bookmarks",
4075
+ description: "File model + files, favorites, bookmarks, search",
4076
+ group: "user",
4077
+ registryDependencies: ["auth"],
4078
+ enable: user("filesAndMedia")
4079
+ },
4080
+ {
4081
+ name: "billing",
4082
+ title: "API keys & billing",
4083
+ description: "Plan, Subscription, Invoice, ApiKey models + user billing routes",
4084
+ group: "user",
4085
+ registryDependencies: ["auth"],
4086
+ enable: user("apiAndBilling")
4087
+ },
4088
+ {
4089
+ name: "support",
4090
+ title: "Support tickets",
4091
+ description: "SupportTicket model + create & view own tickets",
4092
+ group: "user",
4093
+ registryDependencies: ["auth"],
4094
+ enable: user("support")
4095
+ },
4096
+ {
4097
+ name: "admin",
4098
+ title: "Admin portal",
4099
+ description: "admin dashboard stats, behind requireAuth('admin')",
4100
+ group: "admin",
4101
+ registryDependencies: ["auth"],
4102
+ enable: (o) => {
4103
+ o.adminPortal = true;
4104
+ }
4105
+ },
4106
+ {
4107
+ name: "admin-users",
4108
+ title: "User management",
4109
+ description: "list/create/suspend/verify/export users + stats",
4110
+ group: "admin",
4111
+ registryDependencies: ["admin"],
4112
+ enable: admin("userManagement")
4113
+ },
4114
+ {
4115
+ name: "admin-admins",
4116
+ title: "Admin & role management",
4117
+ description: "manage other admins + Role model",
4118
+ group: "admin",
4119
+ registryDependencies: ["admin"],
4120
+ enable: admin("adminManagement")
4121
+ },
4122
+ {
4123
+ name: "admin-analytics",
4124
+ title: "Analytics",
4125
+ description: "users, revenue, traffic dashboards",
4126
+ group: "admin",
4127
+ registryDependencies: ["admin"],
4128
+ enable: admin("analytics")
4129
+ },
4130
+ {
4131
+ name: "admin-content",
4132
+ title: "Content management",
4133
+ description: "FAQ, Blog, Category models + admin routes",
4134
+ group: "admin",
4135
+ registryDependencies: ["admin"],
4136
+ enable: admin("contentManagement")
4137
+ },
4138
+ {
4139
+ name: "admin-billing",
4140
+ title: "Billing management",
4141
+ description: "Plan, Coupon models + plans, coupons, subscriptions",
4142
+ group: "admin",
4143
+ registryDependencies: ["admin"],
4144
+ enable: admin("billingManagement")
4145
+ },
4146
+ {
4147
+ name: "admin-support",
4148
+ title: "Support desk",
4149
+ description: "view & respond to support tickets",
4150
+ group: "admin",
4151
+ registryDependencies: ["admin"],
4152
+ enable: admin("supportManagement")
4153
+ },
4154
+ {
4155
+ name: "admin-logs",
4156
+ title: "Notifications & audit logs",
4157
+ description: "broadcast + audit/activity/security logs",
4158
+ group: "admin",
4159
+ registryDependencies: ["admin"],
4160
+ enable: admin("notificationsAndLogs")
4161
+ },
4162
+ {
4163
+ name: "admin-system",
4164
+ title: "System settings & health",
4165
+ description: "app settings, health check, cache",
4166
+ group: "admin",
4167
+ registryDependencies: ["admin"],
4168
+ enable: admin("systemSettings")
4169
+ },
4170
+ {
4171
+ name: "auth@google",
4172
+ title: "Sign in with Google",
4173
+ description: "GET /auth/google \u2192 Google \u2192 /auth/google/callback (OpenID Connect + PKCE)",
4174
+ group: "auth",
4175
+ registryDependencies: ["auth"],
4176
+ docs: 'Create an OAuth client (type "Web application") at console.cloud.google.com \u2192 APIs & Services \u2192 Credentials, and add GOOGLE_REDIRECT_URI as an authorised redirect URI. Send users to GET /v1/api/auth/google to sign in.',
4177
+ prompts: [
4178
+ {
4179
+ name: "GOOGLE_CLIENT_ID",
4180
+ type: "text",
4181
+ message: "Google OAuth client ID:",
4182
+ placeholder: "1234-abc.apps.googleusercontent.com",
4183
+ required: true,
4184
+ pattern: "\\.apps\\.googleusercontent\\.com$",
4185
+ patternMessage: "Google client IDs end in .apps.googleusercontent.com",
4186
+ note: "console.cloud.google.com \u2192 APIs & Services \u2192 Credentials \u2192 Create credentials \u2192 OAuth client ID (Web application)."
4187
+ },
4188
+ {
4189
+ name: "GOOGLE_CLIENT_SECRET",
4190
+ type: "password",
4191
+ message: "Google OAuth client secret:",
4192
+ required: true
4193
+ },
4194
+ {
4195
+ name: "GOOGLE_REDIRECT_URI",
4196
+ type: "text",
4197
+ message: "Redirect URI (register it on the client):",
4198
+ default: "http://localhost:3000/v1/api/auth/google/callback"
4199
+ },
4200
+ {
4201
+ name: "OAUTH_SUCCESS_REDIRECT",
4202
+ type: "text",
4203
+ message: "Where to send the browser after sign-in:",
4204
+ default: "http://localhost:3000/"
4205
+ }
4206
+ ],
4207
+ enable: (o) => {
4208
+ o.userPortal = true;
4209
+ o.oauth = { ...o.oauth, google: true };
4210
+ }
4211
+ },
4212
+ {
4213
+ name: "auth@apple",
4214
+ title: "Sign in with Apple",
4215
+ description: "GET /auth/apple \u2192 Apple \u2192 POST /auth/apple/callback (form_post)",
4216
+ group: "auth",
4217
+ registryDependencies: ["auth"],
4218
+ docs: "Needs a Services ID with Sign in with Apple enabled, its return URL set to APPLE_REDIRECT_URI (https, on a verified domain \u2014 Apple rejects localhost), and a Sign in with Apple key (.p8). Send users to GET /v1/api/auth/apple to sign in.",
4219
+ prompts: [
4220
+ {
4221
+ name: "APPLE_CLIENT_ID",
4222
+ type: "text",
4223
+ message: "Services ID (client ID):",
4224
+ placeholder: "com.yourapp.signin",
4225
+ required: true,
4226
+ note: "developer.apple.com \u2192 Certificates, IDs & Profiles: a Services ID with Sign in with Apple, and a key with Sign in with Apple (.p8)."
4227
+ },
4228
+ {
4229
+ name: "APPLE_TEAM_ID",
4230
+ type: "text",
4231
+ message: "Team ID:",
4232
+ required: true,
4233
+ pattern: "^[A-Z0-9]{10}$",
4234
+ patternMessage: "Team IDs are 10 characters (A-Z, 0-9)"
4235
+ },
4236
+ {
4237
+ name: "APPLE_KEY_ID",
4238
+ type: "text",
4239
+ message: "Key ID:",
4240
+ required: true,
4241
+ pattern: "^[A-Z0-9]{10}$",
4242
+ patternMessage: "Key IDs are 10 characters (A-Z, 0-9)"
4243
+ },
4244
+ {
4245
+ name: "APPLE_PRIVATE_KEY",
4246
+ type: "file",
4247
+ message: "Path to the .p8 key file:",
4248
+ placeholder: "./AuthKey_ABCDE12345.p8",
4249
+ required: true,
4250
+ pattern: "BEGIN PRIVATE KEY",
4251
+ patternMessage: "That file doesn't look like a .p8 private key"
4252
+ },
4253
+ {
4254
+ name: "APPLE_REDIRECT_URI",
4255
+ type: "text",
4256
+ message: "Return URL (https, registered on the Services ID):",
4257
+ default: "https://api.yourapp.com/v1/api/auth/apple/callback",
4258
+ pattern: "^https://",
4259
+ patternMessage: "Apple only accepts https return URLs"
4260
+ },
4261
+ {
4262
+ name: "OAUTH_SUCCESS_REDIRECT",
4263
+ type: "text",
4264
+ message: "Where to send the browser after sign-in:",
4265
+ default: "http://localhost:3000/"
4266
+ }
4267
+ ],
4268
+ enable: (o) => {
4269
+ o.userPortal = true;
4270
+ o.oauth = { ...o.oauth, apple: true };
4271
+ }
4272
+ },
4273
+ {
4274
+ name: "email-client",
4275
+ aliases: ["mailer"],
4276
+ title: "Email client",
4277
+ description: "SendEmail task over SMTP (nodemailer) \u2014 Gmail or any SMTP server",
4278
+ group: "integrations",
4279
+ registryDependencies: [],
4280
+ replaces: ["src/tasks/SendEmail.ts", "src/tasks/SendEmail.js"],
4281
+ docs: "Every enqueue('SendEmail', { to, subject, body }) now sends real mail. Change SMTP settings in .env.",
4282
+ prompts: [
4283
+ {
4284
+ name: "provider",
4285
+ type: "select",
4286
+ message: "Email provider:",
4287
+ options: [
4288
+ {
4289
+ value: "gmail",
4290
+ label: "Gmail",
4291
+ hint: "smtp.gmail.com, preconfigured",
4292
+ env: { SMTP_HOST: "smtp.gmail.com", SMTP_PORT: "465" }
4293
+ },
4294
+ { value: "custom", label: "Other (custom SMTP)", hint: "you'll provide host + port" }
4295
+ ]
4296
+ },
4297
+ {
4298
+ name: "SMTP_HOST",
4299
+ type: "text",
4300
+ message: "SMTP host:",
4301
+ placeholder: "smtp.mailtrap.io",
4302
+ required: true,
4303
+ when: { provider: "custom" }
4304
+ },
4305
+ {
4306
+ name: "SMTP_PORT",
4307
+ type: "text",
4308
+ message: "SMTP port:",
4309
+ default: "587",
4310
+ when: { provider: "custom" }
4311
+ },
4312
+ {
4313
+ name: "SMTP_USER",
4314
+ type: "text",
4315
+ message: "Gmail address:",
4316
+ placeholder: "you@gmail.com",
4317
+ required: true,
4318
+ when: { provider: "gmail" }
4319
+ },
4320
+ {
4321
+ name: "SMTP_USER",
4322
+ type: "text",
4323
+ message: "SMTP username / email:",
4324
+ placeholder: "you@example.com",
4325
+ required: true,
4326
+ when: { provider: "custom" }
4327
+ },
4328
+ {
4329
+ name: "SMTP_PASS",
4330
+ type: "password",
4331
+ message: "Gmail app password (16 characters):",
4332
+ required: true,
4333
+ stripSpaces: true,
4334
+ pattern: "^\\S{16}$",
4335
+ patternMessage: "Gmail app passwords are 16 characters \u2014 this looks like a regular password, not an app password",
4336
+ when: { provider: "gmail" },
4337
+ note: "Google no longer accepts your regular password for SMTP. Generate a 16-character App Password: Google Account \u2192 Security \u2192 2-Step Verification \u2192 App passwords."
4338
+ },
4339
+ {
4340
+ name: "SMTP_PASS",
4341
+ type: "password",
4342
+ message: "SMTP password:",
4343
+ required: true,
4344
+ when: { provider: "custom" }
4345
+ },
4346
+ { name: "SMTP_FROM", type: "text", message: "From address:", default: "{{SMTP_USER}}" },
4347
+ {
4348
+ name: "APP_URL",
4349
+ type: "text",
4350
+ message: "App URL (used in email links):",
4351
+ default: "http://localhost:3000"
4352
+ }
4353
+ ],
4354
+ enable: (o) => {
4355
+ o.mailer = true;
4356
+ }
4357
+ }
4358
+ ];
4359
+ var BY_NAME = new Map(
4360
+ ITEMS.flatMap((i) => [i.name, ...i.aliases ?? []].map((n) => [n, i]))
4361
+ );
4362
+ function getItem(name) {
4363
+ const item = BY_NAME.get(name);
4364
+ if (!item) throw new Error(`Unknown registry item "${name}"`);
4365
+ return item;
4366
+ }
4367
+ function withDependencies(names) {
4368
+ const out = [];
4369
+ const visit = (alias, stack) => {
4370
+ const name = getItem(alias).name;
4371
+ if (out.includes(name)) return;
4372
+ if (stack.includes(name)) throw new Error(`Dependency cycle: ${[...stack, name].join(" \u2192 ")}`);
4373
+ for (const dep of getItem(name).registryDependencies) visit(dep, [...stack, name]);
4374
+ out.push(name);
4375
+ };
4376
+ for (const name of names) visit(name, []);
4377
+ return out;
4378
+ }
4379
+ function registryOptions(v, items = [], extra = {}) {
4380
+ const o = {
4381
+ projectName: "efc-app",
4382
+ language: v.language,
4383
+ database: v.database,
4384
+ ...v.database === "postgresql" && { modelApi: v.modelApi },
4385
+ authStrategy: "http-only",
4386
+ cluster: true,
4387
+ tasks: true,
4388
+ taskBackend: v.database === "postgresql" ? "pg-boss" : "bullmq",
4389
+ routeDocs: true,
4390
+ userPortal: false,
4391
+ adminPortal: false,
4392
+ adminFeatures: { ...NO_ADMIN_FEATURES },
4393
+ userFeatures: { ...NO_USER_FEATURES },
4394
+ rbac: true,
4395
+ mailer: false,
4396
+ ...extra,
4397
+ registry: true
4398
+ };
4399
+ for (const name of withDependencies(items)) getItem(name).enable(o);
4400
+ return o;
4401
+ }
4402
+ function isManagedPath(p2) {
4403
+ return p2 === "package.json" || p2 === ".env" || p2 === ".env.example" || p2 === "AGENTS.md" || p2 === ".gitignore" || p2 === "tsconfig.json" || /^efc\.config\.[jt]s$/.test(p2) || /^src\/index\.[jt]s$/.test(p2) || /^src\/model\/index\.[jt]s$/.test(p2) || p2.startsWith("src/migrations/");
4404
+ }
4405
+ function sha256(content) {
4406
+ return crypto2.createHash("sha256").update(content).digest("hex");
4407
+ }
4408
+ async function writeManifest(dest, opts, items) {
4409
+ const pkg = await fs2.readJson(path2.join(dest, "package.json"));
4410
+ const files = {};
4411
+ const walk = async (dir) => {
4412
+ for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
4413
+ if (entry.name === "node_modules") continue;
4414
+ const abs = path2.join(dir, entry.name);
4415
+ if (entry.isDirectory()) await walk(abs);
4416
+ else {
4417
+ const rel = path2.relative(dest, abs).split(path2.sep).join("/");
4418
+ if (!isManagedPath(rel)) files[rel] = sha256(await fs2.readFile(abs, "utf8"));
4419
+ }
4420
+ }
4421
+ };
4422
+ await walk(dest);
4423
+ const manifest = {
4424
+ registry: (pkg.dependencies["express-file-cluster"] ?? "").replace(/^[^\d]*/, ""),
4425
+ language: opts.language,
4426
+ database: opts.database,
4427
+ modelApi: opts.database === "postgresql" ? opts.modelApi ?? "defineTable" : "defineModel",
4428
+ tasks: opts.tasks ? opts.taskBackend ?? "bullmq" : null,
4429
+ items: [...withDependencies(items)].sort(),
4430
+ files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)))
4431
+ };
4432
+ await fs2.writeFile(path2.join(dest, "efc.json"), JSON.stringify(manifest, null, 2) + "\n");
4433
+ }
4434
+ var isEnvName = (name) => /^[A-Z][A-Z0-9_]*$/.test(name);
4435
+ function promptApplies(p2, answers) {
4436
+ return Object.entries(p2.when ?? {}).every(([k, v]) => answers[k] === v);
4437
+ }
4438
+ function promptDefault(p2, answers) {
4439
+ return p2.default?.replace(/\{\{(\w+)\}\}/g, (_, k) => answers[k] ?? "");
4440
+ }
4441
+ function normalizeAnswer(p2, raw, cwd = process.cwd()) {
4442
+ let value = p2.stripSpaces ? raw.replace(/\s/g, "") : raw.trim();
4443
+ if (!value) return p2.required ? { error: "Required" } : { value };
4444
+ if (p2.type === "file") {
4445
+ const file = path2.resolve(cwd, value.replace(/^~(?=$|\/)/, os.homedir()));
4446
+ if (!fs2.existsSync(file)) return { error: `No such file: ${file}` };
4447
+ value = fs2.readFileSync(file, "utf8").trim();
4448
+ }
4449
+ if (p2.pattern && !new RegExp(p2.pattern).test(value)) {
4450
+ return { error: p2.patternMessage ?? `Must match ${p2.pattern}` };
4451
+ }
4452
+ if (p2.type === "file") value = value.replace(/\r?\n/g, "\\n");
4453
+ return { value };
4454
+ }
4455
+ function envFromAnswers(prompts, answers) {
4456
+ const env = {};
4457
+ for (const p2 of prompts) {
4458
+ if (!(p2.name in answers) || !promptApplies(p2, answers)) continue;
4459
+ if (isEnvName(p2.name)) env[p2.name] = answers[p2.name];
4460
+ const chosen = p2.options?.find((o) => o.value === answers[p2.name]);
4461
+ Object.assign(env, chosen?.env ?? {});
4462
+ }
4463
+ return env;
4464
+ }
4465
+ function itemPrompts(items) {
4466
+ const out = [];
4467
+ for (const name of items) {
4468
+ for (const p2 of getItem(name).prompts ?? []) {
4469
+ const dup = out.some(
4470
+ (q) => q.name === p2.name && JSON.stringify(q.when) === JSON.stringify(p2.when)
4471
+ );
4472
+ if (!dup) out.push(p2);
4473
+ }
4474
+ }
4475
+ return out;
4476
+ }
4477
+ async function setEnvValues(file, env) {
4478
+ const line = (k, v) => !/[\s#"'`]/.test(v) ? `${k}=${v}` : v.includes("'") ? `${k}="${v.replace(/"/g, '\\"')}"` : `${k}='${v}'`;
4479
+ const lines = await fs2.pathExists(file) ? (await fs2.readFile(file, "utf8")).split("\n") : [];
4480
+ const rest = { ...env };
4481
+ const out = lines.map((l) => {
4482
+ const key = /^([A-Za-z0-9_]+)=/.exec(l)?.[1];
4483
+ if (key === void 0 || !(key in rest)) return l;
4484
+ const v = rest[key];
4485
+ delete rest[key];
4486
+ return line(key, v);
4487
+ });
4488
+ for (const [k, v] of Object.entries(rest)) out.push(line(k, v));
4489
+ await fs2.writeFile(file, out.join("\n"));
4490
+ }
3474
4491
 
3475
4492
  // src/index.ts
3476
4493
  function cancel2(msg = "Cancelled") {
@@ -3503,190 +4520,125 @@ async function main() {
3503
4520
  ]
3504
4521
  });
3505
4522
  if (p.isCancel(database)) cancel2();
3506
- const authStrategy = await p.select({
3507
- message: "Authentication strategy:",
3508
- options: [
3509
- { value: "http-only", label: "http-only", hint: "secure cookie \u2014 recommended for SSR" },
3510
- { value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
3511
- ]
3512
- });
3513
- if (p.isCancel(authStrategy)) cancel2();
3514
- const features = await p.multiselect({
3515
- message: "Features: (space to toggle, enter to confirm)",
3516
- options: [
3517
- { value: "cluster", label: "Multi-core clustering", hint: "Node cluster module" },
3518
- { value: "tasks", label: "Background tasks", hint: "BullMQ / pg-boss" },
3519
- { value: "routeDocs", label: "API route documentation", hint: "meta exports + dashboard" },
3520
- { value: "userPortal", label: "User portal", hint: "auth, profile, billing routes" },
3521
- { value: "adminPortal", label: "Admin portal", hint: "dashboard, user mgmt, analytics" },
3522
- { value: "rbac", label: "Role-based access control", hint: "requireAuth('role') middleware" },
3523
- { value: "mailer", label: "Mailer", hint: "nodemailer + SMTP" }
3524
- ],
3525
- initialValues: ["cluster", "tasks", "routeDocs", "userPortal", "adminPortal", "rbac"],
3526
- required: false
3527
- });
3528
- if (p.isCancel(features)) cancel2();
3529
- const selected = new Set(features);
3530
- let userFeatures = NO_USER_FEATURES;
3531
- if (selected.has("userPortal")) {
3532
- const chosen = await p.multiselect({
3533
- message: "User portal features: (space to toggle, enter to confirm)",
4523
+ let modelApi2;
4524
+ if (database === "postgresql") {
4525
+ const api = await p.select({
4526
+ message: "Models:",
3534
4527
  options: [
3535
- { value: "profileViewing", label: "Profile viewing & editing", hint: "GET/PUT /user/profile" },
3536
- { value: "forgotPassword", label: "Forgot / reset password", hint: "forgot-password, reset-password" },
3537
- { value: "accountSecurity", label: "Change password, 2FA & sessions", hint: "change-password, 2fa/*, sessions/*, token refresh" },
3538
- { value: "emailVerification", label: "Email verification", hint: "verify-email route + registration flow" },
3539
- { value: "accountSettings", label: "Account settings", hint: "avatar, settings, account, dashboard, activity" },
3540
- { value: "notifications", label: "Notifications", hint: "user/notifications" },
3541
- { value: "filesAndMedia", label: "Files, favorites & bookmarks", hint: "files, favorites, bookmarks, search" },
3542
- { value: "apiAndBilling", label: "API keys & billing", hint: "api-keys, plans, subscription, invoices" },
3543
- { value: "support", label: "Support tickets", hint: "create & view own tickets" }
3544
- ],
3545
- initialValues: ["profileViewing", "forgotPassword", "accountSecurity", "emailVerification", "accountSettings", "notifications", "filesAndMedia", "apiAndBilling", "support"],
3546
- required: false
4528
+ {
4529
+ value: "defineTable",
4530
+ label: "defineTable",
4531
+ hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
4532
+ },
4533
+ { value: "defineModel", label: "defineModel", hint: "portable \u2014 the same API on MongoDB" }
4534
+ ]
3547
4535
  });
3548
- if (p.isCancel(chosen)) cancel2();
3549
- const uSet = new Set(chosen);
3550
- userFeatures = {
3551
- profileViewing: uSet.has("profileViewing"),
3552
- forgotPassword: uSet.has("forgotPassword"),
3553
- accountSecurity: uSet.has("accountSecurity"),
3554
- emailVerification: uSet.has("emailVerification"),
3555
- accountSettings: uSet.has("accountSettings"),
3556
- notifications: uSet.has("notifications"),
3557
- filesAndMedia: uSet.has("filesAndMedia"),
3558
- apiAndBilling: uSet.has("apiAndBilling"),
3559
- support: uSet.has("support")
3560
- };
4536
+ if (p.isCancel(api)) cancel2();
4537
+ modelApi2 = api;
3561
4538
  }
3562
- let adminFeatures = NO_ADMIN_FEATURES;
3563
- if (selected.has("adminPortal")) {
3564
- const chosen = await p.multiselect({
3565
- message: "Admin panel features: (space to toggle, enter to confirm)",
3566
- options: [
3567
- { value: "userManagement", label: "User management", hint: "list/create/suspend/verify/export users + dashboard stats" },
3568
- { value: "adminManagement", label: "Admin & role management", hint: "manage other admins" + (selected.has("rbac") ? " + roles" : "") },
3569
- { value: "analytics", label: "Analytics", hint: "users, revenue, traffic dashboards" },
3570
- { value: "contentManagement", label: "Content management", hint: "FAQs, blog posts, categories" },
3571
- { value: "billingManagement", label: "Billing management", hint: "plans, coupons, subscriptions" },
3572
- { value: "supportManagement", label: "Support tickets", hint: "view & respond to tickets" },
3573
- { value: "notificationsAndLogs", label: "Notifications & audit logs", hint: "broadcast + audit/activity/security logs" },
3574
- { value: "systemSettings", label: "System settings & health", hint: "app settings, health check, cache" }
3575
- ],
3576
- initialValues: ["userManagement", "adminManagement", "analytics", "contentManagement", "billingManagement", "supportManagement", "notificationsAndLogs", "systemSettings"],
3577
- required: false
4539
+ const taskBackend = await p.select({
4540
+ message: "Task queue backend:",
4541
+ options: database === "postgresql" ? [
4542
+ { value: "pg-boss", label: "pg-boss", hint: "uses your PostgreSQL \u2014 no Redis" },
4543
+ { value: "bullmq", label: "BullMQ", hint: "Redis" }
4544
+ ] : [{ value: "bullmq", label: "BullMQ", hint: "Redis" }]
4545
+ });
4546
+ if (p.isCancel(taskBackend)) cancel2();
4547
+ const cluster = await p.confirm({
4548
+ message: "Multi-core clustering? (one worker per CPU in production)",
4549
+ initialValue: true
4550
+ });
4551
+ if (p.isCancel(cluster)) cancel2();
4552
+ const groupLabel = {
4553
+ auth: "Auth",
4554
+ user: "User portal",
4555
+ admin: "Admin portal",
4556
+ integrations: "Integrations"
4557
+ };
4558
+ const groups = {};
4559
+ for (const item of ITEMS) {
4560
+ (groups[groupLabel[item.group]] ??= []).push({
4561
+ value: item.name,
4562
+ label: item.title,
4563
+ hint: item.description
3578
4564
  });
3579
- if (p.isCancel(chosen)) cancel2();
3580
- const aSet = new Set(chosen);
3581
- adminFeatures = {
3582
- userManagement: aSet.has("userManagement"),
3583
- adminManagement: aSet.has("adminManagement"),
3584
- analytics: aSet.has("analytics"),
3585
- contentManagement: aSet.has("contentManagement"),
3586
- billingManagement: aSet.has("billingManagement"),
3587
- supportManagement: aSet.has("supportManagement"),
3588
- notificationsAndLogs: aSet.has("notificationsAndLogs"),
3589
- systemSettings: aSet.has("systemSettings")
3590
- };
3591
4565
  }
3592
- let taskBackend;
3593
- if (selected.has("tasks")) {
3594
- const backend = await p.select({
3595
- message: "Task queue backend:",
4566
+ const picked = await p.groupMultiselect({
4567
+ message: `Add features now? ${pc.dim("(space to toggle, enter to confirm \u2014 or add any later with `efc add <item>`)")}`,
4568
+ options: groups,
4569
+ required: false
4570
+ });
4571
+ if (p.isCancel(picked)) cancel2();
4572
+ const items = withDependencies(picked);
4573
+ let authStrategy = "http-only";
4574
+ if (items.includes("auth")) {
4575
+ const strategy = await p.select({
4576
+ message: "Authentication strategy:",
3596
4577
  options: [
3597
- { value: "bullmq", label: "BullMQ", hint: "Redis" },
3598
- {
3599
- value: "pg-boss",
3600
- label: "pg-boss",
3601
- hint: database === "postgresql" ? "uses your PostgreSQL \u2014 no Redis" : "needs PostgreSQL"
3602
- }
4578
+ { value: "http-only", label: "http-only", hint: "secure cookie \u2014 recommended for SSR" },
4579
+ { value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
3603
4580
  ]
3604
4581
  });
3605
- if (p.isCancel(backend)) cancel2();
3606
- taskBackend = backend;
4582
+ if (p.isCancel(strategy)) cancel2();
4583
+ authStrategy = strategy;
3607
4584
  }
3608
- let smtpProvider;
3609
- let smtpHost;
3610
- let smtpPort;
3611
- let smtpUser;
3612
- let smtpPass;
3613
- if (selected.has("mailer")) {
3614
- const provider = await p.select({
3615
- message: "Email provider:",
3616
- options: [
3617
- { value: "gmail", label: "Gmail", hint: "smtp.gmail.com, preconfigured" },
3618
- { value: "custom", label: "Other (custom SMTP)", hint: "you'll provide host + port" }
3619
- ]
3620
- });
3621
- if (p.isCancel(provider)) cancel2();
3622
- smtpProvider = provider;
3623
- if (smtpProvider === "custom") {
3624
- const host = await p.text({
3625
- message: "SMTP host:",
3626
- placeholder: "smtp.mailtrap.io",
3627
- validate: (v) => !v.trim() ? "SMTP host is required" : void 0
3628
- });
3629
- if (p.isCancel(host)) cancel2();
3630
- smtpHost = host;
3631
- const port = await p.text({
3632
- message: "SMTP port:",
3633
- placeholder: "587",
3634
- defaultValue: "587"
4585
+ const answers = {};
4586
+ const prompts = itemPrompts(items);
4587
+ for (const q of prompts) {
4588
+ if (!promptApplies(q, answers)) continue;
4589
+ if (q.note) p.note(q.note);
4590
+ if (q.type === "select") {
4591
+ const choice = await p.select({
4592
+ message: q.message,
4593
+ options: (q.options ?? []).map((o) => ({
4594
+ value: o.value,
4595
+ label: o.label,
4596
+ ...o.hint !== void 0 && { hint: o.hint }
4597
+ }))
3635
4598
  });
3636
- if (p.isCancel(port)) cancel2();
3637
- smtpPort = port;
3638
- }
3639
- const user = await p.text({
3640
- message: smtpProvider === "gmail" ? "Gmail address:" : "SMTP username / email:",
3641
- placeholder: "you@example.com",
3642
- validate: (v) => !v.trim() ? "Email is required" : void 0
3643
- });
3644
- if (p.isCancel(user)) cancel2();
3645
- smtpUser = user;
3646
- if (smtpProvider === "gmail") {
3647
- p.note(
3648
- "Google no longer accepts your regular account password for SMTP.\nGenerate a 16-character App Password: Google Account -> Security -> 2-Step Verification -> App passwords.\nEnter it below without spaces (not your Gmail login password).",
3649
- "Gmail app password required"
3650
- );
4599
+ if (p.isCancel(choice)) cancel2();
4600
+ answers[q.name] = choice;
4601
+ continue;
3651
4602
  }
3652
- const pass = await p.password({
3653
- message: smtpProvider === "gmail" ? "Gmail app password (16 characters):" : "SMTP password:",
3654
- validate: (v) => {
3655
- if (!v.trim()) return "Password is required";
3656
- if (smtpProvider === "gmail" && v.replace(/\s/g, "").length !== 16) {
3657
- return "Gmail app passwords are 16 characters \u2014 this looks like a regular password, not an app password";
3658
- }
3659
- return void 0;
3660
- }
4603
+ const def = promptDefault(q, answers);
4604
+ const check = (v) => {
4605
+ const r2 = normalizeAnswer(q, v.trim() === "" ? def ?? "" : v);
4606
+ return "error" in r2 ? r2.error : void 0;
4607
+ };
4608
+ const raw = q.type === "password" ? await p.password({ message: q.message, validate: check }) : await p.text({
4609
+ message: q.message,
4610
+ ...q.placeholder !== void 0 && { placeholder: q.placeholder },
4611
+ ...def !== void 0 && { placeholder: def, defaultValue: def },
4612
+ validate: check
3661
4613
  });
3662
- if (p.isCancel(pass)) cancel2();
3663
- smtpPass = pass.replace(/\s/g, "");
4614
+ if (p.isCancel(raw)) cancel2();
4615
+ const r = normalizeAnswer(q, raw.trim() === "" ? def ?? "" : raw);
4616
+ answers[q.name] = "value" in r ? r.value : "";
3664
4617
  }
3665
- const opts = {
3666
- projectName,
3667
- language,
3668
- database,
3669
- authStrategy,
3670
- cluster: selected.has("cluster"),
3671
- tasks: selected.has("tasks"),
3672
- routeDocs: selected.has("routeDocs"),
3673
- userPortal: selected.has("userPortal"),
3674
- adminPortal: selected.has("adminPortal"),
3675
- userFeatures,
3676
- adminFeatures,
3677
- rbac: selected.has("rbac"),
3678
- mailer: selected.has("mailer"),
3679
- ...taskBackend !== void 0 && { taskBackend },
3680
- ...smtpProvider !== void 0 && { smtpProvider },
3681
- ...smtpHost !== void 0 && { smtpHost },
3682
- ...smtpPort !== void 0 && { smtpPort },
3683
- ...smtpUser !== void 0 && { smtpUser },
3684
- ...smtpPass !== void 0 && { smtpPass }
3685
- };
4618
+ const env = envFromAnswers(prompts, answers);
4619
+ const opts = registryOptions(
4620
+ {
4621
+ language,
4622
+ database,
4623
+ modelApi: modelApi2 ?? "defineModel"
4624
+ },
4625
+ items,
4626
+ {
4627
+ projectName,
4628
+ authStrategy,
4629
+ cluster,
4630
+ taskBackend,
4631
+ ...answers["provider"] === "custom" && { smtpProvider: "custom" }
4632
+ }
4633
+ );
3686
4634
  const spinner2 = p.spinner();
3687
4635
  spinner2.start("Scaffolding project\u2026");
3688
4636
  try {
3689
4637
  await scaffold(opts);
4638
+ if (Object.keys(env).length) {
4639
+ await setEnvValues(path3.resolve(process.cwd(), opts.projectName, ".env"), env);
4640
+ }
4641
+ await writeManifest(path3.resolve(process.cwd(), opts.projectName), opts, items);
3690
4642
  spinner2.stop("Project created");
3691
4643
  } catch (err) {
3692
4644
  spinner2.stop("Failed to scaffold project");
@@ -3700,11 +4652,12 @@ async function main() {
3700
4652
  await npmInstallGlobal().catch(() => {
3701
4653
  });
3702
4654
  spinner2.stop("efc CLI ready");
3703
- if (opts.mailer) {
4655
+ const secrets = Object.keys(env).filter((k) => /PASS|SECRET|PRIVATE_KEY/.test(k));
4656
+ if (secrets.length) {
3704
4657
  p.note(
3705
- `SMTP_USER and SMTP_PASS were written to ${projectName}/.env \u2014 that file is gitignored, never commit it.
3706
- ` + (opts.smtpProvider === "gmail" ? "If this Gmail app password ever leaks, revoke it and generate a new one." : "Rotate SMTP_PASS immediately if it is ever exposed."),
3707
- "Mailer credentials"
4658
+ `${secrets.join(", ")} ${secrets.length > 1 ? "were" : "was"} written to ${projectName}/.env \u2014 that file is gitignored, never commit it.
4659
+ Rotate any of them immediately if it is ever exposed.`,
4660
+ "Credentials"
3708
4661
  );
3709
4662
  }
3710
4663
  p.outro(
@@ -3715,6 +4668,8 @@ Your project is ready!
3715
4668
  `) + pc.dim(` efc start dev
3716
4669
  `) + pc.dim(`
3717
4670
  (or: npm run dev)
4671
+ `) + pc.dim(`
4672
+ Add features any time: efc list \xB7 efc add <item>
3718
4673
  `)
3719
4674
  );
3720
4675
  }
@@ -3727,7 +4682,10 @@ function npmInstall(projectDir) {
3727
4682
  function npmInstallGlobal() {
3728
4683
  return new Promise((resolve, reject) => {
3729
4684
  const child = spawn("npm", ["install", "-g", "express-file-cluster"], { stdio: "ignore" });
3730
- child.on("exit", (code) => code === 0 ? resolve() : reject(new Error("global install failed")));
4685
+ child.on(
4686
+ "exit",
4687
+ (code) => code === 0 ? resolve() : reject(new Error("global install failed"))
4688
+ );
3731
4689
  });
3732
4690
  }
3733
4691
  main().catch((err) => {