create-efc-app 0.7.1 → 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,6 +31,18 @@ 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
+ }
33
46
  function usesTables(opts) {
34
47
  return opts.database === "postgresql" && opts.modelApi !== "defineModel";
35
48
  }
@@ -50,20 +63,23 @@ function orm(opts) {
50
63
  function generatedModels(opts) {
51
64
  const uf = opts.userFeatures;
52
65
  const af = opts.adminFeatures;
53
- const user = opts.userPortal;
54
- const admin = opts.adminPortal;
66
+ const user2 = opts.userPortal;
67
+ const admin2 = opts.adminPortal;
55
68
  const m = /* @__PURE__ */ new Set();
56
- if (user || admin && af.userManagement) m.add("User");
57
- if (admin) m.add("Admin");
58
- if (user && uf.accountSecurity) m.add("Session");
59
- if (user && uf.notifications || admin && af.notificationsAndLogs) m.add("Notification");
60
- if (user && uf.filesAndMedia) m.add("File");
61
- if (user && uf.support || admin && af.supportManagement) m.add("SupportTicket");
62
- if (admin && af.notificationsAndLogs) m.add("AuditLog");
63
- if (user && uf.apiAndBilling) m.add("Subscription").add("Invoice").add("ApiKey");
64
- if (admin && af.billingManagement) m.add("Plan").add("Coupon");
65
- if (opts.rbac && admin && af.adminManagement) m.add("Role");
66
- if (admin && af.contentManagement) m.add("FAQ").add("Blog").add("Category");
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");
67
83
  return m;
68
84
  }
69
85
  function modelApi(opts) {
@@ -76,7 +92,7 @@ function refField(opts, r) {
76
92
  return ` ${(r.field + ":").padEnd(13)}{ type: 'uuid', ref: '${r.target}'${opts2} },`;
77
93
  }
78
94
  function relationsOption(opts, rels) {
79
- if (!usesTables(opts)) return "";
95
+ if (!usesTables(opts) || opts.registry) return "";
80
96
  const models = generatedModels(opts);
81
97
  const live = rels.filter((r) => models.has(r.target));
82
98
  if (live.length === 0) return "";
@@ -104,7 +120,7 @@ async function scaffold(opts) {
104
120
  await writeExampleRoute(dest, opts);
105
121
  if (models.has("User")) await writeUserModel(dest, opts);
106
122
  if (models.has("Admin")) await writeAdminModel(dest, opts);
107
- await writeAuthRoutes(dest, opts);
123
+ if (!opts.registry || opts.userPortal || opts.adminPortal) await writeAuthRoutes(dest, opts);
108
124
  if (opts.adminPortal) await writeAdminRoutes(dest, opts);
109
125
  if (opts.userPortal && uf.profileViewing) await writeUserRoutes(dest, opts);
110
126
  if (opts.tasks) await writeExampleTask(dest, opts);
@@ -125,6 +141,8 @@ async function scaffold(opts) {
125
141
  ["Coupon", writeCouponModel]
126
142
  ];
127
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);
128
146
  if (usesTables(opts)) await writeModelIndex(dest, opts);
129
147
  if (opts.userPortal) await writeAuthExtendedRoutes(dest, opts);
130
148
  if (opts.userPortal) await writeUserExtendedRoutes(dest, opts);
@@ -135,7 +153,7 @@ async function scaffold(opts) {
135
153
  }
136
154
  async function writePackageJson(dest, opts) {
137
155
  const deps = {
138
- "express-file-cluster": "^0.7.1"
156
+ "express-file-cluster": "^0.8.0"
139
157
  };
140
158
  if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
141
159
  if (opts.database === "postgresql") {
@@ -145,7 +163,9 @@ async function writePackageJson(dest, opts) {
145
163
  if (opts.tasks && opts.taskBackend === "bullmq") deps["bullmq"] = "^5.0.0";
146
164
  if (opts.tasks && opts.taskBackend === "pg-boss") deps["pg-boss"] = "^12.0.0";
147
165
  if (opts.mailer) deps["nodemailer"] = "^6.9.0";
148
- 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";
149
169
  const devDeps = {
150
170
  vitest: "^5.0.3"
151
171
  };
@@ -156,7 +176,7 @@ async function writePackageJson(dest, opts) {
156
176
  devDeps["@types/express"] = "^4.17.21";
157
177
  devDeps["tsx"] = "^4.0.0";
158
178
  if (opts.mailer) devDeps["@types/nodemailer"] = "^6.4.0";
159
- devDeps["@types/bcrypt"] = "^5.0.0";
179
+ if (auth) devDeps["@types/bcrypt"] = "^5.0.0";
160
180
  if (opts.database === "postgresql") devDeps["@types/pg"] = "^8.11.0";
161
181
  }
162
182
  const pkg = {
@@ -291,12 +311,15 @@ Optional \u2014 the same docs over MCP, with search and scaffolding tools:
291
311
  - \`src/tasks/*.${ext}\` \u2014 background tasks; the file name is the task name (not recursive).
292
312
  ${usesTables(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineTable\` models (PostgreSQL-native, Prisma-style queries:
293
313
  \`findMany({ where, include })\`, \`create({ data })\`, \`update({ where, data })\`). A \`ref\` is a
294
- foreign key; back-relations are in each model's \`relations\`. \`src/model/index.${ext}\` imports
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
295
315
  them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
296
316
  - \`src/index.${ext}\` is the entry point; it imports \`efc.config.${ext}\` and passes it to \`ignite()\`.
297
317
  - Only three import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
298
318
  \`express-file-cluster/tasks\`.
299
-
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
+ ` : ""}
300
323
  ## Commands
301
324
 
302
325
  \`\`\`bash
@@ -334,16 +357,48 @@ SMTP_PASS=${isGmail ? "your_16_char_app_password" : "your_smtp_password"}${passC
334
357
  SMTP_FROM=noreply@yourapp.com
335
358
  ` : "";
336
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("");
337
392
  const dotenv = `PORT=3000
338
393
  NODE_ENV=development
339
394
  DATABASE_URL=${dbUrl}
340
395
  JWT_SECRET=${secret}${redis}
341
- CORS_ORIGINS=http://localhost:3000${smtpVars}`;
396
+ CORS_ORIGINS=http://localhost:3000${smtpVars}${oauthEnv}`;
342
397
  const example = `PORT=3000
343
398
  NODE_ENV=development
344
399
  DATABASE_URL=${dbExampleUrl}
345
400
  JWT_SECRET=<generate with: openssl rand -hex 64>${redis}
346
- CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}`;
401
+ CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}${oauthExample}`;
347
402
  await fs.outputFile(path.join(dest, ".env"), dotenv);
348
403
  await fs.outputFile(path.join(dest, ".env.example"), example);
349
404
  }
@@ -527,8 +582,7 @@ export const meta: RouteMeta = {
527
582
  };
528
583
 
529
584
  ` : "";
530
- const hasUser = opts.userPortal;
531
- const hasAdmin = opts.adminPortal;
585
+ const { user: hasUser, admin: hasAdmin } = principals(opts);
532
586
  const hasModels = hasUser || hasAdmin;
533
587
  const loginDbImports = hasModels ? `import bcrypt from 'bcrypt';
534
588
  import crypto from 'node:crypto';
@@ -661,7 +715,7 @@ ${logoutMeta}export const POST = async (req, res) => {
661
715
  `;
662
716
  await fs.outputFile(path.join(dest, "src", "api", "auth", `login.${ext}`), loginContent);
663
717
  await fs.outputFile(path.join(dest, "src", "api", "auth", `logout.${ext}`), logoutContent);
664
- if (!opts.userPortal) return;
718
+ if (!hasUser) return;
665
719
  const registerMeta = opts.routeDocs ? ts ? `import type { RouteMeta } from 'express-file-cluster';
666
720
 
667
721
  export const meta: RouteMeta = {
@@ -683,9 +737,9 @@ export const meta: RouteMeta = {
683
737
  ` : "";
684
738
  const registerDbImports = `import bcrypt from 'bcrypt';
685
739
  import crypto from 'node:crypto';
686
- ${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';
687
741
  `;
688
- const sendVerifyEmail = opts.mailer ? `
742
+ const sendVerifyEmail = sendsMail(opts) ? `
689
743
  var appUrl = process.env.APP_URL || 'http://localhost:3000';
690
744
  await enqueue('SendEmail', {
691
745
  to: email,
@@ -795,7 +849,7 @@ export const meta: RouteMeta = {
795
849
  };
796
850
 
797
851
  ` : "";
798
- const dashboardHasUserModel = opts.adminFeatures.userManagement;
852
+ const dashboardHasUserModel = !!opts.registry || opts.adminFeatures.userManagement;
799
853
  const dashboardDbImport = dashboardHasUserModel ? `import { User } from '../../model/User.js';
800
854
  ` : "";
801
855
  const dashboardContent = dashboardHasUserModel ? ts ? `import { requireAuth } from 'express-file-cluster/auth';
@@ -1339,9 +1393,9 @@ async function writeAuthExtendedRoutes(dest, opts) {
1339
1393
  const mwUser3 = mwUser2;
1340
1394
  const RA = `import { requireAuth } from 'express-file-cluster/auth';
1341
1395
  `;
1342
- const hasAdmin = opts.adminPortal;
1396
+ const hasAdmin = principals(opts).admin;
1343
1397
  const uf = opts.userFeatures;
1344
- 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';
1345
1399
  await enqueue('SendEmail', {
1346
1400
  to: email,
1347
1401
  subject: 'Reset your password',
@@ -1349,7 +1403,7 @@ async function writeAuthExtendedRoutes(dest, opts) {
1349
1403
  });
1350
1404
  ` : ` // TODO: email this token to the user \u2014 enable the Mailer feature to auto-wire SendEmail
1351
1405
  `;
1352
- 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';
1353
1407
  await enqueue('SendEmail', {
1354
1408
  to: email,
1355
1409
  subject: 'Verify your email address',
@@ -1357,7 +1411,7 @@ async function writeAuthExtendedRoutes(dest, opts) {
1357
1411
  });
1358
1412
  ` : ` // TODO: email this token to the user \u2014 enable the Mailer feature to auto-wire SendEmail
1359
1413
  `;
1360
- const mailerTaskImport = opts.mailer ? `import { enqueue } from 'express-file-cluster/tasks';
1414
+ const mailerTaskImport = sendsMail(opts) ? `import { enqueue } from 'express-file-cluster/tasks';
1361
1415
  ` : "";
1362
1416
  if (uf.accountSecurity) {
1363
1417
  const refreshMeta = mkMeta(opts, {
@@ -1795,7 +1849,7 @@ async function writeUserExtendedRoutes(dest, opts) {
1795
1849
  const mw3 = mw2;
1796
1850
  const RA = `import { requireAuth } from 'express-file-cluster/auth';
1797
1851
  `;
1798
- const user = ts ? `(req as any).user` : `req.user`;
1852
+ const user2 = ts ? `(req as any).user` : `req.user`;
1799
1853
  const uf = opts.userFeatures;
1800
1854
  if (uf.accountSettings) {
1801
1855
  const avatarMeta = mkMeta(opts, {
@@ -1882,7 +1936,7 @@ export const PUT = async (req, res) => {
1882
1936
  ${accountMeta}${mw2}
1883
1937
  export const GET = async (req: Request, res: Response) => {
1884
1938
  // TODO: compile and return personal data export
1885
- res.json({ data: { user: ${user}, exportedAt: new Date().toISOString() } });
1939
+ res.json({ data: { user: ${user2}, exportedAt: new Date().toISOString() } });
1886
1940
  };
1887
1941
 
1888
1942
  export const DELETE = async (req: Request, res: Response) => {
@@ -1894,7 +1948,7 @@ export const DELETE = async (req: Request, res: Response) => {
1894
1948
  ` : `${RA}${accountMeta}${mw2}
1895
1949
  export const GET = async (req, res) => {
1896
1950
  // TODO: compile and return personal data export
1897
- res.json({ data: { user: ${user}, exportedAt: new Date().toISOString() } });
1951
+ res.json({ data: { user: ${user2}, exportedAt: new Date().toISOString() } });
1898
1952
  };
1899
1953
 
1900
1954
  export const DELETE = async (req, res) => {
@@ -3559,6 +3613,7 @@ var MODEL_ORDER = [
3559
3613
  "Role",
3560
3614
  "FAQ",
3561
3615
  "Coupon",
3616
+ "OAuthAccount",
3562
3617
  "Session",
3563
3618
  "Notification",
3564
3619
  "File",
@@ -3594,6 +3649,845 @@ export async function down${sig} {
3594
3649
  `;
3595
3650
  await fs.outputFile(path.join(dest, "src", "migrations", `00000000000000_init.${ext}`), content);
3596
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
+ }
3597
4491
 
3598
4492
  // src/index.ts
3599
4493
  function cancel2(msg = "Cancelled") {
@@ -3642,262 +4536,109 @@ async function main() {
3642
4536
  if (p.isCancel(api)) cancel2();
3643
4537
  modelApi2 = api;
3644
4538
  }
3645
- const authStrategy = await p.select({
3646
- message: "Authentication strategy:",
3647
- options: [
3648
- { value: "http-only", label: "http-only", hint: "secure cookie \u2014 recommended for SSR" },
3649
- { value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
3650
- ]
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" }]
3651
4545
  });
3652
- if (p.isCancel(authStrategy)) cancel2();
3653
- const features = await p.multiselect({
3654
- message: "Features: (space to toggle, enter to confirm)",
3655
- options: [
3656
- { value: "cluster", label: "Multi-core clustering", hint: "Node cluster module" },
3657
- { value: "tasks", label: "Background tasks", hint: "BullMQ / pg-boss" },
3658
- { value: "routeDocs", label: "API route documentation", hint: "meta exports + dashboard" },
3659
- { value: "userPortal", label: "User portal", hint: "auth, profile, billing routes" },
3660
- { value: "adminPortal", label: "Admin portal", hint: "dashboard, user mgmt, analytics" },
3661
- { value: "rbac", label: "Role-based access control", hint: "requireAuth('role') middleware" },
3662
- { value: "mailer", label: "Mailer", hint: "nodemailer + SMTP" }
3663
- ],
3664
- initialValues: ["cluster", "tasks", "routeDocs", "userPortal", "adminPortal", "rbac"],
3665
- required: false
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
3666
4550
  });
3667
- if (p.isCancel(features)) cancel2();
3668
- const selected = new Set(features);
3669
- let userFeatures = NO_USER_FEATURES;
3670
- if (selected.has("userPortal")) {
3671
- const chosen = await p.multiselect({
3672
- message: "User portal features: (space to toggle, enter to confirm)",
3673
- options: [
3674
- {
3675
- value: "profileViewing",
3676
- label: "Profile viewing & editing",
3677
- hint: "GET/PUT /user/profile"
3678
- },
3679
- {
3680
- value: "forgotPassword",
3681
- label: "Forgot / reset password",
3682
- hint: "forgot-password, reset-password"
3683
- },
3684
- {
3685
- value: "accountSecurity",
3686
- label: "Change password, 2FA & sessions",
3687
- hint: "change-password, 2fa/*, sessions/*, token refresh"
3688
- },
3689
- {
3690
- value: "emailVerification",
3691
- label: "Email verification",
3692
- hint: "verify-email route + registration flow"
3693
- },
3694
- {
3695
- value: "accountSettings",
3696
- label: "Account settings",
3697
- hint: "avatar, settings, account, dashboard, activity"
3698
- },
3699
- { value: "notifications", label: "Notifications", hint: "user/notifications" },
3700
- {
3701
- value: "filesAndMedia",
3702
- label: "Files, favorites & bookmarks",
3703
- hint: "files, favorites, bookmarks, search"
3704
- },
3705
- {
3706
- value: "apiAndBilling",
3707
- label: "API keys & billing",
3708
- hint: "api-keys, plans, subscription, invoices"
3709
- },
3710
- { value: "support", label: "Support tickets", hint: "create & view own tickets" }
3711
- ],
3712
- initialValues: [
3713
- "profileViewing",
3714
- "forgotPassword",
3715
- "accountSecurity",
3716
- "emailVerification",
3717
- "accountSettings",
3718
- "notifications",
3719
- "filesAndMedia",
3720
- "apiAndBilling",
3721
- "support"
3722
- ],
3723
- required: false
3724
- });
3725
- if (p.isCancel(chosen)) cancel2();
3726
- const uSet = new Set(chosen);
3727
- userFeatures = {
3728
- profileViewing: uSet.has("profileViewing"),
3729
- forgotPassword: uSet.has("forgotPassword"),
3730
- accountSecurity: uSet.has("accountSecurity"),
3731
- emailVerification: uSet.has("emailVerification"),
3732
- accountSettings: uSet.has("accountSettings"),
3733
- notifications: uSet.has("notifications"),
3734
- filesAndMedia: uSet.has("filesAndMedia"),
3735
- apiAndBilling: uSet.has("apiAndBilling"),
3736
- support: uSet.has("support")
3737
- };
3738
- }
3739
- let adminFeatures = NO_ADMIN_FEATURES;
3740
- if (selected.has("adminPortal")) {
3741
- const chosen = await p.multiselect({
3742
- message: "Admin panel features: (space to toggle, enter to confirm)",
3743
- options: [
3744
- {
3745
- value: "userManagement",
3746
- label: "User management",
3747
- hint: "list/create/suspend/verify/export users + dashboard stats"
3748
- },
3749
- {
3750
- value: "adminManagement",
3751
- label: "Admin & role management",
3752
- hint: "manage other admins" + (selected.has("rbac") ? " + roles" : "")
3753
- },
3754
- { value: "analytics", label: "Analytics", hint: "users, revenue, traffic dashboards" },
3755
- {
3756
- value: "contentManagement",
3757
- label: "Content management",
3758
- hint: "FAQs, blog posts, categories"
3759
- },
3760
- {
3761
- value: "billingManagement",
3762
- label: "Billing management",
3763
- hint: "plans, coupons, subscriptions"
3764
- },
3765
- { value: "supportManagement", label: "Support tickets", hint: "view & respond to tickets" },
3766
- {
3767
- value: "notificationsAndLogs",
3768
- label: "Notifications & audit logs",
3769
- hint: "broadcast + audit/activity/security logs"
3770
- },
3771
- {
3772
- value: "systemSettings",
3773
- label: "System settings & health",
3774
- hint: "app settings, health check, cache"
3775
- }
3776
- ],
3777
- initialValues: [
3778
- "userManagement",
3779
- "adminManagement",
3780
- "analytics",
3781
- "contentManagement",
3782
- "billingManagement",
3783
- "supportManagement",
3784
- "notificationsAndLogs",
3785
- "systemSettings"
3786
- ],
3787
- required: false
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
3788
4564
  });
3789
- if (p.isCancel(chosen)) cancel2();
3790
- const aSet = new Set(chosen);
3791
- adminFeatures = {
3792
- userManagement: aSet.has("userManagement"),
3793
- adminManagement: aSet.has("adminManagement"),
3794
- analytics: aSet.has("analytics"),
3795
- contentManagement: aSet.has("contentManagement"),
3796
- billingManagement: aSet.has("billingManagement"),
3797
- supportManagement: aSet.has("supportManagement"),
3798
- notificationsAndLogs: aSet.has("notificationsAndLogs"),
3799
- systemSettings: aSet.has("systemSettings")
3800
- };
3801
4565
  }
3802
- let taskBackend;
3803
- if (selected.has("tasks")) {
3804
- const backend = await p.select({
3805
- 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:",
3806
4577
  options: [
3807
- { value: "bullmq", label: "BullMQ", hint: "Redis" },
3808
- {
3809
- value: "pg-boss",
3810
- label: "pg-boss",
3811
- hint: database === "postgresql" ? "uses your PostgreSQL \u2014 no Redis" : "needs PostgreSQL"
3812
- }
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" }
3813
4580
  ]
3814
4581
  });
3815
- if (p.isCancel(backend)) cancel2();
3816
- taskBackend = backend;
4582
+ if (p.isCancel(strategy)) cancel2();
4583
+ authStrategy = strategy;
3817
4584
  }
3818
- let smtpProvider;
3819
- let smtpHost;
3820
- let smtpPort;
3821
- let smtpUser;
3822
- let smtpPass;
3823
- if (selected.has("mailer")) {
3824
- const provider = await p.select({
3825
- message: "Email provider:",
3826
- options: [
3827
- { value: "gmail", label: "Gmail", hint: "smtp.gmail.com, preconfigured" },
3828
- { value: "custom", label: "Other (custom SMTP)", hint: "you'll provide host + port" }
3829
- ]
3830
- });
3831
- if (p.isCancel(provider)) cancel2();
3832
- smtpProvider = provider;
3833
- if (smtpProvider === "custom") {
3834
- const host = await p.text({
3835
- message: "SMTP host:",
3836
- placeholder: "smtp.mailtrap.io",
3837
- validate: (v) => !v.trim() ? "SMTP host is required" : void 0
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
+ }))
3838
4598
  });
3839
- if (p.isCancel(host)) cancel2();
3840
- smtpHost = host;
3841
- const port = await p.text({
3842
- message: "SMTP port:",
3843
- placeholder: "587",
3844
- defaultValue: "587"
3845
- });
3846
- if (p.isCancel(port)) cancel2();
3847
- smtpPort = port;
4599
+ if (p.isCancel(choice)) cancel2();
4600
+ answers[q.name] = choice;
4601
+ continue;
3848
4602
  }
3849
- const user = await p.text({
3850
- message: smtpProvider === "gmail" ? "Gmail address:" : "SMTP username / email:",
3851
- placeholder: "you@example.com",
3852
- validate: (v) => !v.trim() ? "Email is required" : void 0
3853
- });
3854
- if (p.isCancel(user)) cancel2();
3855
- smtpUser = user;
3856
- if (smtpProvider === "gmail") {
3857
- p.note(
3858
- "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).",
3859
- "Gmail app password required"
3860
- );
3861
- }
3862
- const pass = await p.password({
3863
- message: smtpProvider === "gmail" ? "Gmail app password (16 characters):" : "SMTP password:",
3864
- validate: (v) => {
3865
- if (!v.trim()) return "Password is required";
3866
- if (smtpProvider === "gmail" && v.replace(/\s/g, "").length !== 16) {
3867
- return "Gmail app passwords are 16 characters \u2014 this looks like a regular password, not an app password";
3868
- }
3869
- return void 0;
3870
- }
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
3871
4613
  });
3872
- if (p.isCancel(pass)) cancel2();
3873
- 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 : "";
3874
4617
  }
3875
- const opts = {
3876
- projectName,
3877
- language,
3878
- database,
3879
- authStrategy,
3880
- cluster: selected.has("cluster"),
3881
- tasks: selected.has("tasks"),
3882
- routeDocs: selected.has("routeDocs"),
3883
- userPortal: selected.has("userPortal"),
3884
- adminPortal: selected.has("adminPortal"),
3885
- userFeatures,
3886
- adminFeatures,
3887
- rbac: selected.has("rbac"),
3888
- mailer: selected.has("mailer"),
3889
- ...modelApi2 !== void 0 && { modelApi: modelApi2 },
3890
- ...taskBackend !== void 0 && { taskBackend },
3891
- ...smtpProvider !== void 0 && { smtpProvider },
3892
- ...smtpHost !== void 0 && { smtpHost },
3893
- ...smtpPort !== void 0 && { smtpPort },
3894
- ...smtpUser !== void 0 && { smtpUser },
3895
- ...smtpPass !== void 0 && { smtpPass }
3896
- };
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
+ );
3897
4634
  const spinner2 = p.spinner();
3898
4635
  spinner2.start("Scaffolding project\u2026");
3899
4636
  try {
3900
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);
3901
4642
  spinner2.stop("Project created");
3902
4643
  } catch (err) {
3903
4644
  spinner2.stop("Failed to scaffold project");
@@ -3911,11 +4652,12 @@ async function main() {
3911
4652
  await npmInstallGlobal().catch(() => {
3912
4653
  });
3913
4654
  spinner2.stop("efc CLI ready");
3914
- if (opts.mailer) {
4655
+ const secrets = Object.keys(env).filter((k) => /PASS|SECRET|PRIVATE_KEY/.test(k));
4656
+ if (secrets.length) {
3915
4657
  p.note(
3916
- `SMTP_USER and SMTP_PASS were written to ${projectName}/.env \u2014 that file is gitignored, never commit it.
3917
- ` + (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."),
3918
- "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"
3919
4661
  );
3920
4662
  }
3921
4663
  p.outro(
@@ -3926,6 +4668,8 @@ Your project is ready!
3926
4668
  `) + pc.dim(` efc start dev
3927
4669
  `) + pc.dim(`
3928
4670
  (or: npm run dev)
4671
+ `) + pc.dim(`
4672
+ Add features any time: efc list \xB7 efc add <item>
3929
4673
  `)
3930
4674
  );
3931
4675
  }