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/README.md +14 -1
- package/dist/index.js +1023 -279
- package/dist/index.js.map +1 -1
- package/package.json +10 -1
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
|
|
54
|
-
const
|
|
66
|
+
const user2 = opts.userPortal;
|
|
67
|
+
const admin2 = opts.adminPortal;
|
|
55
68
|
const m = /* @__PURE__ */ new Set();
|
|
56
|
-
if (
|
|
57
|
-
if (
|
|
58
|
-
if (
|
|
59
|
-
if (
|
|
60
|
-
if (
|
|
61
|
-
if (
|
|
62
|
-
if (
|
|
63
|
-
if (
|
|
64
|
-
if (
|
|
65
|
-
if (opts
|
|
66
|
-
if (
|
|
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.
|
|
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["
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
740
|
+
${sendsMail(opts) ? "import { enqueue } from 'express-file-cluster/tasks';\n" : ""}import { User } from '../../model/User.js';
|
|
687
741
|
`;
|
|
688
|
-
const sendVerifyEmail = opts
|
|
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.
|
|
1396
|
+
const hasAdmin = principals(opts).admin;
|
|
1343
1397
|
const uf = opts.userFeatures;
|
|
1344
|
-
const sendResetEmail = opts
|
|
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
|
|
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
|
|
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
|
|
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: ${
|
|
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: ${
|
|
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
|
|
3646
|
-
message: "
|
|
3647
|
-
options: [
|
|
3648
|
-
{ value: "
|
|
3649
|
-
{ value: "
|
|
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(
|
|
3653
|
-
const
|
|
3654
|
-
message: "
|
|
3655
|
-
|
|
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(
|
|
3668
|
-
const
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
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
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
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: "
|
|
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(
|
|
3816
|
-
|
|
4582
|
+
if (p.isCancel(strategy)) cancel2();
|
|
4583
|
+
authStrategy = strategy;
|
|
3817
4584
|
}
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
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(
|
|
3840
|
-
|
|
3841
|
-
|
|
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
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
}
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
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(
|
|
3873
|
-
|
|
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
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
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
|
-
|
|
4655
|
+
const secrets = Object.keys(env).filter((k) => /PASS|SECRET|PRIVATE_KEY/.test(k));
|
|
4656
|
+
if (secrets.length) {
|
|
3915
4657
|
p.note(
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
"
|
|
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
|
}
|