@awsmbiz/create-inbis-app 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/cli.mjs +850 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +43 -3
- package/template/.dev.vars.example +4 -0
- package/template/README.md +115 -0
- package/template/e2e/accessibility.spec.ts +33 -0
- package/template/e2e/axe.ts +47 -0
- package/template/e2e/shell.spec.ts +33 -0
- package/template/e2e/tsconfig.json +8 -0
- package/template/eslint.config.js +7 -0
- package/template/migrations/0001_app_initial.sql +8 -0
- package/template/modules/README.md +6 -0
- package/template/modules/access-control/files/src/access-control-routes.ts +36 -0
- package/template/modules/access-control/files/src/access-control.ts +46 -0
- package/template/modules/access-control/files/test/access-control.test.ts +78 -0
- package/template/modules/access-control/module.json +54 -0
- package/template/modules/access-control/wiring/admin/admin.imports.tsx +1 -0
- package/template/modules/access-control/wiring/admin/admin.nav.tsx +2 -0
- package/template/modules/access-control/wiring/admin/admin.pages.tsx +4 -0
- package/template/modules/access-control/wiring/admin/admin.userSections.tsx +1 -0
- package/template/modules/access-control/wiring/admin/readme.modules.md +3 -0
- package/template/modules/access-control/wiring/admin/web.messages.ts +4 -0
- package/template/modules/access-control/wiring/audit/audit.catalogues.imports.ts +1 -0
- package/template/modules/access-control/wiring/audit/audit.catalogues.ts +1 -0
- package/template/modules/access-control/wiring/audit/readme.modules.md +4 -0
- package/template/modules/access-control/wiring/audit/web.messages.ts +6 -0
- package/template/modules/access-control/wiring/d1.jsonc +8 -0
- package/template/modules/access-control/wiring/env.bindings.ts +2 -0
- package/template/modules/access-control/wiring/readme.modules.md +20 -0
- package/template/modules/access-control/wiring/test.env.ts +1 -0
- package/template/modules/access-control/wiring/test.migrate.ts +1 -0
- package/template/modules/access-control/wiring/vitest.bindings.ts +1 -0
- package/template/modules/access-control/wiring/worker.imports.ts +1 -0
- package/template/modules/access-control/wiring/worker.routes.ts +2 -0
- package/template/modules/access-control/with-admin/e2e/access-roles.spec.ts +51 -0
- package/template/modules/access-control/with-admin/web/src/admin-roles.tsx +38 -0
- package/template/modules/access-control/with-audit/test/access-control-audit.test.ts +25 -0
- package/template/modules/admin/files/e2e/admin-accessibility.spec.ts +86 -0
- package/template/modules/admin/files/e2e/admin-area.spec.ts +109 -0
- package/template/modules/admin/files/e2e/admin-helpers.ts +39 -0
- package/template/modules/admin/files/src/admin.ts +46 -0
- package/template/modules/admin/files/test/admin.test.ts +47 -0
- package/template/modules/admin/files/web/src/admin-area.tsx +140 -0
- package/template/modules/admin/files/web/src/admin-client.ts +49 -0
- package/template/modules/admin/files/web/src/admin-example.tsx +62 -0
- package/template/modules/admin/files/web/src/admin-gate.tsx +112 -0
- package/template/modules/admin/module.json +50 -0
- package/template/modules/admin/wiring/account.sections.tsx +16 -0
- package/template/modules/admin/wiring/audit/admin.authAudit.ts +2 -0
- package/template/modules/admin/wiring/audit/admin.imports.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.nav.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.pages.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.server.imports.ts +3 -0
- package/template/modules/admin/wiring/audit/admin.stats.ts +3 -0
- package/template/modules/admin/wiring/audit/admin.userSections.tsx +1 -0
- package/template/modules/admin/wiring/audit/readme.modules.md +4 -0
- package/template/modules/admin/wiring/audit/web.messages.ts +1 -0
- package/template/modules/admin/wiring/readme.modules.md +26 -0
- package/template/modules/admin/wiring/web.messages.ts +22 -0
- package/template/modules/admin/wiring/web.routes.imports.tsx +1 -0
- package/template/modules/admin/wiring/web.routes.tsx +2 -0
- package/template/modules/admin/wiring/web.styles.css +1 -0
- package/template/modules/admin/wiring/worker.imports.ts +1 -0
- package/template/modules/admin/wiring/worker.routes.ts +2 -0
- package/template/modules/admin/with-audit/e2e/admin-activity.spec.ts +37 -0
- package/template/modules/admin/with-audit/test/admin-audit.test.ts +37 -0
- package/template/modules/admin/with-audit/web/src/admin-activity.tsx +21 -0
- package/template/modules/audit/files/src/audit-actions.ts +20 -0
- package/template/modules/audit/files/src/audit-log.ts +33 -0
- package/template/modules/audit/files/test/audit.test.ts +47 -0
- package/template/modules/audit/module.json +36 -0
- package/template/modules/audit/wiring/audit.imports.ts +1 -0
- package/template/modules/audit/wiring/audit.sink.ts +2 -0
- package/template/modules/audit/wiring/auth/audit.catalogues.imports.ts +1 -0
- package/template/modules/audit/wiring/auth/audit.catalogues.ts +1 -0
- package/template/modules/audit/wiring/auth/readme.modules.md +7 -0
- package/template/modules/audit/wiring/auth/web.messages.ts +26 -0
- package/template/modules/audit/wiring/auth/web.routes.imports.tsx +1 -0
- package/template/modules/audit/wiring/auth/web.routes.tsx +1 -0
- package/template/modules/audit/wiring/auth/worker.imports.ts +1 -0
- package/template/modules/audit/wiring/auth/worker.routes.ts +2 -0
- package/template/modules/audit/wiring/d1.jsonc +8 -0
- package/template/modules/audit/wiring/env.bindings.ts +4 -0
- package/template/modules/audit/wiring/index.imports.ts +1 -0
- package/template/modules/audit/wiring/index.setup.ts +2 -0
- package/template/modules/audit/wiring/readme.modules.md +16 -0
- package/template/modules/audit/wiring/test.env.ts +1 -0
- package/template/modules/audit/wiring/test.migrate.ts +1 -0
- package/template/modules/audit/wiring/vars.deployed.jsonc +2 -0
- package/template/modules/audit/wiring/vars.dev.jsonc +2 -0
- package/template/modules/audit/wiring/vitest.bindings.ts +1 -0
- package/template/modules/audit/with-auth/e2e/my-activity.spec.ts +89 -0
- package/template/modules/audit/with-auth/src/audit-routes.ts +21 -0
- package/template/modules/audit/with-auth/test/audit-routes.test.ts +17 -0
- package/template/modules/audit/with-auth/web/src/my-activity.tsx +168 -0
- package/template/modules/auth/files/e2e/auth-accessibility.spec.ts +68 -0
- package/template/modules/auth/files/e2e/auth-bootstrap-admin.spec.ts +58 -0
- package/template/modules/auth/files/e2e/auth-helpers.ts +74 -0
- package/template/modules/auth/files/e2e/auth-invitations.spec.ts +51 -0
- package/template/modules/auth/files/e2e/auth-phone.spec.ts +41 -0
- package/template/modules/auth/files/e2e/auth-seeded.ts +97 -0
- package/template/modules/auth/files/e2e/auth-sign-in.spec.ts +57 -0
- package/template/modules/auth/files/e2e/auth-two-factor.spec.ts +44 -0
- package/template/modules/auth/files/scripts/seed-user.mjs +64 -0
- package/template/modules/auth/files/src/auth-choices.ts +33 -0
- package/template/modules/auth/files/src/auth-config.ts +55 -0
- package/template/modules/auth/files/src/auth.ts +68 -0
- package/template/modules/auth/files/src/local-test-mode.ts +49 -0
- package/template/modules/auth/files/src/notifications.ts +94 -0
- package/template/modules/auth/files/test/auth-fixtures.ts +135 -0
- package/template/modules/auth/files/test/auth.test.ts +210 -0
- package/template/modules/auth/files/test/totp.ts +49 -0
- package/template/modules/auth/files/test/turnstile-fake.ts +42 -0
- package/template/modules/auth/files/web/src/account-menu.tsx +25 -0
- package/template/modules/auth/files/web/src/account.tsx +87 -0
- package/template/modules/auth/files/web/src/auth-client.ts +60 -0
- package/template/modules/auth/files/web/src/auth-pages.tsx +152 -0
- package/template/modules/auth/module.json +56 -0
- package/template/modules/auth/wiring/bindings.deployed.jsonc +2 -0
- package/template/modules/auth/wiring/d1.jsonc +7 -0
- package/template/modules/auth/wiring/devvars.txt +12 -0
- package/template/modules/auth/wiring/env.bindings.ts +26 -0
- package/template/modules/auth/wiring/index.deps.ts +1 -0
- package/template/modules/auth/wiring/index.imports.ts +4 -0
- package/template/modules/auth/wiring/index.setup.ts +7 -0
- package/template/modules/auth/wiring/playwright.imports.ts +1 -0
- package/template/modules/auth/wiring/playwright.servers.ts +12 -0
- package/template/modules/auth/wiring/playwright.setup.ts +2 -0
- package/template/modules/auth/wiring/playwright.vars.ts +2 -0
- package/template/modules/auth/wiring/readme.modules.md +30 -0
- package/template/modules/auth/wiring/readme.provisioning.md +11 -0
- package/template/modules/auth/wiring/secrets.jsonc +2 -0
- package/template/modules/auth/wiring/test.env.ts +1 -0
- package/template/modules/auth/wiring/test.migrate.ts +1 -0
- package/template/modules/auth/wiring/vars.dev.jsonc +19 -0
- package/template/modules/auth/wiring/vars.production.jsonc +13 -0
- package/template/modules/auth/wiring/vars.staging.jsonc +13 -0
- package/template/modules/auth/wiring/vitest.bindings.ts +6 -0
- package/template/modules/auth/wiring/web.header.tsx +1 -0
- package/template/modules/auth/wiring/web.layout.imports.tsx +1 -0
- package/template/modules/auth/wiring/web.messages.ts +17 -0
- package/template/modules/auth/wiring/web.routes.imports.tsx +12 -0
- package/template/modules/auth/wiring/web.routes.tsx +11 -0
- package/template/modules/auth/wiring/web.styles.css +1 -0
- package/template/modules/auth/wiring/worker.deps.ts +2 -0
- package/template/modules/auth/wiring/worker.imports.ts +1 -0
- package/template/modules/auth/wiring/worker.routes.ts +2 -0
- package/template/modules/example/files/e2e/example-notes.spec.ts +39 -0
- package/template/modules/example/files/migrations/0002_example_notes.sql +12 -0
- package/template/modules/example/files/src/example-audit-actions.ts +12 -0
- package/template/modules/example/files/src/example-notes.ts +136 -0
- package/template/modules/example/files/test/example-notes-fixtures.ts +30 -0
- package/template/modules/example/files/test/example-notes.test.ts +52 -0
- package/template/modules/example/files/web/src/example-notes.tsx +176 -0
- package/template/modules/example/module.json +61 -0
- package/template/modules/example/wiring/access-control/access.permissions.ts +3 -0
- package/template/modules/example/wiring/access-control/access.roles.ts +2 -0
- package/template/modules/example/wiring/access-control/example.e2e.imports.ts +2 -0
- package/template/modules/example/wiring/access-control/example.e2e.setup.ts +18 -0
- package/template/modules/example/wiring/access-control/example.imports.ts +3 -0
- package/template/modules/example/wiring/access-control/example.policy.ts +10 -0
- package/template/modules/example/wiring/access-control/example.test.caller.ts +17 -0
- package/template/modules/example/wiring/access-control/example.test.imports.ts +1 -0
- package/template/modules/example/wiring/access-control/readme.modules.md +5 -0
- package/template/modules/example/wiring/admin/admin.imports.tsx +1 -0
- package/template/modules/example/wiring/admin/admin.nav.tsx +2 -0
- package/template/modules/example/wiring/admin/admin.pages.tsx +2 -0
- package/template/modules/example/wiring/admin/readme.modules.md +4 -0
- package/template/modules/example/wiring/admin/web.messages.ts +11 -0
- package/template/modules/example/wiring/admin/worker.imports.ts +1 -0
- package/template/modules/example/wiring/admin/worker.routes.ts +2 -0
- package/template/modules/example/wiring/audit/audit.catalogues.imports.ts +1 -0
- package/template/modules/example/wiring/audit/audit.catalogues.ts +2 -0
- package/template/modules/example/wiring/audit/readme.modules.md +4 -0
- package/template/modules/example/wiring/readme.modules.md +16 -0
- package/template/modules/example/wiring/web.messages.ts +19 -0
- package/template/modules/example/wiring/web.routes.imports.tsx +1 -0
- package/template/modules/example/wiring/web.routes.tsx +2 -0
- package/template/modules/example/wiring/worker.imports.ts +1 -0
- package/template/modules/example/wiring/worker.routes.ts +2 -0
- package/template/modules/example/with-access-control/test/example-notes-access.test.ts +51 -0
- package/template/modules/example/with-admin/e2e/example-notes-admin.spec.ts +55 -0
- package/template/modules/example/with-admin/src/example-notes-admin.ts +62 -0
- package/template/modules/example/with-admin/test/example-notes-admin.test.ts +42 -0
- package/template/modules/example/with-admin/web/src/admin-notes.tsx +99 -0
- package/template/modules/example/with-audit/test/example-notes-audit.test.ts +22 -0
- package/template/package.json +57 -0
- package/template/playwright.config.ts +38 -0
- package/template/renovate.json +12 -0
- package/template/src/app.ts +46 -0
- package/template/src/audit-catalogues.ts +9 -0
- package/template/src/audit-sink.ts +21 -0
- package/template/src/env.d.ts +21 -0
- package/template/src/index.ts +18 -0
- package/template/src/v1.ts +24 -0
- package/template/standalone/.github/workflows/ci.yml +21 -0
- package/template/standalone/.github/workflows/deploy.yml +32 -0
- package/template/standalone/.nvmrc +1 -0
- package/template/standalone/_gitignore +9 -0
- package/template/standalone/pnpm-workspace.yaml +11 -0
- package/template/template.json +56 -0
- package/template/test/env.d.ts +11 -0
- package/template/test/migrate.ts +11 -0
- package/template/test/migrations.test.ts +22 -0
- package/template/test/worker.test.ts +65 -0
- package/template/tsconfig.json +7 -0
- package/template/vitest.config.ts +24 -0
- package/template/web/index.html +12 -0
- package/template/web/src/app.tsx +44 -0
- package/template/web/src/home.tsx +51 -0
- package/template/web/src/layout.tsx +48 -0
- package/template/web/src/main.tsx +16 -0
- package/template/web/src/messages.ts +38 -0
- package/template/web/src/navigation.tsx +34 -0
- package/template/web/src/route-focus.ts +49 -0
- package/template/web/src/routes.tsx +38 -0
- package/template/web/src/styles.css +11 -0
- package/template/web/src/system-theme.ts +16 -0
- package/template/web/tsconfig.json +7 -0
- package/template/web/vite.config.ts +15 -0
- package/template/wrangler.jsonc +96 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
// src/ask.ts
|
|
9
|
+
/** A wrong answer given as a flag (or a missing one without a terminal). */
|
|
10
|
+
var AnswerError = class extends Error {};
|
|
11
|
+
// src/auth-answers.ts
|
|
12
|
+
const AUTH_MODULE = "auth";
|
|
13
|
+
const AUTH_METHODS = [
|
|
14
|
+
"email-password",
|
|
15
|
+
"phone-otp",
|
|
16
|
+
"totp"
|
|
17
|
+
];
|
|
18
|
+
/** Same values as `@awsmbiz/inbis-auth`'s `REGISTRATION_MODES`. */
|
|
19
|
+
const REGISTRATION_MODES = [
|
|
20
|
+
"open",
|
|
21
|
+
"invite-only",
|
|
22
|
+
"admin-only",
|
|
23
|
+
"closed"
|
|
24
|
+
];
|
|
25
|
+
const DEFAULT_METHODS = ["email-password", "totp"];
|
|
26
|
+
const DEFAULT_REGISTRATION = "invite-only";
|
|
27
|
+
const METHOD_LABELS = {
|
|
28
|
+
"email-password": "Email and password",
|
|
29
|
+
"phone-otp": "Phone number and SMS code",
|
|
30
|
+
totp: "Two-factor authentication (authenticator app)"
|
|
31
|
+
};
|
|
32
|
+
const MODE_HINTS = {
|
|
33
|
+
open: "anyone can sign up",
|
|
34
|
+
"invite-only": "administrators create accounts or send invitations",
|
|
35
|
+
"admin-only": "only administrators create accounts; no invitations",
|
|
36
|
+
closed: "no new accounts (except the first administrator)"
|
|
37
|
+
};
|
|
38
|
+
/** A calling-code prefix, as `@awsmbiz/inbis-auth` checks it: `+`, then 1-6 digits not starting with 0. */
|
|
39
|
+
const CALLING_CODE = /^\+[1-9]\d{0,5}$/;
|
|
40
|
+
/** A comma-separated list; `none` is the empty list. */
|
|
41
|
+
function parseList(value) {
|
|
42
|
+
return value.trim() === "none" ? [] : value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
function checkMethods(methods) {
|
|
45
|
+
for (const method of methods) if (!AUTH_METHODS.includes(method)) throw new AnswerError(`Unknown sign-in method "${method}". Methods: ${AUTH_METHODS.join(", ")}.`);
|
|
46
|
+
const chosen = methods;
|
|
47
|
+
if (!chosen.includes("email-password") && !chosen.includes("phone-otp")) throw new AnswerError("Choose at least one sign-in method: email-password and/or phone-otp.");
|
|
48
|
+
if (chosen.includes("totp") && !chosen.includes("email-password")) throw new AnswerError("totp (2FA) needs email-password: it completes password sign-ins.");
|
|
49
|
+
return AUTH_METHODS.filter((method) => chosen.includes(method));
|
|
50
|
+
}
|
|
51
|
+
function checkRegistration(mode, methods) {
|
|
52
|
+
if (!REGISTRATION_MODES.includes(mode)) throw new AnswerError(`Unknown registration mode "${mode}". Modes: ${REGISTRATION_MODES.join(", ")}.`);
|
|
53
|
+
if (mode !== "open" && !(methods.includes("email-password") && methods.includes("totp"))) throw new AnswerError(`Registration "${mode}" needs an administrator to create accounts, and administrators sign in with email-password and totp (2FA). Add both methods or choose "open".`);
|
|
54
|
+
return mode;
|
|
55
|
+
}
|
|
56
|
+
/** Why a phone country list is wrong, or `undefined`. */
|
|
57
|
+
function listError(list) {
|
|
58
|
+
const bad = list.find((prefix) => !CALLING_CODE.test(prefix));
|
|
59
|
+
return bad === void 0 ? void 0 : `"${bad}" is not a calling-code prefix such as +44 or +1876.`;
|
|
60
|
+
}
|
|
61
|
+
function checkPhone(allowed, blocked) {
|
|
62
|
+
const error = listError(allowed) ?? listError(blocked);
|
|
63
|
+
if (error !== void 0) throw new AnswerError(error);
|
|
64
|
+
const both = allowed.find((prefix) => blocked.includes(prefix));
|
|
65
|
+
if (both !== void 0) throw new AnswerError(`"${both}" is both allowed and blocked.`);
|
|
66
|
+
return {
|
|
67
|
+
allowed,
|
|
68
|
+
blocked
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Rejects auth flags given without the auth module (before anything is asked). */
|
|
72
|
+
function checkAuthFlagsWithoutModule(given) {
|
|
73
|
+
if (given.methods !== void 0) throw new AnswerError("--auth-methods needs the auth module (--modules auth).");
|
|
74
|
+
if (given.registration !== void 0) throw new AnswerError("--registration needs the auth module (--modules auth).");
|
|
75
|
+
if (given.phoneAllow !== void 0 || given.phoneBlock !== void 0) throw new AnswerError("--phone-allow and --phone-block need the auth module (--modules auth).");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Turns the auth flags into answers: flags are validated and used; missing
|
|
79
|
+
* answers are asked for when `ask` is given, otherwise defaulted, except the
|
|
80
|
+
* phone country lists, which have no default.
|
|
81
|
+
*/
|
|
82
|
+
async function resolveAuthAnswers(given, ask, modules = [AUTH_MODULE]) {
|
|
83
|
+
const methodsFor = (answer) => {
|
|
84
|
+
const methods = checkMethods(answer);
|
|
85
|
+
if (modules.includes("admin") && !(methods.includes("email-password") && methods.includes("totp"))) throw new AnswerError("The admin module needs administrators, who sign in with email-password and totp (2FA). Add both methods.");
|
|
86
|
+
return methods;
|
|
87
|
+
};
|
|
88
|
+
let methods;
|
|
89
|
+
if (given.methods !== void 0) methods = methodsFor(given.methods);
|
|
90
|
+
else if (ask === void 0) methods = DEFAULT_METHODS;
|
|
91
|
+
else methods = await askUntilValid(ask, () => ask.multiselect({
|
|
92
|
+
message: "Sign-in methods",
|
|
93
|
+
options: AUTH_METHODS.map((value) => ({
|
|
94
|
+
value,
|
|
95
|
+
label: METHOD_LABELS[value],
|
|
96
|
+
...DEFAULT_METHODS.includes(value) ? { hint: "default" } : {}
|
|
97
|
+
}))
|
|
98
|
+
}), methodsFor);
|
|
99
|
+
let registration;
|
|
100
|
+
if (given.registration !== void 0) registration = checkRegistration(given.registration, methods);
|
|
101
|
+
else if (ask === void 0) registration = checkRegistration(DEFAULT_REGISTRATION, methods);
|
|
102
|
+
else registration = await askUntilValid(ask, () => ask.select({
|
|
103
|
+
message: "Who may create an account? (registration mode)",
|
|
104
|
+
options: REGISTRATION_MODES.map((value) => ({
|
|
105
|
+
value,
|
|
106
|
+
label: value,
|
|
107
|
+
hint: MODE_HINTS[value]
|
|
108
|
+
})),
|
|
109
|
+
initialValue: methods.includes("totp") ? DEFAULT_REGISTRATION : "open"
|
|
110
|
+
}), (mode) => checkRegistration(mode, methods));
|
|
111
|
+
if (!methods.includes("phone-otp")) {
|
|
112
|
+
if (given.phoneAllow !== void 0 || given.phoneBlock !== void 0) throw new AnswerError("--phone-allow and --phone-block need phone-otp in --auth-methods.");
|
|
113
|
+
return {
|
|
114
|
+
methods,
|
|
115
|
+
registration
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (ask === void 0 && (given.phoneAllow === void 0 || given.phoneBlock === void 0)) throw new AnswerError("Phone OTP needs --phone-allow and --phone-block: the calling-code prefixes that may and may not receive codes (e.g. --phone-allow +44,+1 --phone-block none). There is no default.");
|
|
119
|
+
const askList = (message) => ask === void 0 ? Promise.resolve([]) : ask.text({
|
|
120
|
+
message,
|
|
121
|
+
initialValue: "",
|
|
122
|
+
validate: (value) => value.trim() === "" ? "Give a comma-separated list of prefixes, or \"none\"." : listError(parseList(value))
|
|
123
|
+
}).then(parseList);
|
|
124
|
+
const allowed = given.phoneAllow ?? await askList("Phone countries that may receive codes (calling-code prefixes, e.g. +44,+1; \"none\" = every country)");
|
|
125
|
+
const blocked = given.phoneBlock ?? await askList("Phone countries that never receive codes (e.g. +1876; \"none\" = nothing blocked)");
|
|
126
|
+
return {
|
|
127
|
+
methods,
|
|
128
|
+
registration,
|
|
129
|
+
phone: checkPhone(allowed, blocked)
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Asks again after an answer the rules reject, showing why. */
|
|
133
|
+
async function askUntilValid(ask, question, rule) {
|
|
134
|
+
for (;;) {
|
|
135
|
+
const answer = await question();
|
|
136
|
+
try {
|
|
137
|
+
return rule(answer);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!(error instanceof AnswerError)) throw error;
|
|
140
|
+
ask.info(error.message);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const list = (items) => `[${items.map((item) => JSON.stringify(item)).join(", ")}]`;
|
|
145
|
+
/** The app's src/auth-choices.ts for these answers. */
|
|
146
|
+
function renderAuthChoices(answers) {
|
|
147
|
+
const phone = answers.phone;
|
|
148
|
+
const phoneOtp = phone === void 0 ? "false" : `{ ${[...phone.allowed.length === 0 ? [] : [`allowedCountries: ${list(phone.allowed)}`], `blockedCountries: ${list(phone.blocked)}`].join(", ")} }`;
|
|
149
|
+
return `${AUTH_CHOICES_HEADER}
|
|
150
|
+
export const AUTH_CHOICES: AuthChoices = {
|
|
151
|
+
emailPassword: ${String(answers.methods.includes("email-password"))},
|
|
152
|
+
phoneOtp: ${phoneOtp},
|
|
153
|
+
twoFactor: ${String(answers.methods.includes("totp"))},
|
|
154
|
+
registration: ${JSON.stringify(answers.registration)},
|
|
155
|
+
};
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
/** Everything above the `AUTH_CHOICES` constant; the same text as the module's own file. */
|
|
159
|
+
const AUTH_CHOICES_HEADER = `import type { RegistrationMode } from "@awsmbiz/inbis-auth";
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The sign-in choices made when this app was created (create-inbis-app). They are
|
|
163
|
+
* yours to change: src/auth-config.ts turns them into the auth config, and the
|
|
164
|
+
* tests read them to know which journeys apply.
|
|
165
|
+
*/
|
|
166
|
+
export interface AuthChoices {
|
|
167
|
+
/** Email + password sign-in (and password reset, email verification). */
|
|
168
|
+
emailPassword: boolean;
|
|
169
|
+
/**
|
|
170
|
+
* Sign-in with an SMS code, or \`false\`. Countries are calling-code prefixes
|
|
171
|
+
* (\`"+44"\`, \`"+1876"\`): absent \`allowedCountries\` means every country;
|
|
172
|
+
* \`blockedCountries\` wins over it. There is no platform default.
|
|
173
|
+
*/
|
|
174
|
+
phoneOtp: false | { allowedCountries?: readonly string[]; blockedCountries: readonly string[] };
|
|
175
|
+
/** Two-factor authentication with an authenticator app (password sign-ins). Administrators need it. */
|
|
176
|
+
twoFactor: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* Who may create an account: \`open\` (anyone signs up), \`invite-only\`
|
|
179
|
+
* (administrators create accounts or invite), \`admin-only\` (administrators
|
|
180
|
+
* create accounts; invitations are refused), \`closed\` (no new accounts but
|
|
181
|
+
* the first administrator).
|
|
182
|
+
*/
|
|
183
|
+
registration: RegistrationMode;
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
/** Writes the answers into the generated app. */
|
|
187
|
+
function writeAuthChoices(outDir, answers) {
|
|
188
|
+
writeFileSync(join(outDir, "src", "auth-choices.ts"), renderAuthChoices(answers));
|
|
189
|
+
}
|
|
190
|
+
// src/template-modules.ts
|
|
191
|
+
/** A wiring marker: `inbis:wire <point> [key=value ...]` inside any comment. */
|
|
192
|
+
const MARKER = /inbis:wire\s+([\w.-]+)((?:[ \t]+[\w-]+=[^\s*>]+)*)/;
|
|
193
|
+
/** Reads `templateDir/template.json`. */
|
|
194
|
+
function loadTemplate(templateDir) {
|
|
195
|
+
return JSON.parse(readFileSync(join(templateDir, "template.json"), "utf8"));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Reads every `<name>/module.json` under `root` (the template's `modules/`),
|
|
199
|
+
* checking that each module is ordered after the modules it requires (so
|
|
200
|
+
* manifest order is a dependency order).
|
|
201
|
+
*/
|
|
202
|
+
function loadModules(root) {
|
|
203
|
+
if (!existsSync(root)) return [];
|
|
204
|
+
const manifests = readdirSync(root).filter((name) => existsSync(join(root, name, "module.json"))).map((name) => {
|
|
205
|
+
const manifest = JSON.parse(readFileSync(join(root, name, "module.json"), "utf8"));
|
|
206
|
+
if (manifest.name !== name) throw new Error(`modules/${name}/module.json is named "${manifest.name}"; it must match its directory.`);
|
|
207
|
+
return {
|
|
208
|
+
...manifest,
|
|
209
|
+
dir: join(root, name)
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
const byName = new Map(manifests.map((m) => [m.name, m]));
|
|
213
|
+
for (const m of manifests) for (const required of m.requires ?? []) {
|
|
214
|
+
const dep = byName.get(required);
|
|
215
|
+
if (dep === void 0) throw new Error(`Module "${m.name}" requires unknown module "${required}".`);
|
|
216
|
+
if (dep.order >= m.order) throw new Error(`Module "${m.name}" (order ${m.order}) requires "${required}" (order ${dep.order}); it must come later.`);
|
|
217
|
+
}
|
|
218
|
+
return manifests;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Adds the modules the selection requires (transitively) and orders the result
|
|
222
|
+
* by manifest `order`. `added` lists each module that was not asked for, with
|
|
223
|
+
* the module that first required it.
|
|
224
|
+
*/
|
|
225
|
+
function resolveModules(manifests, selected) {
|
|
226
|
+
const byName = new Map(manifests.map((m) => [m.name, m]));
|
|
227
|
+
const chosen = /* @__PURE__ */ new Set();
|
|
228
|
+
const added = [];
|
|
229
|
+
const visit = (name, requiredBy) => {
|
|
230
|
+
const manifest = byName.get(name);
|
|
231
|
+
if (manifest === void 0) {
|
|
232
|
+
const known = [...byName.keys()].sort().join(", ");
|
|
233
|
+
throw new Error(`Unknown module "${name}". Known modules: ${known === "" ? "(none)" : known}.`);
|
|
234
|
+
}
|
|
235
|
+
if (chosen.has(name)) return;
|
|
236
|
+
chosen.add(name);
|
|
237
|
+
if (requiredBy !== void 0 && !selected.includes(name)) added.push({
|
|
238
|
+
name,
|
|
239
|
+
requiredBy
|
|
240
|
+
});
|
|
241
|
+
for (const required of manifest.requires ?? []) visit(required, name);
|
|
242
|
+
};
|
|
243
|
+
for (const name of selected) visit(name, void 0);
|
|
244
|
+
const order = (name) => byName.get(name)?.order ?? 0;
|
|
245
|
+
return {
|
|
246
|
+
modules: [...chosen].sort((a, b) => order(a) - order(b) || a.localeCompare(b)),
|
|
247
|
+
added
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Every file under `dir`, as paths relative to it with `/` separators. Paths in
|
|
252
|
+
* `skip` (files or whole directories) are left out without being read.
|
|
253
|
+
*/
|
|
254
|
+
function listFiles(dir, skip = /* @__PURE__ */ new Set()) {
|
|
255
|
+
const files = [];
|
|
256
|
+
const walk = (current) => {
|
|
257
|
+
for (const entry of readdirSync(current)) {
|
|
258
|
+
const path = join(current, entry);
|
|
259
|
+
const rel = relative(dir, path).split(sep).join("/");
|
|
260
|
+
if (skip.has(rel)) continue;
|
|
261
|
+
if (statSync(path).isDirectory()) walk(path);
|
|
262
|
+
else files.push(rel);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
if (existsSync(dir)) walk(dir);
|
|
266
|
+
return files.sort();
|
|
267
|
+
}
|
|
268
|
+
/** Copies `from/**` into `to`, refusing to overwrite (a module may only add files). */
|
|
269
|
+
function addFiles(from, to, owner) {
|
|
270
|
+
for (const file of listFiles(from)) {
|
|
271
|
+
const target = join(to, file);
|
|
272
|
+
if (existsSync(target)) throw new Error(`Module "${owner}" adds ${file}, which already exists.`);
|
|
273
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
274
|
+
cpSync(join(from, file), target);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Merges `extra` into `target`, refusing a key that is already set to a different value. */
|
|
278
|
+
function mergeDeps(target, extra, field, owner) {
|
|
279
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
280
|
+
const current = target[name];
|
|
281
|
+
if (current !== void 0 && current !== value) throw new Error(`Module "${owner}" conflicts on ${field} "${name}": already ${current}, the module wants ${value}.`);
|
|
282
|
+
target[name] = value;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const sortKeys = (object) => Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
|
|
286
|
+
/** Parses a marker's `key=value` arguments. */
|
|
287
|
+
function markerArgs(text) {
|
|
288
|
+
return Object.fromEntries(text.trim().split(/\s+/).filter(Boolean).map((pair) => pair.split("=", 2)));
|
|
289
|
+
}
|
|
290
|
+
/** Snippet lines with their common indentation removed (trailing blank lines dropped). */
|
|
291
|
+
function dedent(text) {
|
|
292
|
+
const lines = text.replace(/\s+$/, "").split("\n");
|
|
293
|
+
const indents = lines.filter((line) => line.trim() !== "").map((line) => /^[ \t]*/.exec(line)?.[0].length ?? 0);
|
|
294
|
+
const common = Math.min(...indents, Infinity);
|
|
295
|
+
return lines.map((line) => line.trim() === "" ? "" : line.slice(common === Infinity ? 0 : common));
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Copies the template's base to `outDir` and applies `modules` (plus the ones
|
|
299
|
+
* they require). Throws on an unknown module or wiring point, a missing
|
|
300
|
+
* marker, a file clash or a conflicting dependency (the output directory is
|
|
301
|
+
* then incomplete: use a fresh one).
|
|
302
|
+
*/
|
|
303
|
+
function applyModules({ templateDir, outDir, modules: selected, modulesDir = join(templateDir, "modules"), standalone = false, stripMarkers = false }) {
|
|
304
|
+
const template = loadTemplate(templateDir);
|
|
305
|
+
const manifests = loadModules(modulesDir);
|
|
306
|
+
const { modules, added } = resolveModules(manifests, selected);
|
|
307
|
+
const byName = new Map(manifests.map((m) => [m.name, m]));
|
|
308
|
+
const parts = [];
|
|
309
|
+
const chosen = modules.flatMap((name) => byName.get(name) ?? []);
|
|
310
|
+
for (const manifest of chosen) {
|
|
311
|
+
const { name } = manifest;
|
|
312
|
+
const dir = manifest.dir ?? join(modulesDir, name);
|
|
313
|
+
parts.push({
|
|
314
|
+
owner: name,
|
|
315
|
+
dir,
|
|
316
|
+
part: {
|
|
317
|
+
...manifest,
|
|
318
|
+
files: manifest.files ?? "files"
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
for (const extra of manifest.when ?? []) if (extra.modules.every((m) => modules.includes(m))) parts.push({
|
|
322
|
+
owner: name,
|
|
323
|
+
dir,
|
|
324
|
+
part: extra
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
const points = { ...template.wiring };
|
|
328
|
+
for (const manifest of chosen) for (const [point, declaration] of Object.entries(manifest.points ?? {})) {
|
|
329
|
+
if (points[point] !== void 0) throw new Error(`Module "${manifest.name}" declares wiring point "${point}", which is already declared.`);
|
|
330
|
+
points[point] = declaration;
|
|
331
|
+
}
|
|
332
|
+
for (const { owner, part } of parts) for (const point of Object.keys(part.wiring ?? {})) if (points[point] === void 0) throw new Error(`Module "${owner}" contributes to unknown wiring point "${point}".`);
|
|
333
|
+
const exclude = /* @__PURE__ */ new Set([
|
|
334
|
+
...template.exclude ?? [],
|
|
335
|
+
"template.json",
|
|
336
|
+
"modules",
|
|
337
|
+
"node_modules"
|
|
338
|
+
]);
|
|
339
|
+
if (template.standalone !== void 0) exclude.add(template.standalone);
|
|
340
|
+
mkdirSync(outDir, { recursive: true });
|
|
341
|
+
for (const file of listFiles(templateDir, exclude)) {
|
|
342
|
+
mkdirSync(dirname(join(outDir, file)), { recursive: true });
|
|
343
|
+
cpSync(join(templateDir, file), join(outDir, file));
|
|
344
|
+
}
|
|
345
|
+
if (standalone && template.standalone !== void 0) addFiles(join(templateDir, template.standalone), outDir, "(standalone)");
|
|
346
|
+
const pkgPath = join(outDir, "package.json");
|
|
347
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
348
|
+
for (const { owner, dir, part } of parts) {
|
|
349
|
+
if (part.files !== void 0) addFiles(join(dir, part.files), outDir, owner);
|
|
350
|
+
for (const field of [
|
|
351
|
+
"dependencies",
|
|
352
|
+
"devDependencies",
|
|
353
|
+
"scripts"
|
|
354
|
+
]) {
|
|
355
|
+
const extra = part[field];
|
|
356
|
+
if (extra === void 0) continue;
|
|
357
|
+
const merged = { ...pkg[field] };
|
|
358
|
+
mergeDeps(merged, extra, field, owner);
|
|
359
|
+
pkg[field] = sortKeys(merged);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
363
|
+
const files = new Set(Object.values(points).map((point) => point.file));
|
|
364
|
+
for (const file of files) {
|
|
365
|
+
const path = join(outDir, file);
|
|
366
|
+
if (!existsSync(path)) throw new Error(`Wiring file ${file} does not exist in the app.`);
|
|
367
|
+
const seen = /* @__PURE__ */ new Set();
|
|
368
|
+
const output = [];
|
|
369
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
370
|
+
const match = MARKER.exec(line);
|
|
371
|
+
if (match === null) {
|
|
372
|
+
output.push(line);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const point = match[1] ?? "";
|
|
376
|
+
if (points[point]?.file !== file) throw new Error(`${file} marks wiring point "${point}", which neither template.json nor a selected module declares for it.`);
|
|
377
|
+
seen.add(point);
|
|
378
|
+
const args = markerArgs(match[2] ?? "");
|
|
379
|
+
const indent = /^[ \t]*/.exec(line)?.[0] ?? "";
|
|
380
|
+
for (const { owner, dir, part } of parts) {
|
|
381
|
+
const contribution = part.wiring?.[point];
|
|
382
|
+
if (contribution === void 0) continue;
|
|
383
|
+
const snippet = typeof contribution === "string" ? contribution : contribution[args["env"] ?? ""];
|
|
384
|
+
if (snippet === void 0) continue;
|
|
385
|
+
const text = readFileSync(join(dir, snippet), "utf8").replace(/\{\{(\w+)\}\}/g, (placeholder, key) => {
|
|
386
|
+
const value = args[key];
|
|
387
|
+
if (value === void 0) throw new Error(`Module "${owner}" uses ${placeholder} in ${snippet}, which the "${point}" marker in ${file} does not supply.`);
|
|
388
|
+
return value;
|
|
389
|
+
});
|
|
390
|
+
for (const snippetLine of dedent(text)) output.push(snippetLine === "" ? "" : indent + snippetLine);
|
|
391
|
+
}
|
|
392
|
+
if (!stripMarkers) output.push(line);
|
|
393
|
+
}
|
|
394
|
+
for (const [point, { file: declared }] of Object.entries(points)) if (declared === file && !seen.has(point)) throw new Error(`Wiring point "${point}" has no marker in ${file}.`);
|
|
395
|
+
writeFileSync(path, output.join("\n"));
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
modules,
|
|
399
|
+
added,
|
|
400
|
+
manifests: chosen
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
// src/answers.ts
|
|
404
|
+
const WORKER_NAME_MAX = 52;
|
|
405
|
+
const validate = {
|
|
406
|
+
directory: (value, cwd = process.cwd()) => {
|
|
407
|
+
if (value.trim() === "") return "Give the directory to create the app in.";
|
|
408
|
+
const path = resolve(cwd, value);
|
|
409
|
+
if (existsSync(path) && readdirSync(path).length > 0) return `${path} is not empty.`;
|
|
410
|
+
},
|
|
411
|
+
appName: (value) => {
|
|
412
|
+
if (value.length > 214 || !/^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(value)) return `"${value}" is not a valid package name: use lowercase letters, digits, "-", "." or "_" (optionally @scope/name).`;
|
|
413
|
+
},
|
|
414
|
+
workerName: (value) => {
|
|
415
|
+
if (value.length > WORKER_NAME_MAX || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value)) return `"${value}" is not a valid Worker name: lowercase letters, digits and inner "-", at most ${WORKER_NAME_MAX} characters.`;
|
|
416
|
+
},
|
|
417
|
+
domain: (value) => {
|
|
418
|
+
if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/.test(value)) return `"${value}" is not a domain name (e.g. app.example.com).`;
|
|
419
|
+
},
|
|
420
|
+
binding: (value) => {
|
|
421
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(value)) return `"${value}" is not a binding name: uppercase letters, digits and "_", starting with a letter.`;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
/** A Worker name derived from a package name (scope dropped, other characters replaced). */
|
|
425
|
+
function workerNameFor(appName) {
|
|
426
|
+
const name = (appName.split("/").pop() ?? appName).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, WORKER_NAME_MAX).replace(/-+$/, "");
|
|
427
|
+
return name === "" ? "app" : name;
|
|
428
|
+
}
|
|
429
|
+
function check(error) {
|
|
430
|
+
if (error !== void 0) throw new AnswerError(error);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Turns flags into answers: a flag is validated and used; a missing answer is
|
|
434
|
+
* asked for when `ask` is given (with the default filled in), otherwise
|
|
435
|
+
* defaulted, and the directory, which has no default, is an error. Paths are
|
|
436
|
+
* relative to `cwd`.
|
|
437
|
+
*/
|
|
438
|
+
async function resolveAnswers(given, offer, ask, cwd = process.cwd()) {
|
|
439
|
+
const answer = async (value, fallback, message, rule) => {
|
|
440
|
+
if (value !== void 0) {
|
|
441
|
+
check(rule(value));
|
|
442
|
+
return value;
|
|
443
|
+
}
|
|
444
|
+
if (ask === void 0) {
|
|
445
|
+
check(rule(fallback));
|
|
446
|
+
return fallback;
|
|
447
|
+
}
|
|
448
|
+
return ask.text({
|
|
449
|
+
message,
|
|
450
|
+
initialValue: fallback,
|
|
451
|
+
validate: rule
|
|
452
|
+
});
|
|
453
|
+
};
|
|
454
|
+
if (given.directory === void 0 && ask === void 0) throw new AnswerError("Give the directory to create the app in: create-inbis-app <directory> (see --help).");
|
|
455
|
+
const inCwd = (value) => validate.directory(value, cwd);
|
|
456
|
+
const directory = resolve(cwd, await answer(given.directory, "my-inbis-app", "Directory for the new app", inCwd));
|
|
457
|
+
const appName = await answer(given.appName, basename(directory), "App name (package.json)", validate.appName);
|
|
458
|
+
const workerName = await answer(given.workerName, workerNameFor(appName), "Cloudflare Worker name", validate.workerName);
|
|
459
|
+
const staging = await answer(given.stagingDomain, `staging.${workerName}.example.com`, "Staging domain", validate.domain);
|
|
460
|
+
const production = await answer(given.productionDomain, `${workerName}.example.com`, "Production domain", validate.domain);
|
|
461
|
+
const modules = await chooseModules(given.modules, offer.modules, ask);
|
|
462
|
+
if (!modules.includes("auth")) checkAuthFlagsWithoutModule(given.auth ?? {});
|
|
463
|
+
const auth = modules.includes("auth") ? await resolveAuthAnswers(given.auth ?? {}, ask, modules) : void 0;
|
|
464
|
+
const bindings = await nameBindings(given.bindings ?? {}, offer, modules, ask);
|
|
465
|
+
const git = given.git ?? (ask === void 0 ? false : await ask.confirm({
|
|
466
|
+
message: "Initialise a git repository?",
|
|
467
|
+
initialValue: true
|
|
468
|
+
}));
|
|
469
|
+
return {
|
|
470
|
+
directory,
|
|
471
|
+
appName,
|
|
472
|
+
workerName,
|
|
473
|
+
domains: {
|
|
474
|
+
staging,
|
|
475
|
+
production
|
|
476
|
+
},
|
|
477
|
+
bindings,
|
|
478
|
+
modules,
|
|
479
|
+
...auth === void 0 ? {} : { auth },
|
|
480
|
+
git
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
async function chooseModules(given, offered, ask) {
|
|
484
|
+
let selected = given ?? [];
|
|
485
|
+
if (given === void 0 && ask !== void 0 && offered.length > 0) selected = await ask.multiselect({
|
|
486
|
+
message: "Modules (none gives the minimal app)",
|
|
487
|
+
options: offered.map((m) => ({
|
|
488
|
+
value: m.name,
|
|
489
|
+
label: m.name,
|
|
490
|
+
...m.description === void 0 ? {} : { hint: m.description }
|
|
491
|
+
}))
|
|
492
|
+
});
|
|
493
|
+
let resolved;
|
|
494
|
+
try {
|
|
495
|
+
resolved = resolveModules(offered, selected);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
throw new AnswerError(error.message);
|
|
498
|
+
}
|
|
499
|
+
for (const { name, requiredBy } of resolved.added) {
|
|
500
|
+
const message = `Added module "${name}": "${requiredBy}" requires it.`;
|
|
501
|
+
if (ask === void 0) console.log(message);
|
|
502
|
+
else ask.info(message);
|
|
503
|
+
}
|
|
504
|
+
return resolved.modules;
|
|
505
|
+
}
|
|
506
|
+
async function nameBindings(given, offer, modules, ask) {
|
|
507
|
+
const declared = { ...offer.bindings };
|
|
508
|
+
for (const m of offer.modules) if (modules.includes(m.name)) Object.assign(declared, m.bindings);
|
|
509
|
+
for (const name of Object.keys(given)) if (declared[name] === void 0) {
|
|
510
|
+
const known = Object.keys(declared).sort().join(", ");
|
|
511
|
+
throw new AnswerError(`There is no binding "${name}" to rename. Renameable bindings: ${known === "" ? "(none)" : known}.`);
|
|
512
|
+
}
|
|
513
|
+
const bindings = {};
|
|
514
|
+
for (const [name, description] of Object.entries(declared)) {
|
|
515
|
+
const value = given[name];
|
|
516
|
+
if (value !== void 0) check(validate.binding(value));
|
|
517
|
+
bindings[name] = value ?? (ask === void 0 ? name : await ask.text({
|
|
518
|
+
message: `Binding name for ${description}`,
|
|
519
|
+
initialValue: name,
|
|
520
|
+
validate: validate.binding
|
|
521
|
+
}));
|
|
522
|
+
}
|
|
523
|
+
const names = Object.values(bindings);
|
|
524
|
+
const twice = names.find((name, index) => names.indexOf(name) !== index);
|
|
525
|
+
if (twice !== void 0) throw new AnswerError(`Two bindings would be named "${twice}".`);
|
|
526
|
+
return bindings;
|
|
527
|
+
}
|
|
528
|
+
// src/args.ts
|
|
529
|
+
const USAGE = `Usage: pnpm create @awsmbiz/inbis-app [directory] [flags]
|
|
530
|
+
|
|
531
|
+
Creates a new Inbis app. Every question has a flag; questions without one are
|
|
532
|
+
asked in a terminal, or take their default with --yes (or without a terminal).
|
|
533
|
+
|
|
534
|
+
[directory] Directory to create (must be empty or missing)
|
|
535
|
+
--name <name> App (package.json) name. Default: the directory name
|
|
536
|
+
--worker <name> Cloudflare Worker name. Default: from the app name
|
|
537
|
+
--staging-domain <domain> Staging custom domain. Default: staging.<worker>.example.com
|
|
538
|
+
--production-domain <dom> Production custom domain. Default: <worker>.example.com
|
|
539
|
+
--binding <NAME=NEW> Rename a binding (repeatable), e.g. --binding DB=APP_DB
|
|
540
|
+
--modules <a,b|none> Modules to add; ones they require are added too
|
|
541
|
+
--auth-methods <a,b> auth: sign-in methods among email-password, phone-otp,
|
|
542
|
+
totp. Default: email-password,totp
|
|
543
|
+
--registration <mode> auth: who may create an account: open, invite-only,
|
|
544
|
+
admin-only, closed. Default: invite-only
|
|
545
|
+
--phone-allow <+44,..|none> auth with phone-otp: calling-code prefixes that may
|
|
546
|
+
receive codes (none: every country). No default
|
|
547
|
+
--phone-block <+1876,..|none>
|
|
548
|
+
auth with phone-otp: prefixes that never receive
|
|
549
|
+
codes (none: nothing blocked). No default
|
|
550
|
+
--git / --no-git Initialise a git repository. Default: ask; no with --yes
|
|
551
|
+
-y, --yes Do not prompt: use the defaults for missing answers
|
|
552
|
+
--platform-version <ver> Version to pin @awsmbiz packages to. Default: this
|
|
553
|
+
generator's version (the platform releases in lockstep)
|
|
554
|
+
--modules-dir <dir> Take modules from <dir> instead of the template's own
|
|
555
|
+
(platform tests)
|
|
556
|
+
-h, --help Show this help
|
|
557
|
+
-v, --version Show the version`;
|
|
558
|
+
/** Parses argv into flags; unknown flags and malformed values are an AnswerError. */
|
|
559
|
+
function parseFlags(argv) {
|
|
560
|
+
let parsed;
|
|
561
|
+
try {
|
|
562
|
+
parsed = parse(argv);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
throw new AnswerError(`${error.message}\n\n${USAGE}`);
|
|
565
|
+
}
|
|
566
|
+
const { values, positionals } = parsed;
|
|
567
|
+
if (positionals.length > 1) throw new AnswerError(`Give one directory, not ${positionals.length}: ${positionals.join(" ")}.`);
|
|
568
|
+
const given = {};
|
|
569
|
+
const set = (key, value) => {
|
|
570
|
+
if (value !== void 0) given[key] = value;
|
|
571
|
+
};
|
|
572
|
+
set("directory", positionals[0]);
|
|
573
|
+
set("appName", values.name);
|
|
574
|
+
set("workerName", values.worker);
|
|
575
|
+
set("stagingDomain", values["staging-domain"]?.toLowerCase());
|
|
576
|
+
set("productionDomain", values["production-domain"]?.toLowerCase());
|
|
577
|
+
set("git", values.git);
|
|
578
|
+
if (values.modules !== void 0) set("modules", values.modules.trim() === "none" ? [] : values.modules.split(",").map((m) => m.trim()).filter(Boolean));
|
|
579
|
+
const auth = {};
|
|
580
|
+
if (values["auth-methods"] !== void 0) auth.methods = parseList(values["auth-methods"]);
|
|
581
|
+
if (values.registration !== void 0) auth.registration = values.registration;
|
|
582
|
+
if (values["phone-allow"] !== void 0) auth.phoneAllow = parseList(values["phone-allow"]);
|
|
583
|
+
if (values["phone-block"] !== void 0) auth.phoneBlock = parseList(values["phone-block"]);
|
|
584
|
+
if (Object.keys(auth).length > 0) set("auth", auth);
|
|
585
|
+
if (values.binding !== void 0) {
|
|
586
|
+
const bindings = {};
|
|
587
|
+
for (const pair of values.binding) {
|
|
588
|
+
const at = pair.indexOf("=");
|
|
589
|
+
if (at < 1 || at === pair.length - 1) throw new AnswerError(`--binding takes NAME=NEW_NAME, got "${pair}".`);
|
|
590
|
+
bindings[pair.slice(0, at)] = pair.slice(at + 1);
|
|
591
|
+
}
|
|
592
|
+
set("bindings", bindings);
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
given,
|
|
596
|
+
yes: values.yes,
|
|
597
|
+
help: values.help,
|
|
598
|
+
version: values.version,
|
|
599
|
+
...values["platform-version"] === void 0 ? {} : { platformVersion: values["platform-version"] },
|
|
600
|
+
...values["modules-dir"] === void 0 ? {} : { modulesDir: values["modules-dir"] }
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function parse(argv) {
|
|
604
|
+
return parseArgs({
|
|
605
|
+
args: argv,
|
|
606
|
+
allowPositionals: true,
|
|
607
|
+
allowNegative: true,
|
|
608
|
+
strict: true,
|
|
609
|
+
options: {
|
|
610
|
+
name: { type: "string" },
|
|
611
|
+
worker: { type: "string" },
|
|
612
|
+
"staging-domain": { type: "string" },
|
|
613
|
+
"production-domain": { type: "string" },
|
|
614
|
+
binding: {
|
|
615
|
+
type: "string",
|
|
616
|
+
multiple: true
|
|
617
|
+
},
|
|
618
|
+
modules: { type: "string" },
|
|
619
|
+
"auth-methods": { type: "string" },
|
|
620
|
+
registration: { type: "string" },
|
|
621
|
+
"phone-allow": { type: "string" },
|
|
622
|
+
"phone-block": { type: "string" },
|
|
623
|
+
git: { type: "boolean" },
|
|
624
|
+
yes: {
|
|
625
|
+
type: "boolean",
|
|
626
|
+
short: "y",
|
|
627
|
+
default: false
|
|
628
|
+
},
|
|
629
|
+
"platform-version": { type: "string" },
|
|
630
|
+
"modules-dir": { type: "string" },
|
|
631
|
+
help: {
|
|
632
|
+
type: "boolean",
|
|
633
|
+
short: "h",
|
|
634
|
+
default: false
|
|
635
|
+
},
|
|
636
|
+
version: {
|
|
637
|
+
type: "boolean",
|
|
638
|
+
short: "v",
|
|
639
|
+
default: false
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// src/generate.ts
|
|
645
|
+
/** The template's own name, used for the app name, the Worker name and in resource names. */
|
|
646
|
+
const PLACEHOLDER = "react-hono-app";
|
|
647
|
+
function generate({ templateDir, modulesDir, answers, platformVersion }) {
|
|
648
|
+
const outDir = answers.directory;
|
|
649
|
+
const staged = stageTemplate(templateDir);
|
|
650
|
+
try {
|
|
651
|
+
applyModules({
|
|
652
|
+
templateDir: staged,
|
|
653
|
+
outDir,
|
|
654
|
+
modules: answers.modules,
|
|
655
|
+
...modulesDir === void 0 ? {} : { modulesDir },
|
|
656
|
+
standalone: true,
|
|
657
|
+
stripMarkers: true
|
|
658
|
+
});
|
|
659
|
+
} finally {
|
|
660
|
+
rmSync(staged, {
|
|
661
|
+
recursive: true,
|
|
662
|
+
force: true
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
if (answers.auth !== void 0) writeAuthChoices(outDir, answers.auth);
|
|
666
|
+
substitute(outDir, answers);
|
|
667
|
+
const pkgPath = join(outDir, "package.json");
|
|
668
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
669
|
+
pkg["name"] = answers.appName;
|
|
670
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
671
|
+
const deps = pkg[field] ?? {};
|
|
672
|
+
for (const [name, range] of Object.entries(deps)) if (range.startsWith("workspace:")) deps[name] = platformVersion;
|
|
673
|
+
}
|
|
674
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
675
|
+
if (answers.git) execFileSync("git", [
|
|
676
|
+
"init",
|
|
677
|
+
"--quiet",
|
|
678
|
+
"--initial-branch=main"
|
|
679
|
+
], {
|
|
680
|
+
cwd: outDir,
|
|
681
|
+
stdio: "ignore"
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
/** Dotfiles that `pnpm pack` drops; the package stores them as `_gitignore`, `_npmrc`, `_npmignore`. */
|
|
685
|
+
const STORED_DOTFILES = /* @__PURE__ */ new Set([
|
|
686
|
+
"_gitignore",
|
|
687
|
+
"_npmrc",
|
|
688
|
+
"_npmignore"
|
|
689
|
+
]);
|
|
690
|
+
/** Copies the shipped template to a temporary directory with its dotfiles' real names restored. */
|
|
691
|
+
function stageTemplate(templateDir) {
|
|
692
|
+
const staged = mkdtempSync(join(tmpdir(), "inbis-template-"));
|
|
693
|
+
for (const file of listFiles(templateDir)) {
|
|
694
|
+
const name = basename(file);
|
|
695
|
+
const target = join(staged, dirname(file), STORED_DOTFILES.has(name) ? `.${name.slice(1)}` : name);
|
|
696
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
697
|
+
cpSync(join(templateDir, file), target);
|
|
698
|
+
}
|
|
699
|
+
return staged;
|
|
700
|
+
}
|
|
701
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
702
|
+
/**
|
|
703
|
+
* Replaces, in every text file and in one pass (so a replacement is never
|
|
704
|
+
* replaced again): the template's staging and production domains, then its
|
|
705
|
+
* name (the Worker name: wrangler `name`, D1 database names, README commands),
|
|
706
|
+
* then each renamed binding as a whole word. The README's title becomes the app name.
|
|
707
|
+
*/
|
|
708
|
+
function substitute(outDir, answers) {
|
|
709
|
+
const replacements = /* @__PURE__ */ new Map([
|
|
710
|
+
[`staging.${PLACEHOLDER}.example.com`, answers.domains.staging],
|
|
711
|
+
[`${PLACEHOLDER}.example.com`, answers.domains.production],
|
|
712
|
+
[PLACEHOLDER, answers.workerName]
|
|
713
|
+
]);
|
|
714
|
+
const words = Object.entries(answers.bindings).filter(([from, to]) => from !== to);
|
|
715
|
+
const pattern = new RegExp([...[...replacements.keys()].map(escape), ...words.map(([from]) => `\\b${escape(from)}\\b`)].join("|"), "g");
|
|
716
|
+
const bindings = new Map(words);
|
|
717
|
+
for (const file of listFiles(outDir)) {
|
|
718
|
+
const path = join(outDir, file);
|
|
719
|
+
const buffer = readFileSync(path);
|
|
720
|
+
if (buffer.includes(0)) continue;
|
|
721
|
+
const before = buffer.toString("utf8");
|
|
722
|
+
let after = before.replace(pattern, (match) => replacements.get(match) ?? bindings.get(match) ?? match);
|
|
723
|
+
if (file === "README.md" && before.startsWith(`# react-hono-app\n`)) after = `# ${answers.appName}${after.slice(after.indexOf("\n"))}`;
|
|
724
|
+
if (after !== before) writeFileSync(path, after);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// src/paths.ts
|
|
728
|
+
/** The package root: dist/cli.mjs lives one level below it, next to template/. */
|
|
729
|
+
const packageRoot = join(import.meta.dirname, "..");
|
|
730
|
+
/** The app template shipped in the package (copied there by `pnpm build`). */
|
|
731
|
+
const templateDir = join(packageRoot, "template");
|
|
732
|
+
/** This generator's version, which is also the platform's: every package is released in lockstep. */
|
|
733
|
+
const ownVersion = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
|
|
734
|
+
// src/prompts.ts
|
|
735
|
+
var Cancelled = class extends Error {};
|
|
736
|
+
function answered(value) {
|
|
737
|
+
if (p.isCancel(value) || typeof value === "symbol") throw new Cancelled("Cancelled: nothing was created.");
|
|
738
|
+
return value;
|
|
739
|
+
}
|
|
740
|
+
function clackAsk(io) {
|
|
741
|
+
return {
|
|
742
|
+
async text({ message, initialValue, validate }) {
|
|
743
|
+
return answered(await p.text({
|
|
744
|
+
...io,
|
|
745
|
+
message,
|
|
746
|
+
initialValue,
|
|
747
|
+
validate: (value) => validate(value ?? "")
|
|
748
|
+
}));
|
|
749
|
+
},
|
|
750
|
+
async multiselect({ message, options }) {
|
|
751
|
+
return answered(await p.multiselect({
|
|
752
|
+
...io,
|
|
753
|
+
message,
|
|
754
|
+
options,
|
|
755
|
+
required: false
|
|
756
|
+
}));
|
|
757
|
+
},
|
|
758
|
+
async select({ message, options, initialValue }) {
|
|
759
|
+
return answered(await p.select({
|
|
760
|
+
...io,
|
|
761
|
+
message,
|
|
762
|
+
options,
|
|
763
|
+
initialValue
|
|
764
|
+
}));
|
|
765
|
+
},
|
|
766
|
+
async confirm({ message, initialValue }) {
|
|
767
|
+
return answered(await p.confirm({
|
|
768
|
+
...io,
|
|
769
|
+
message,
|
|
770
|
+
initialValue
|
|
771
|
+
}));
|
|
772
|
+
},
|
|
773
|
+
info(message) {
|
|
774
|
+
p.log.info(message, io);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
const intro = (title, io) => {
|
|
779
|
+
p.intro(title, io);
|
|
780
|
+
};
|
|
781
|
+
const outro = (message, io) => {
|
|
782
|
+
p.outro(message, io);
|
|
783
|
+
};
|
|
784
|
+
// src/main.ts
|
|
785
|
+
const defaultIo = {
|
|
786
|
+
stdin: process.stdin,
|
|
787
|
+
stdout: process.stdout,
|
|
788
|
+
stderr: process.stderr,
|
|
789
|
+
env: process.env,
|
|
790
|
+
cwd: process.cwd()
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* The CLI: parses flags, asks for what is missing (only in a terminal and
|
|
794
|
+
* without --yes), writes the app. Returns the exit code.
|
|
795
|
+
*/
|
|
796
|
+
async function main(argv, io = defaultIo) {
|
|
797
|
+
const say = (text) => io.stdout.write(`${text}\n`);
|
|
798
|
+
try {
|
|
799
|
+
const flags = parseFlags(argv);
|
|
800
|
+
if (flags.help) {
|
|
801
|
+
say(USAGE);
|
|
802
|
+
return 0;
|
|
803
|
+
}
|
|
804
|
+
if (flags.version) {
|
|
805
|
+
say(ownVersion);
|
|
806
|
+
return 0;
|
|
807
|
+
}
|
|
808
|
+
const ci = io.env["CI"] !== void 0 && io.env["CI"] !== "" && io.env["CI"] !== "false";
|
|
809
|
+
const interactive = !flags.yes && io.stdin.isTTY === true && !ci;
|
|
810
|
+
const prompts = {
|
|
811
|
+
input: io.stdin,
|
|
812
|
+
output: io.stdout
|
|
813
|
+
};
|
|
814
|
+
const modulesDir = flags.modulesDir === void 0 ? void 0 : resolve(io.cwd, flags.modulesDir);
|
|
815
|
+
const offer = {
|
|
816
|
+
bindings: loadTemplate(templateDir).bindings ?? {},
|
|
817
|
+
modules: loadModules(modulesDir ?? join(templateDir, "modules"))
|
|
818
|
+
};
|
|
819
|
+
if (interactive) intro("create-inbis-app", prompts);
|
|
820
|
+
const answers = await resolveAnswers(flags.given, offer, interactive ? clackAsk(prompts) : void 0, io.cwd);
|
|
821
|
+
generate({
|
|
822
|
+
templateDir,
|
|
823
|
+
...modulesDir === void 0 ? {} : { modulesDir },
|
|
824
|
+
answers,
|
|
825
|
+
platformVersion: flags.platformVersion ?? ownVersion
|
|
826
|
+
});
|
|
827
|
+
const where = relative(io.cwd, answers.directory) || ".";
|
|
828
|
+
const what = answers.modules.length === 0 ? "the minimal app" : `the app with ${answers.modules.join(", ")}`;
|
|
829
|
+
const next = [
|
|
830
|
+
`cd ${where}`,
|
|
831
|
+
"pnpm install",
|
|
832
|
+
"pnpm dev"
|
|
833
|
+
].map((step) => ` ${step}`).join("\n");
|
|
834
|
+
const summary = `Created ${what} in ${where} (platform ${flags.platformVersion ?? ownVersion}).\nNext steps (see README.md):\n${next}`;
|
|
835
|
+
if (interactive) outro(summary, prompts);
|
|
836
|
+
else say(summary);
|
|
837
|
+
return 0;
|
|
838
|
+
} catch (error) {
|
|
839
|
+
if (error instanceof AnswerError || error instanceof Cancelled) {
|
|
840
|
+
io.stderr.write(`${error.message}\n`);
|
|
841
|
+
return 1;
|
|
842
|
+
}
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// src/cli.ts
|
|
847
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
848
|
+
export {};
|
|
849
|
+
|
|
850
|
+
//# sourceMappingURL=cli.mjs.map
|