@flaredev/cli 0.1.1 → 0.2.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 +47 -47
- package/dist/index.js +373 -169
- package/package.json +3 -2
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +38 -0
- package/templates/app/app/dashboard/layout.tsx +39 -0
- package/templates/app/app/dashboard/page.tsx +42 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/components/account/security-settings.tsx +525 -0
- package/templates/app/components/admin/fields/country-field.tsx +152 -0
- package/templates/app/components/admin/fields/field-widget.tsx +186 -128
- package/templates/app/components/admin/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/admin/fields/phone-field.tsx +86 -0
- package/templates/app/components/admin/resource-nav.tsx +120 -109
- package/templates/app/components/admin/resource-table.tsx +31 -1
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/password-forms.tsx +79 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +315 -0
- package/templates/app/components/auth/sign-up-form.tsx +101 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +124 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/dashboard/dashboard-nav.tsx +35 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/lib/admin-nav.ts +1 -1
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +33 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +81 -6
- package/templates/app/lib/site.ts +47 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +32 -1
- package/templates/app/migrations/meta/0000_snapshot.json +215 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/components/auth-form.tsx +0 -120
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import pc19 from "picocolors";
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
5
|
import { cac } from "cac";
|
|
@@ -14,34 +14,85 @@ import { FLARE_VERSION } from "@flaredev/core";
|
|
|
14
14
|
|
|
15
15
|
// src/auth-providers.ts
|
|
16
16
|
var AUTH_PROVIDERS = {
|
|
17
|
-
google: { envPrefix: "GOOGLE", console: "https://console.cloud.google.com/apis/credentials" },
|
|
18
|
-
github: { envPrefix: "GITHUB", console: "https://github.com/settings/developers" }
|
|
17
|
+
google: { label: "Google", envPrefix: "GOOGLE", console: "https://console.cloud.google.com/apis/credentials", extra: [] },
|
|
18
|
+
github: { label: "GitHub", envPrefix: "GITHUB", console: "https://github.com/settings/developers", extra: [] },
|
|
19
|
+
apple: {
|
|
20
|
+
label: "Apple",
|
|
21
|
+
envPrefix: "APPLE",
|
|
22
|
+
console: "https://developer.apple.com/account/resources/identifiers/list/serviceId",
|
|
23
|
+
// The client secret is a JWT signed with your key; the bundle id is only for native apps.
|
|
24
|
+
extra: ["# APPLE_CLIENT_SECRET is a JWT you generate from your Sign in with Apple key (see the auth guide).", "APPLE_APP_BUNDLE_ID="]
|
|
25
|
+
},
|
|
26
|
+
microsoft: {
|
|
27
|
+
label: "Microsoft",
|
|
28
|
+
envPrefix: "MICROSOFT",
|
|
29
|
+
console: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
|
|
30
|
+
extra: ['# "common" (the default) accepts personal and work accounts; a tenant id limits sign-in to one organisation.', "MICROSOFT_TENANT_ID="]
|
|
31
|
+
}
|
|
19
32
|
};
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
var AUTH_METHODS = {
|
|
34
|
+
"magic-link": "Magic links (a sign-in link by email)",
|
|
35
|
+
"email-otp": "Email codes (a 6-digit sign-in code)",
|
|
36
|
+
passkeys: "Passkeys (Face ID, Touch ID, Windows Hello)",
|
|
37
|
+
"2fa-app": "Two-factor with an authenticator app, plus backup codes",
|
|
38
|
+
"2fa-email": "Two-factor with codes by email"
|
|
39
|
+
};
|
|
40
|
+
var DEFAULT_AUTH_METHODS = ["magic-link", "email-otp", "passkeys", "2fa-app", "2fa-email"];
|
|
41
|
+
function parseList(input, known, what) {
|
|
22
42
|
const names = input.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
|
|
23
|
-
const unknown = names.filter((name) => !(name
|
|
43
|
+
const unknown = names.filter((name) => !known.includes(name));
|
|
24
44
|
if (unknown.length > 0) {
|
|
25
|
-
throw new Error(
|
|
26
|
-
`Unknown auth provider${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Supported: ${Object.keys(AUTH_PROVIDERS).join(", ")}.`
|
|
27
|
-
);
|
|
45
|
+
throw new Error(`Unknown ${what}${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Supported: ${known.join(", ")}.`);
|
|
28
46
|
}
|
|
29
47
|
return [...new Set(names)];
|
|
30
48
|
}
|
|
31
|
-
function
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return [
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
function parseAuthProviders(input) {
|
|
50
|
+
if (!input) return [];
|
|
51
|
+
return parseList(input, Object.keys(AUTH_PROVIDERS), "auth provider");
|
|
52
|
+
}
|
|
53
|
+
function parseAuthMethods(input) {
|
|
54
|
+
if (input === void 0) return DEFAULT_AUTH_METHODS;
|
|
55
|
+
const value = input.trim().toLowerCase();
|
|
56
|
+
if (value === "all") return [...DEFAULT_AUTH_METHODS];
|
|
57
|
+
if (value === "none" || value === "") return [];
|
|
58
|
+
return parseList(value, Object.keys(AUTH_METHODS), "sign-in method");
|
|
59
|
+
}
|
|
60
|
+
function renderAuthConfig(methods, providers) {
|
|
61
|
+
const on = (method) => methods.includes(method);
|
|
62
|
+
return `/**
|
|
63
|
+
* How people sign in to this app. \`flare create\` wrote the choices you made; change
|
|
64
|
+
* them here any time. Every method's tables already exist, so switching one on or off
|
|
65
|
+
* needs no migration, and a method that's off is refused by the API, not just hidden.
|
|
66
|
+
*
|
|
67
|
+
* Email and password sign-in is always available.
|
|
68
|
+
*/
|
|
69
|
+
export const authConfig = {
|
|
70
|
+
/** A sign-in link by email. */
|
|
71
|
+
magicLink: ${on("magic-link")},
|
|
72
|
+
/** A 6-digit sign-in code by email. */
|
|
73
|
+
emailOtp: ${on("email-otp")},
|
|
74
|
+
/** Face ID, Touch ID, Windows Hello or a security key. */
|
|
75
|
+
passkeys: ${on("passkeys")},
|
|
76
|
+
twoFactor: {
|
|
77
|
+
/** Codes from an authenticator app (TOTP), with backup codes. */
|
|
78
|
+
authenticator: ${on("2fa-app")},
|
|
79
|
+
/** Codes by email as the second step. */
|
|
80
|
+
email: ${on("2fa-email")},
|
|
81
|
+
},
|
|
82
|
+
/**
|
|
83
|
+
* Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
|
|
84
|
+
* without them its button stays hidden.
|
|
85
|
+
*/
|
|
86
|
+
social: ${JSON.stringify(providers).replace(/","/g, '", "')} as SocialProvider[],
|
|
87
|
+
/** Refuse password sign-in until the email address is verified. */
|
|
88
|
+
requireEmailVerification: false,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
92
|
+
|
|
93
|
+
/** Whether any second factor can be set up. */
|
|
94
|
+
export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
|
|
95
|
+
`;
|
|
45
96
|
}
|
|
46
97
|
function devVarsEntries(providers) {
|
|
47
98
|
return providers.map((provider) => `${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_ID=
|
|
@@ -50,21 +101,39 @@ ${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_SECRET=
|
|
|
50
101
|
}
|
|
51
102
|
function devVarsExampleEntries(providers) {
|
|
52
103
|
return providers.map((provider) => {
|
|
53
|
-
const { envPrefix, console: console2 } = AUTH_PROVIDERS[provider];
|
|
104
|
+
const { label, envPrefix, console: console2, extra } = AUTH_PROVIDERS[provider];
|
|
54
105
|
return [
|
|
55
106
|
"",
|
|
56
|
-
`# ${
|
|
107
|
+
`# ${label} sign-in: ${console2}`,
|
|
57
108
|
`# Callback URL: <your app URL>/api/auth/callback/${provider}`,
|
|
58
109
|
`${envPrefix}_CLIENT_ID=`,
|
|
59
110
|
`${envPrefix}_CLIENT_SECRET=`,
|
|
111
|
+
...extra,
|
|
60
112
|
""
|
|
61
113
|
].join("\n");
|
|
62
114
|
}).join("");
|
|
63
115
|
}
|
|
64
116
|
|
|
117
|
+
// src/themes.ts
|
|
118
|
+
var THEMES = {
|
|
119
|
+
default: "Calm and centred, indigo accents",
|
|
120
|
+
coral: "Warm and rounded, sign-in as a card over the page",
|
|
121
|
+
amber: "Plain and direct, boxed forms with pill buttons",
|
|
122
|
+
sky: "Crisp blue, bold headings, social sign-in first",
|
|
123
|
+
mono: "Black and white on a fine grid",
|
|
124
|
+
emerald: "Fresh green, sign-in beside a customer quote"
|
|
125
|
+
};
|
|
126
|
+
function parseTheme(value) {
|
|
127
|
+
if (value === void 0) return "default";
|
|
128
|
+
const name = value.trim().toLowerCase();
|
|
129
|
+
if (!(name in THEMES)) throw new Error(`Unknown theme "${value}". Themes: ${Object.keys(THEMES).join(", ")}.`);
|
|
130
|
+
return name;
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
// src/versions.ts
|
|
66
134
|
var APP_DEPENDENCIES = {
|
|
67
135
|
"@better-auth/drizzle-adapter": "1.7.5",
|
|
136
|
+
"@better-auth/passkey": "1.7.5",
|
|
68
137
|
// Server components import Slot from here rather than the "radix-ui" umbrella (see components/ui/button.tsx).
|
|
69
138
|
"@radix-ui/react-slot": "1.3.3",
|
|
70
139
|
"@vinext/cloudflare": "1.0.0-beta.8",
|
|
@@ -80,6 +149,14 @@ var APP_DEPENDENCIES = {
|
|
|
80
149
|
sonner: "^2.0.8",
|
|
81
150
|
"tailwind-merge": "^3.7.0",
|
|
82
151
|
"tw-animate-css": "^1.4.0",
|
|
152
|
+
// Typefaces for the six themes (app/globals.css).
|
|
153
|
+
"@fontsource-variable/figtree": "5.3.0",
|
|
154
|
+
"@fontsource-variable/geist": "5.3.0",
|
|
155
|
+
"@fontsource-variable/inter": "5.3.0",
|
|
156
|
+
"@fontsource-variable/noto-sans": "5.3.0",
|
|
157
|
+
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
158
|
+
// QR codes for authenticator-app (2FA) setup.
|
|
159
|
+
uqr: "0.1.3",
|
|
83
160
|
"drizzle-orm": "^0.45.2",
|
|
84
161
|
react: "19.3.0",
|
|
85
162
|
"react-dom": "19.3.0",
|
|
@@ -157,6 +234,15 @@ function childEnv(env = process.env) {
|
|
|
157
234
|
}
|
|
158
235
|
return next;
|
|
159
236
|
}
|
|
237
|
+
function runQuiet(command, args, cwd) {
|
|
238
|
+
const env = childEnv();
|
|
239
|
+
const result = process.platform === "win32" ? spawnSync([command, ...args].join(" "), { cwd, env, shell: true, encoding: "utf8" }) : spawnSync(command, args, { cwd, env, encoding: "utf8" });
|
|
240
|
+
return { code: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
|
|
241
|
+
}
|
|
242
|
+
function installArgs(pm) {
|
|
243
|
+
if (pm === "npm") return ["install", "--no-audit", "--no-fund", "--loglevel=error"];
|
|
244
|
+
return ["install"];
|
|
245
|
+
}
|
|
160
246
|
|
|
161
247
|
// src/commands/create.ts
|
|
162
248
|
function toAppName(dir) {
|
|
@@ -185,14 +271,17 @@ function createApp(target, options2 = {}) {
|
|
|
185
271
|
}
|
|
186
272
|
const packageManager = options2.pm ?? detectPackageManager();
|
|
187
273
|
const authProviders = parseAuthProviders(options2.authProviders);
|
|
274
|
+
const authMethods = parseAuthMethods(options2.auth);
|
|
275
|
+
const theme = parseTheme(options2.theme);
|
|
188
276
|
const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
|
|
189
277
|
const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
190
278
|
copyTemplate(join2(templatesDir, "app"), dir, {
|
|
191
279
|
APP_NAME: name,
|
|
192
280
|
COMPAT_DATE: compatibilityDate,
|
|
193
281
|
PM: packageManager,
|
|
194
|
-
|
|
282
|
+
THEME: theme
|
|
195
283
|
});
|
|
284
|
+
writeFileSync2(join2(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders));
|
|
196
285
|
appendFileSync(join2(dir, ".dev.vars.example"), devVarsExampleEntries(authProviders));
|
|
197
286
|
writeJson(join2(dir, "package.json"), {
|
|
198
287
|
name,
|
|
@@ -221,24 +310,42 @@ ${devVarsEntries(authProviders)}`
|
|
|
221
310
|
if (packageManager === "pnpm" && !inWorkspace) {
|
|
222
311
|
writeFileSync2(join2(dir, "pnpm-workspace.yaml"), "allowBuilds:\n esbuild: true\n workerd: true\n sharp: false\n");
|
|
223
312
|
}
|
|
313
|
+
const log = options2.log ?? ((message) => console.log(message));
|
|
224
314
|
if (options2.install !== false) {
|
|
225
|
-
|
|
315
|
+
log(`${pc.green("\u2714")} Wrote the app to ${relative(process.cwd(), dir) || "."}`);
|
|
316
|
+
log(`${pc.cyan("\u25CF")} Installing dependencies with ${packageManager}. The first install downloads the Workers runtime and toolchain, so it can take a few minutes.
|
|
317
|
+
`);
|
|
318
|
+
const started = Date.now();
|
|
319
|
+
const code = run(packageManager, installArgs(packageManager), dir);
|
|
226
320
|
if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
|
|
227
|
-
|
|
228
|
-
|
|
321
|
+
log(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
|
|
322
|
+
const typegen = runQuiet(packageManager, ["run", "cf-typegen"], dir);
|
|
323
|
+
if (typegen.code !== 0) {
|
|
324
|
+
process.stderr.write(typegen.output);
|
|
325
|
+
throw new Error(`wrangler types failed (exit code ${typegen.code}).`);
|
|
326
|
+
}
|
|
327
|
+
log(`${pc.green("\u2714")} Generated types for the Cloudflare bindings`);
|
|
229
328
|
}
|
|
230
329
|
return { dir, name, packageManager, inWorkspace };
|
|
231
330
|
}
|
|
331
|
+
function formatDuration(ms) {
|
|
332
|
+
const seconds = Math.round(ms / 1e3);
|
|
333
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
334
|
+
}
|
|
232
335
|
function printNextSteps(result, installed) {
|
|
233
336
|
const rel = relative(process.cwd(), result.dir) || ".";
|
|
234
337
|
const pm = result.packageManager;
|
|
338
|
+
const step = (command, note) => ` ${command.padEnd(30)}${pc.dim(note)}`;
|
|
235
339
|
console.log(`
|
|
236
|
-
${pc.green("\u2714")} Created ${pc.bold(result.name)}
|
|
340
|
+
${pc.green("\u2714")} Created ${pc.bold(result.name)}
|
|
237
341
|
`);
|
|
238
342
|
console.log("Next steps:");
|
|
239
343
|
console.log(` cd ${rel}`);
|
|
240
344
|
if (!installed) console.log(` ${pm} install`);
|
|
241
|
-
console.log(
|
|
345
|
+
console.log(step(`${pm} run dev`, "start it at http://localhost:3000"));
|
|
346
|
+
console.log(step("npx flare gen resource ...", "add your first resource"));
|
|
347
|
+
console.log(`
|
|
348
|
+
Quickstart: ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
|
|
242
349
|
}
|
|
243
350
|
|
|
244
351
|
// src/commands/gen-billing.ts
|
|
@@ -247,9 +354,10 @@ import { dirname as dirname6, join as join11 } from "path";
|
|
|
247
354
|
import pc6 from "picocolors";
|
|
248
355
|
|
|
249
356
|
// src/generator/grammar.ts
|
|
250
|
-
import { camelCase, FILE_CATEGORIES } from "@flaredev/core";
|
|
357
|
+
import { camelCase, FILE_CATEGORIES, STRING_FORMATS } from "@flaredev/core";
|
|
251
358
|
var SIMPLE_KINDS = ["string", "text", "int", "float", "boolean", "date", "datetime"];
|
|
252
|
-
var
|
|
359
|
+
var FORMAT_ALIASES = { phone: "tel" };
|
|
360
|
+
var ALL_KINDS = [...SIMPLE_KINDS, ...STRING_FORMATS, "phone", "enum", "select", "radio", "multiselect", "file", "belongsTo", "hasMany"];
|
|
253
361
|
var FieldGrammarError = class extends Error {
|
|
254
362
|
constructor(message) {
|
|
255
363
|
super(message);
|
|
@@ -317,15 +425,21 @@ function parseField(spec) {
|
|
|
317
425
|
let match;
|
|
318
426
|
if (SIMPLE_KINDS.includes(type)) {
|
|
319
427
|
field.kind = type;
|
|
320
|
-
} else if (
|
|
321
|
-
field.kind = "
|
|
322
|
-
|
|
428
|
+
} else if (STRING_FORMATS.includes(type) || type in FORMAT_ALIASES) {
|
|
429
|
+
field.kind = "string";
|
|
430
|
+
field.format = FORMAT_ALIASES[type] ?? type;
|
|
431
|
+
} else if (match = /^(enum|select|radio|multiselect)\((.*)\)$/.exec(type)) {
|
|
432
|
+
const choice = match[1];
|
|
433
|
+
field.kind = choice === "multiselect" ? "multiselect" : "enum";
|
|
434
|
+
if (choice === "radio") field.widget = "radio";
|
|
435
|
+
if (choice === "multiselect" && unique) throw new FieldGrammarError(`"${spec}": multiselect fields can't be unique.`);
|
|
436
|
+
const options2 = list(match[2], `${choice} option`, spec);
|
|
323
437
|
for (const option of options2) {
|
|
324
438
|
if (!/^[A-Za-z0-9_-]+$/.test(option)) {
|
|
325
|
-
throw new FieldGrammarError(`"${spec}":
|
|
439
|
+
throw new FieldGrammarError(`"${spec}": ${choice} option "${option}" may only use letters, digits, _ and -.`);
|
|
326
440
|
}
|
|
327
441
|
}
|
|
328
|
-
if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate
|
|
442
|
+
if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate ${choice} options.`);
|
|
329
443
|
field.options = options2;
|
|
330
444
|
} else if (match = /^file(?::\[(.*)\])?$/.exec(type)) {
|
|
331
445
|
field.kind = "file";
|
|
@@ -360,7 +474,7 @@ function parseField(spec) {
|
|
|
360
474
|
const base = type.replace(/[([].*$/, "").replace(/:$/, "");
|
|
361
475
|
throw new FieldGrammarError(`"${spec}": unknown type "${type}".${suggest(base, ALL_KINDS)} Types: ${ALL_KINDS.join(", ")}.`);
|
|
362
476
|
}
|
|
363
|
-
if (field.kind === "string") {
|
|
477
|
+
if (field.kind === "string" && !field.format) {
|
|
364
478
|
if (/^email$|Email$/.test(key)) field.format = "email";
|
|
365
479
|
else if (/^(url|website)$|(Url|Website)$/.test(key)) field.format = "url";
|
|
366
480
|
}
|
|
@@ -478,6 +592,7 @@ function options(field) {
|
|
|
478
592
|
if (!field.required) opts.required = false;
|
|
479
593
|
if (field.unique) opts.unique = true;
|
|
480
594
|
if (field.format) opts.format = field.format;
|
|
595
|
+
if (field.widget) opts.widget = field.widget;
|
|
481
596
|
if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
|
|
482
597
|
return opts;
|
|
483
598
|
}
|
|
@@ -486,6 +601,8 @@ function toField(field) {
|
|
|
486
601
|
switch (field.kind) {
|
|
487
602
|
case "enum":
|
|
488
603
|
return builders.enum(field.options, opts);
|
|
604
|
+
case "multiselect":
|
|
605
|
+
return builders.multiselect(field.options, opts);
|
|
489
606
|
case "file":
|
|
490
607
|
return builders.file(field.accept, opts);
|
|
491
608
|
case "belongsTo":
|
|
@@ -507,6 +624,8 @@ function renderField(field) {
|
|
|
507
624
|
switch (field.kind) {
|
|
508
625
|
case "enum":
|
|
509
626
|
return `${field.key}: field.enum(${args(literal(field.options), opts)}),`;
|
|
627
|
+
case "multiselect":
|
|
628
|
+
return `${field.key}: field.multiselect(${args(literal(field.options), opts)}),`;
|
|
510
629
|
case "file":
|
|
511
630
|
return `${field.key}: field.file(${args(literal(field.accept), opts)}),`;
|
|
512
631
|
case "belongsTo":
|
|
@@ -1816,6 +1935,9 @@ function columnExpression(key, def, resources, selfName) {
|
|
|
1816
1935
|
case "enum":
|
|
1817
1936
|
expr = `text(${col}, { enum: ${q(def.options)} })`;
|
|
1818
1937
|
break;
|
|
1938
|
+
case "multiselect":
|
|
1939
|
+
expr = `text(${col}, { mode: "json" }).$type<(${def.options.map((option) => q(option)).join(" | ")})[]>()`;
|
|
1940
|
+
break;
|
|
1819
1941
|
case "belongsTo": {
|
|
1820
1942
|
const target = resources.get(def.target);
|
|
1821
1943
|
if (!target) {
|
|
@@ -1829,7 +1951,7 @@ function columnExpression(key, def, resources, selfName) {
|
|
|
1829
1951
|
expr = `text(${col})`;
|
|
1830
1952
|
}
|
|
1831
1953
|
if (def.required) expr += ".notNull()";
|
|
1832
|
-
if (def.unique) expr += ".unique()";
|
|
1954
|
+
if ("unique" in def && def.unique) expr += ".unique()";
|
|
1833
1955
|
if ("default" in def && def.default !== void 0) expr += `.default(${q(def.default)})`;
|
|
1834
1956
|
return expr;
|
|
1835
1957
|
}
|
|
@@ -2867,11 +2989,83 @@ async function genSecurity(options2 = {}) {
|
|
|
2867
2989
|
return { files, migrations, bindings };
|
|
2868
2990
|
}
|
|
2869
2991
|
|
|
2992
|
+
// src/commands/theme.ts
|
|
2993
|
+
import { existsSync as existsSync14, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
|
|
2994
|
+
import { join as join13 } from "path";
|
|
2995
|
+
import pc8 from "picocolors";
|
|
2996
|
+
var THEME_LINE = /theme: "([a-z]+)" as ThemeName,/;
|
|
2997
|
+
function setTheme(name, cwd = process.cwd(), log = console.log) {
|
|
2998
|
+
const appRoot = findAppRoot(cwd);
|
|
2999
|
+
const path = join13(appRoot, "lib/site.ts");
|
|
3000
|
+
if (!existsSync14(path)) throw new Error("lib/site.ts not found: this app was created before themes. Add it from the Flare app template.");
|
|
3001
|
+
const source = readFileSync10(path, "utf8");
|
|
3002
|
+
const current = THEME_LINE.exec(source)?.[1];
|
|
3003
|
+
if (!name) {
|
|
3004
|
+
for (const [theme2, description] of Object.entries(THEMES)) {
|
|
3005
|
+
log(`${theme2 === current ? pc8.green("\u25CF") : " "} ${theme2.padEnd(8)} ${pc8.dim(description)}`);
|
|
3006
|
+
}
|
|
3007
|
+
log(pc8.dim("\nSwitch with `flare theme <name>`. FLARE_THEME in .env overrides it for a build."));
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
const theme = parseTheme(name);
|
|
3011
|
+
if (!current) throw new Error('lib/site.ts has no `theme: "\u2026" as ThemeName,` line to change.');
|
|
3012
|
+
writeFileSync8(path, source.replace(THEME_LINE, `theme: "${theme}" as ThemeName,`));
|
|
3013
|
+
log(`${pc8.green("\u2714")} Theme is now ${pc8.bold(theme)}. Restart \`flare dev\` (or rebuild) to see it.`);
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
// src/commands/create-prompts.ts
|
|
3017
|
+
import * as prompts from "@clack/prompts";
|
|
3018
|
+
var canPrompt = (yes) => !yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
3019
|
+
function stopIfCancelled(value) {
|
|
3020
|
+
if (prompts.isCancel(value)) {
|
|
3021
|
+
prompts.cancel("Cancelled: nothing was created.");
|
|
3022
|
+
process.exit(1);
|
|
3023
|
+
}
|
|
3024
|
+
return value;
|
|
3025
|
+
}
|
|
3026
|
+
async function askCreateQuestions(given) {
|
|
3027
|
+
prompts.intro("Let's set up your Flare app");
|
|
3028
|
+
const answers = { ...given };
|
|
3029
|
+
if (given.theme === void 0) {
|
|
3030
|
+
answers.theme = stopIfCancelled(
|
|
3031
|
+
await prompts.select({
|
|
3032
|
+
message: "Pick a look (change it later in lib/site.ts or with FLARE_THEME)",
|
|
3033
|
+
initialValue: "default",
|
|
3034
|
+
options: Object.entries(THEMES).map(([value, hint]) => ({ value, label: value, hint }))
|
|
3035
|
+
})
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
if (given.auth === void 0) {
|
|
3039
|
+
const methods = stopIfCancelled(
|
|
3040
|
+
await prompts.multiselect({
|
|
3041
|
+
message: "How can people sign in? Email and password is always on. (space to toggle)",
|
|
3042
|
+
initialValues: DEFAULT_AUTH_METHODS,
|
|
3043
|
+
required: false,
|
|
3044
|
+
options: Object.entries(AUTH_METHODS).map(([value, label]) => ({ value, label }))
|
|
3045
|
+
})
|
|
3046
|
+
);
|
|
3047
|
+
answers.auth = methods.length ? methods.join(",") : "none";
|
|
3048
|
+
}
|
|
3049
|
+
if (given.authProviders === void 0) {
|
|
3050
|
+
const providers = stopIfCancelled(
|
|
3051
|
+
await prompts.multiselect({
|
|
3052
|
+
message: "Social sign-in? Each needs its own credentials later. (space to toggle, enter to skip)",
|
|
3053
|
+
initialValues: [],
|
|
3054
|
+
required: false,
|
|
3055
|
+
options: Object.entries(AUTH_PROVIDERS).map(([value, { label }]) => ({ value, label }))
|
|
3056
|
+
})
|
|
3057
|
+
);
|
|
3058
|
+
answers.authProviders = providers.join(",");
|
|
3059
|
+
}
|
|
3060
|
+
prompts.outro("Creating your app");
|
|
3061
|
+
return answers;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
2870
3064
|
// src/commands/gen.ts
|
|
2871
|
-
import { existsSync as
|
|
2872
|
-
import { dirname as dirname8, join as
|
|
3065
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
|
|
3066
|
+
import { dirname as dirname8, join as join14 } from "path";
|
|
2873
3067
|
import { camelCase as camelCase4, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
|
|
2874
|
-
import
|
|
3068
|
+
import pc9 from "picocolors";
|
|
2875
3069
|
function validateFields(name, fields, others, log) {
|
|
2876
3070
|
const known = /* @__PURE__ */ new Set([name, ...others.map((resource) => resource.name)]);
|
|
2877
3071
|
for (const field of fields) {
|
|
@@ -2882,7 +3076,7 @@ function validateFields(name, fields, others, log) {
|
|
|
2882
3076
|
const candidate = defineResource2({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
|
|
2883
3077
|
const graph = relationGraph2([...others, candidate]);
|
|
2884
3078
|
for (const pending of graph.pending.filter((p) => p.resource === name)) {
|
|
2885
|
-
log(
|
|
3079
|
+
log(pc9.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
|
|
2886
3080
|
}
|
|
2887
3081
|
}
|
|
2888
3082
|
async function genResource(rawName, options2) {
|
|
@@ -2890,8 +3084,8 @@ async function genResource(rawName, options2) {
|
|
|
2890
3084
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2891
3085
|
const name = resourceName(rawName);
|
|
2892
3086
|
const relativePath = descriptorPath(name);
|
|
2893
|
-
const path =
|
|
2894
|
-
const exists =
|
|
3087
|
+
const path = join14(appRoot, relativePath);
|
|
3088
|
+
const exists = existsSync15(path);
|
|
2895
3089
|
if (!exists && !options2.fields) {
|
|
2896
3090
|
throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
|
|
2897
3091
|
}
|
|
@@ -2902,12 +3096,12 @@ async function genResource(rawName, options2) {
|
|
|
2902
3096
|
validateFields(name, fields, others, log);
|
|
2903
3097
|
if (!exists) {
|
|
2904
3098
|
mkdirSync7(dirname8(path), { recursive: true });
|
|
2905
|
-
|
|
2906
|
-
log(`${
|
|
3099
|
+
writeFileSync9(path, renderDescriptor(name, fields));
|
|
3100
|
+
log(`${pc9.green("create".padEnd(9))} ${relativePath}`);
|
|
2907
3101
|
} else {
|
|
2908
3102
|
try {
|
|
2909
3103
|
const outcome = writeGenerated(path, renderFieldsBlock(fields), { force: options2.force });
|
|
2910
|
-
log(`${(outcome === "identical" ?
|
|
3104
|
+
log(`${(outcome === "identical" ? pc9.dim : pc9.yellow)(outcome.padEnd(9))} ${relativePath}`);
|
|
2911
3105
|
} catch (error) {
|
|
2912
3106
|
if (error instanceof DriftError) {
|
|
2913
3107
|
throw new Error(
|
|
@@ -2933,28 +3127,28 @@ async function genResource(rawName, options2) {
|
|
|
2933
3127
|
}
|
|
2934
3128
|
const skipped = written.filter((r) => r.outcome.startsWith("skipped"));
|
|
2935
3129
|
if (skipped.length) {
|
|
2936
|
-
log(
|
|
3130
|
+
log(pc9.yellow(`
|
|
2937
3131
|
Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for details.`));
|
|
2938
3132
|
}
|
|
2939
3133
|
let migrations = [];
|
|
2940
3134
|
if (!options2.skipMigration) {
|
|
2941
3135
|
const result = await generateSchemaMigration(appRoot, `${exists ? "update" : "create"}_${table}`);
|
|
2942
3136
|
migrations = result.files;
|
|
2943
|
-
for (const file of migrations) log(`${
|
|
2944
|
-
for (const repair of result.repairs) log(
|
|
3137
|
+
for (const file of migrations) log(`${pc9.green("create".padEnd(9))} ${file}`);
|
|
3138
|
+
for (const repair of result.repairs) log(pc9.dim(`repaired ${repair}`));
|
|
2945
3139
|
if (migrations.length) log(`
|
|
2946
|
-
Next: ${
|
|
2947
|
-
else if (exists) log(
|
|
3140
|
+
Next: ${pc9.bold("flare migrate")} to apply it locally.`);
|
|
3141
|
+
else if (exists) log(pc9.dim("\nNo table changes, so no migration."));
|
|
2948
3142
|
}
|
|
2949
3143
|
const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
|
|
2950
3144
|
return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
|
|
2951
3145
|
}
|
|
2952
3146
|
|
|
2953
3147
|
// src/commands/gen-migration.ts
|
|
2954
|
-
import { existsSync as
|
|
2955
|
-
import { basename as basename2, join as
|
|
3148
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
|
|
3149
|
+
import { basename as basename2, join as join15 } from "path";
|
|
2956
3150
|
import { snakeCase } from "@flaredev/core";
|
|
2957
|
-
import
|
|
3151
|
+
import pc10 from "picocolors";
|
|
2958
3152
|
var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
|
|
2959
3153
|
-- Write SQL that undoes the up migration, e.g.:
|
|
2960
3154
|
-- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
|
|
@@ -2970,31 +3164,31 @@ async function genMigration(rawName, options2 = {}) {
|
|
|
2970
3164
|
log(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
|
|
2971
3165
|
return [];
|
|
2972
3166
|
}
|
|
2973
|
-
for (const file of files) log(`${
|
|
2974
|
-
for (const repair of repairs) log(
|
|
3167
|
+
for (const file of files) log(`${pc10.green("create".padEnd(9))} ${file}`);
|
|
3168
|
+
for (const repair of repairs) log(pc10.dim(`repaired ${repair}`));
|
|
2975
3169
|
if (!options2.fromSchema) {
|
|
2976
|
-
const downDir =
|
|
3170
|
+
const downDir = join15(appRoot, "migrations", "down");
|
|
2977
3171
|
mkdirSync8(downDir, { recursive: true });
|
|
2978
3172
|
for (const file of files) {
|
|
2979
|
-
const down =
|
|
2980
|
-
if (
|
|
2981
|
-
|
|
2982
|
-
log(`${
|
|
3173
|
+
const down = join15(downDir, basename2(file));
|
|
3174
|
+
if (existsSync16(down)) continue;
|
|
3175
|
+
writeFileSync10(down, DOWN_TEMPLATE(basename2(file)));
|
|
3176
|
+
log(`${pc10.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
|
|
2983
3177
|
}
|
|
2984
3178
|
log(`
|
|
2985
|
-
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${
|
|
3179
|
+
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc10.bold("flare migrate")}.`);
|
|
2986
3180
|
} else {
|
|
2987
3181
|
log(`
|
|
2988
|
-
Next: ${
|
|
3182
|
+
Next: ${pc10.bold("flare migrate")}.`);
|
|
2989
3183
|
}
|
|
2990
3184
|
return files;
|
|
2991
3185
|
}
|
|
2992
3186
|
|
|
2993
3187
|
// src/commands/gen-policy.ts
|
|
2994
|
-
import { existsSync as
|
|
2995
|
-
import { dirname as dirname9, join as
|
|
3188
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
|
|
3189
|
+
import { dirname as dirname9, join as join16 } from "path";
|
|
2996
3190
|
import { definePolicy } from "@flaredev/core";
|
|
2997
|
-
import
|
|
3191
|
+
import pc11 from "picocolors";
|
|
2998
3192
|
function parseRoles(input, what = "--roles") {
|
|
2999
3193
|
const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
3000
3194
|
if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
|
|
@@ -3010,8 +3204,8 @@ async function genPolicy(rawName, options2) {
|
|
|
3010
3204
|
throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
|
|
3011
3205
|
}
|
|
3012
3206
|
const relative2 = policyPath(name);
|
|
3013
|
-
const path =
|
|
3014
|
-
const exists =
|
|
3207
|
+
const path = join16(appRoot, relative2);
|
|
3208
|
+
const exists = existsSync17(path);
|
|
3015
3209
|
let roles;
|
|
3016
3210
|
let deleteRoles;
|
|
3017
3211
|
if (options2.roles || !exists) {
|
|
@@ -3023,12 +3217,12 @@ async function genPolicy(rawName, options2) {
|
|
|
3023
3217
|
const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
|
|
3024
3218
|
if (!exists) {
|
|
3025
3219
|
mkdirSync9(dirname9(path), { recursive: true });
|
|
3026
|
-
|
|
3027
|
-
log(`${
|
|
3220
|
+
writeFileSync11(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
|
|
3221
|
+
log(`${pc11.green("create".padEnd(9))} ${relative2}`);
|
|
3028
3222
|
} else {
|
|
3029
3223
|
try {
|
|
3030
3224
|
const outcome = writeGenerated(path, block, { force: options2.force });
|
|
3031
|
-
log(`${(outcome === "identical" ?
|
|
3225
|
+
log(`${(outcome === "identical" ? pc11.dim : pc11.yellow)(outcome.padEnd(9))} ${relative2}`);
|
|
3032
3226
|
} catch (error) {
|
|
3033
3227
|
if (error instanceof DriftError) {
|
|
3034
3228
|
throw new Error(`The roles in ${relative2} were edited by hand, so --roles won't overwrite them. Edit the file directly, or pass --force.`);
|
|
@@ -3040,15 +3234,15 @@ async function genPolicy(rawName, options2) {
|
|
|
3040
3234
|
const policies = await loadPolicies(appRoot);
|
|
3041
3235
|
logResults(applyPlan(appRoot, planFiles(resources, policies)), log);
|
|
3042
3236
|
log(`
|
|
3043
|
-
${
|
|
3237
|
+
${pc11.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
|
|
3044
3238
|
return { name, path: relative2 };
|
|
3045
3239
|
}
|
|
3046
3240
|
|
|
3047
3241
|
// src/commands/migrate.ts
|
|
3048
|
-
import { existsSync as
|
|
3242
|
+
import { existsSync as existsSync18, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
|
|
3049
3243
|
import { tmpdir } from "os";
|
|
3050
|
-
import { join as
|
|
3051
|
-
import
|
|
3244
|
+
import { join as join17 } from "path";
|
|
3245
|
+
import pc12 from "picocolors";
|
|
3052
3246
|
|
|
3053
3247
|
// src/generator/down.ts
|
|
3054
3248
|
var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
|
|
@@ -3165,7 +3359,7 @@ async function rollback(options2 = {}) {
|
|
|
3165
3359
|
const steps = options2.steps ?? 1;
|
|
3166
3360
|
if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
|
|
3167
3361
|
const { appRoot, db, target, where, wrangler, log } = context(options2);
|
|
3168
|
-
const migrationsDir =
|
|
3362
|
+
const migrationsDir = join17(appRoot, db.migrations_dir ?? "migrations");
|
|
3169
3363
|
const applied = await appliedMigrations(options2, steps);
|
|
3170
3364
|
if (applied.length === 0) {
|
|
3171
3365
|
log("Nothing to roll back.");
|
|
@@ -3174,18 +3368,18 @@ async function rollback(options2 = {}) {
|
|
|
3174
3368
|
const plan = [];
|
|
3175
3369
|
for (const { name } of applied) {
|
|
3176
3370
|
if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
|
|
3177
|
-
const handWritten =
|
|
3178
|
-
if (
|
|
3179
|
-
const sql2 =
|
|
3371
|
+
const handWritten = join17(migrationsDir, "down", name);
|
|
3372
|
+
if (existsSync18(handWritten)) {
|
|
3373
|
+
const sql2 = readFileSync11(handWritten, "utf8");
|
|
3180
3374
|
if (splitStatements(sql2).length === 0) {
|
|
3181
3375
|
throw new Error(`Can't roll back ${name}: migrations/down/${name} has no SQL statements yet. Write the rollback SQL, then run the rollback again. Nothing was changed.`);
|
|
3182
3376
|
}
|
|
3183
3377
|
plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
|
|
3184
3378
|
continue;
|
|
3185
3379
|
}
|
|
3186
|
-
const upPath =
|
|
3187
|
-
if (!
|
|
3188
|
-
const down = deriveDown(
|
|
3380
|
+
const upPath = join17(migrationsDir, name);
|
|
3381
|
+
if (!existsSync18(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
|
|
3382
|
+
const down = deriveDown(readFileSync11(upPath, "utf8"));
|
|
3189
3383
|
if (!down.reversible) {
|
|
3190
3384
|
throw new Error(
|
|
3191
3385
|
`Can't roll back ${name}: ${down.reason}
|
|
@@ -3194,37 +3388,37 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
|
|
|
3194
3388
|
}
|
|
3195
3389
|
plan.push({ name, sql: down.sql, source: "derived" });
|
|
3196
3390
|
}
|
|
3197
|
-
log(`${
|
|
3391
|
+
log(`${pc12.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
|
|
3198
3392
|
for (const step of plan) {
|
|
3199
|
-
log(` ${step.name} ${
|
|
3200
|
-
for (const line of step.sql.trim().split("\n")) log(
|
|
3393
|
+
log(` ${step.name} ${pc12.dim(`(${step.source})`)}`);
|
|
3394
|
+
for (const line of step.sql.trim().split("\n")) log(pc12.dim(` ${line}`));
|
|
3201
3395
|
}
|
|
3202
3396
|
if (options2.remote && !options2.yes) {
|
|
3203
|
-
log(
|
|
3397
|
+
log(pc12.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
|
|
3204
3398
|
return 1;
|
|
3205
3399
|
}
|
|
3206
3400
|
const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
|
|
3207
3401
|
DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
|
|
3208
|
-
const dir = mkdtempSync(
|
|
3402
|
+
const dir = mkdtempSync(join17(tmpdir(), "flare-rollback-"));
|
|
3209
3403
|
try {
|
|
3210
|
-
const file =
|
|
3211
|
-
|
|
3404
|
+
const file = join17(dir, "rollback.sql");
|
|
3405
|
+
writeFileSync12(file, script);
|
|
3212
3406
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3213
3407
|
if (result.code !== 0) throw new Error(`Rollback failed:
|
|
3214
3408
|
${result.output.trim()}`);
|
|
3215
3409
|
} finally {
|
|
3216
3410
|
rmSync2(dir, { recursive: true, force: true });
|
|
3217
3411
|
}
|
|
3218
|
-
log(
|
|
3412
|
+
log(pc12.green(`
|
|
3219
3413
|
Rolled back ${plan.map((step) => step.name).join(", ")}.`));
|
|
3220
3414
|
return 0;
|
|
3221
3415
|
}
|
|
3222
3416
|
|
|
3223
3417
|
// src/commands/rm.ts
|
|
3224
|
-
import { existsSync as
|
|
3225
|
-
import { dirname as dirname10, join as
|
|
3418
|
+
import { existsSync as existsSync19, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
3419
|
+
import { dirname as dirname10, join as join18 } from "path";
|
|
3226
3420
|
import { relationGraph as relationGraph3 } from "@flaredev/core";
|
|
3227
|
-
import
|
|
3421
|
+
import pc13 from "picocolors";
|
|
3228
3422
|
var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
|
|
3229
3423
|
function handWrittenReason(source, header, options2 = {}) {
|
|
3230
3424
|
const parts = splitMarkers(source);
|
|
@@ -3241,10 +3435,10 @@ function handWrittenReason(source, header, options2 = {}) {
|
|
|
3241
3435
|
return null;
|
|
3242
3436
|
}
|
|
3243
3437
|
function removeEmptyDirs(appRoot, start) {
|
|
3244
|
-
let dir = dirname10(
|
|
3245
|
-
const roots = [
|
|
3438
|
+
let dir = dirname10(join18(appRoot, start));
|
|
3439
|
+
const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "admin")];
|
|
3246
3440
|
const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
|
|
3247
|
-
while (inside() &&
|
|
3441
|
+
while (inside() && existsSync19(dir) && readdirSync7(dir).length === 0) {
|
|
3248
3442
|
rmdirSync(dir);
|
|
3249
3443
|
dir = dirname10(dir);
|
|
3250
3444
|
}
|
|
@@ -3273,7 +3467,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
3273
3467
|
if (entry) {
|
|
3274
3468
|
owned.add(entry.file);
|
|
3275
3469
|
for (const file of planFiles(all)) {
|
|
3276
|
-
if (
|
|
3470
|
+
if (existsSync19(join18(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
|
|
3277
3471
|
}
|
|
3278
3472
|
}
|
|
3279
3473
|
for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
|
|
@@ -3282,7 +3476,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
3282
3476
|
if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
|
|
3283
3477
|
const findings = [];
|
|
3284
3478
|
for (const path of owned) {
|
|
3285
|
-
const source =
|
|
3479
|
+
const source = readFileSync12(join18(appRoot, path), "utf8");
|
|
3286
3480
|
let wrapper;
|
|
3287
3481
|
if (entry && path === entry.file) {
|
|
3288
3482
|
const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
|
|
@@ -3303,34 +3497,34 @@ Move what you want to keep, or pass --force to delete anyway.`);
|
|
|
3303
3497
|
}
|
|
3304
3498
|
const removed = [...owned].sort();
|
|
3305
3499
|
for (const path of removed) {
|
|
3306
|
-
unlinkSync2(
|
|
3500
|
+
unlinkSync2(join18(appRoot, path));
|
|
3307
3501
|
removeEmptyDirs(appRoot, path);
|
|
3308
|
-
log(`${
|
|
3502
|
+
log(`${pc13.red("remove".padEnd(9))} ${path}`);
|
|
3309
3503
|
}
|
|
3310
3504
|
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log);
|
|
3311
3505
|
const table = entry ? tableExport(entry.resource) : void 0;
|
|
3312
|
-
if (table &&
|
|
3313
|
-
const seeds = readdirSync7(
|
|
3314
|
-
(file) => new RegExp(`\\b${table}\\b`).test(
|
|
3506
|
+
if (table && existsSync19(join18(appRoot, "seeds"))) {
|
|
3507
|
+
const seeds = readdirSync7(join18(appRoot, "seeds")).filter(
|
|
3508
|
+
(file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join18(appRoot, "seeds", file), "utf8"))
|
|
3315
3509
|
);
|
|
3316
|
-
if (seeds.length) log(
|
|
3510
|
+
if (seeds.length) log(pc13.yellow(`
|
|
3317
3511
|
These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
|
|
3318
3512
|
}
|
|
3319
3513
|
let migrations = [];
|
|
3320
3514
|
if (!options2.skipMigration && entry) {
|
|
3321
3515
|
const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
|
|
3322
3516
|
migrations = result.files;
|
|
3323
|
-
for (const file of migrations) log(`${
|
|
3324
|
-
for (const repair of result.repairs) log(
|
|
3517
|
+
for (const file of migrations) log(`${pc13.green("create".padEnd(9))} ${file}`);
|
|
3518
|
+
for (const repair of result.repairs) log(pc13.dim(`repaired ${repair}`));
|
|
3325
3519
|
if (migrations.length) log(`
|
|
3326
|
-
Next: ${
|
|
3520
|
+
Next: ${pc13.bold("flare migrate")} to drop the table locally.`);
|
|
3327
3521
|
}
|
|
3328
3522
|
return { removed, migrations };
|
|
3329
3523
|
}
|
|
3330
3524
|
|
|
3331
3525
|
// src/commands/role.ts
|
|
3332
3526
|
import { humanize } from "@flaredev/core";
|
|
3333
|
-
import
|
|
3527
|
+
import pc14 from "picocolors";
|
|
3334
3528
|
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3335
3529
|
function roleInsertSql(name, label) {
|
|
3336
3530
|
if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
|
|
@@ -3375,24 +3569,24 @@ ${result.output.trim()}`);
|
|
|
3375
3569
|
}
|
|
3376
3570
|
const [first] = extractJson(result.output);
|
|
3377
3571
|
const added = first?.results?.[0];
|
|
3378
|
-
log(
|
|
3379
|
-
log(
|
|
3572
|
+
log(pc14.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
|
|
3573
|
+
log(pc14.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
|
|
3380
3574
|
return 0;
|
|
3381
3575
|
}
|
|
3382
3576
|
|
|
3383
3577
|
// src/commands/seed.ts
|
|
3384
|
-
import { existsSync as
|
|
3578
|
+
import { existsSync as existsSync20, mkdirSync as mkdirSync10, readdirSync as readdirSync8, writeFileSync as writeFileSync13 } from "fs";
|
|
3385
3579
|
import { createRequire } from "module";
|
|
3386
|
-
import { join as
|
|
3580
|
+
import { join as join19 } from "path";
|
|
3387
3581
|
import { pathToFileURL } from "url";
|
|
3388
3582
|
import { kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
|
|
3389
3583
|
import { createJiti as createJiti4 } from "jiti";
|
|
3390
|
-
import
|
|
3584
|
+
import pc15 from "picocolors";
|
|
3391
3585
|
var SEEDS_DIR = "seeds";
|
|
3392
3586
|
var LOCAL_STATE = ".wrangler/state/v3";
|
|
3393
3587
|
function seedFiles(appRoot, names) {
|
|
3394
|
-
const dir =
|
|
3395
|
-
const all =
|
|
3588
|
+
const dir = join19(appRoot, SEEDS_DIR);
|
|
3589
|
+
const all = existsSync20(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
|
|
3396
3590
|
if (!names?.length) return all;
|
|
3397
3591
|
return names.map((name) => {
|
|
3398
3592
|
const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
|
|
@@ -3407,29 +3601,29 @@ async function runSeeds(options2 = {}) {
|
|
|
3407
3601
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3408
3602
|
const files = seedFiles(appRoot, options2.names);
|
|
3409
3603
|
if (files.length === 0) {
|
|
3410
|
-
log(`No seeds found. Create one with ${
|
|
3604
|
+
log(`No seeds found. Create one with ${pc15.bold("flare seed:make <name>")}.`);
|
|
3411
3605
|
return;
|
|
3412
3606
|
}
|
|
3413
|
-
const jiti = createJiti4(
|
|
3414
|
-
const appRequire = createRequire(
|
|
3607
|
+
const jiti = createJiti4(join19(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
|
|
3608
|
+
const appRequire = createRequire(join19(appRoot, "package.json"));
|
|
3415
3609
|
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3416
3610
|
const { drizzle } = await jiti.import("drizzle-orm/d1");
|
|
3417
|
-
const schema = await jiti.import(
|
|
3611
|
+
const schema = await jiti.import(join19(appRoot, "db/schema.ts"));
|
|
3418
3612
|
const { env, dispose } = await wrangler.getPlatformProxy({
|
|
3419
|
-
configPath:
|
|
3420
|
-
persist: { path:
|
|
3613
|
+
configPath: join19(appRoot, "wrangler.jsonc"),
|
|
3614
|
+
persist: { path: join19(appRoot, LOCAL_STATE) }
|
|
3421
3615
|
});
|
|
3422
3616
|
try {
|
|
3423
3617
|
const bindings = env;
|
|
3424
3618
|
if (!bindings.DB) throw new Error("No DB binding found in wrangler.jsonc.");
|
|
3425
3619
|
const db = drizzle(bindings.DB, { schema });
|
|
3426
3620
|
for (const file of files) {
|
|
3427
|
-
const mod = await jiti.import(
|
|
3621
|
+
const mod = await jiti.import(join19(appRoot, SEEDS_DIR, file));
|
|
3428
3622
|
if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
|
|
3429
|
-
log(`${
|
|
3623
|
+
log(`${pc15.cyan("seed")} ${file}`);
|
|
3430
3624
|
await mod.default({ db, env: bindings, log: (message) => log(` ${message}`) });
|
|
3431
3625
|
}
|
|
3432
|
-
log(
|
|
3626
|
+
log(pc15.green(`
|
|
3433
3627
|
Ran ${files.length} seed(s) against the local database.`));
|
|
3434
3628
|
} finally {
|
|
3435
3629
|
await dispose();
|
|
@@ -3440,6 +3634,11 @@ function sampleValue(key, def, row) {
|
|
|
3440
3634
|
case "string":
|
|
3441
3635
|
if (def.format === "email") return `\`${kebabCase3(key).replace(/-?email$/, "") || "user"}\${i}@example.com\``;
|
|
3442
3636
|
if (def.format === "url") return `\`https://example.com/\${i}\``;
|
|
3637
|
+
if (def.format === "tel") return `\`+1202555\${String(1000 + i).slice(-4)}\``;
|
|
3638
|
+
if (def.format === "domain") return `\`example\${i}.com\``;
|
|
3639
|
+
if (def.format === "country") return JSON.stringify(["US", "GB", "UG", "KE", "DE"][row % 5]);
|
|
3640
|
+
if (def.format === "color") return JSON.stringify("#f2541d");
|
|
3641
|
+
if (def.format === "slug") return `\`${kebabCase3(def.label ?? key)}-\${i}\``;
|
|
3443
3642
|
return `\`${def.label} \${i}\``;
|
|
3444
3643
|
case "text":
|
|
3445
3644
|
return `"Lorem ipsum dolor sit amet."`;
|
|
@@ -3455,6 +3654,8 @@ function sampleValue(key, def, row) {
|
|
|
3455
3654
|
return "new Date().toISOString()";
|
|
3456
3655
|
case "enum":
|
|
3457
3656
|
return JSON.stringify(def.options[row % def.options.length]);
|
|
3657
|
+
case "multiselect":
|
|
3658
|
+
return JSON.stringify(def.options.slice(0, Math.max(1, def.minItems ?? 1)));
|
|
3458
3659
|
case "file":
|
|
3459
3660
|
return void 0;
|
|
3460
3661
|
case "belongsTo":
|
|
@@ -3498,8 +3699,8 @@ async function makeSeed(rawName, options2 = {}) {
|
|
|
3498
3699
|
const name = kebabCase3(rawName);
|
|
3499
3700
|
if (!name) throw new Error(`Invalid seed name "${rawName}".`);
|
|
3500
3701
|
const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
|
|
3501
|
-
const path =
|
|
3502
|
-
if (
|
|
3702
|
+
const path = join19(appRoot, relative2);
|
|
3703
|
+
if (existsSync20(path)) throw new Error(`${relative2} already exists.`);
|
|
3503
3704
|
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
3504
3705
|
let resource;
|
|
3505
3706
|
if (options2.resource) {
|
|
@@ -3508,27 +3709,27 @@ async function makeSeed(rawName, options2 = {}) {
|
|
|
3508
3709
|
} else {
|
|
3509
3710
|
resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
|
|
3510
3711
|
}
|
|
3511
|
-
mkdirSync10(
|
|
3512
|
-
|
|
3513
|
-
log(`${
|
|
3712
|
+
mkdirSync10(join19(appRoot, SEEDS_DIR), { recursive: true });
|
|
3713
|
+
writeFileSync13(path, renderSeed(resource));
|
|
3714
|
+
log(`${pc15.green("create".padEnd(9))} ${relative2}`);
|
|
3514
3715
|
log(`
|
|
3515
|
-
Run it with ${
|
|
3716
|
+
Run it with ${pc15.bold(`flare seed ${name}`)}.`);
|
|
3516
3717
|
return relative2;
|
|
3517
3718
|
}
|
|
3518
3719
|
|
|
3519
3720
|
// src/commands/billing-sync.ts
|
|
3520
3721
|
import { createRequire as createRequire2 } from "module";
|
|
3521
|
-
import { existsSync as
|
|
3722
|
+
import { existsSync as existsSync21, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, rmSync as rmSync3, writeFileSync as writeFileSync14 } from "fs";
|
|
3522
3723
|
import { tmpdir as tmpdir2 } from "os";
|
|
3523
|
-
import { join as
|
|
3724
|
+
import { join as join20 } from "path";
|
|
3524
3725
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
3525
3726
|
import { kebabCase as kebabCase4 } from "@flaredev/core";
|
|
3526
|
-
import
|
|
3727
|
+
import pc16 from "picocolors";
|
|
3527
3728
|
function stripeKeys(appRoot) {
|
|
3528
3729
|
const map = {};
|
|
3529
|
-
const path =
|
|
3530
|
-
if (
|
|
3531
|
-
for (const line of
|
|
3730
|
+
const path = join20(appRoot, ".dev.vars");
|
|
3731
|
+
if (existsSync21(path)) {
|
|
3732
|
+
for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
|
|
3532
3733
|
const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
3533
3734
|
if (match) map[match[1]] = match[2].trim();
|
|
3534
3735
|
}
|
|
@@ -3611,10 +3812,10 @@ async function syncPlans(options2 = {}) {
|
|
|
3611
3812
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
|
|
3612
3813
|
const db = readD1Databases(appRoot)[0];
|
|
3613
3814
|
if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
3614
|
-
const appRequire = createRequire2(
|
|
3815
|
+
const appRequire = createRequire2(join20(appRoot, "package.json"));
|
|
3615
3816
|
const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
|
|
3616
3817
|
const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
|
|
3617
|
-
const appName = JSON.parse(
|
|
3818
|
+
const appName = JSON.parse(readFileSync13(join20(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
3618
3819
|
const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
|
|
3619
3820
|
const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
|
|
3620
3821
|
if (products.length === 0) {
|
|
@@ -3635,10 +3836,10 @@ async function syncPlans(options2 = {}) {
|
|
|
3635
3836
|
${existingResult.output.trim()}`);
|
|
3636
3837
|
}
|
|
3637
3838
|
const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
|
|
3638
|
-
const dir = mkdtempSync2(
|
|
3839
|
+
const dir = mkdtempSync2(join20(tmpdir2(), "flare-sync-"));
|
|
3639
3840
|
try {
|
|
3640
|
-
const file =
|
|
3641
|
-
|
|
3841
|
+
const file = join20(dir, "sync-plans.sql");
|
|
3842
|
+
writeFileSync14(file, renderSyncSql(rows));
|
|
3642
3843
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3643
3844
|
if (result.code !== 0) throw new Error(`Could not write plans:
|
|
3644
3845
|
${result.output.trim()}`);
|
|
@@ -3650,26 +3851,26 @@ ${result.output.trim()}`);
|
|
|
3650
3851
|
const isNew = !known.has(row.stripePriceId);
|
|
3651
3852
|
if (isNew) created++;
|
|
3652
3853
|
const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
|
|
3653
|
-
log(`${(isNew ?
|
|
3854
|
+
log(`${(isNew ? pc16.green("create") : pc16.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc16.dim(price)}`);
|
|
3654
3855
|
}
|
|
3655
|
-
for (const name of skipped) log(
|
|
3856
|
+
for (const name of skipped) log(pc16.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
|
|
3656
3857
|
const configuration = await syncPortalConfiguration(stripe, rows, appName);
|
|
3657
|
-
if (configuration) log(`${
|
|
3658
|
-
log(
|
|
3858
|
+
if (configuration) log(`${pc16.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
|
|
3859
|
+
log(pc16.green(`
|
|
3659
3860
|
${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
|
|
3660
3861
|
return { created, updated: rows.length - created, skipped };
|
|
3661
3862
|
}
|
|
3662
3863
|
|
|
3663
3864
|
// src/commands/sync.ts
|
|
3664
|
-
import { existsSync as
|
|
3665
|
-
import { join as
|
|
3666
|
-
import
|
|
3865
|
+
import { existsSync as existsSync22 } from "fs";
|
|
3866
|
+
import { join as join21 } from "path";
|
|
3867
|
+
import pc17 from "picocolors";
|
|
3667
3868
|
async function syncTypes(options2 = {}) {
|
|
3668
3869
|
const log = options2.log ?? ((message) => console.log(message));
|
|
3669
3870
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3670
3871
|
const all = await loadResources(appRoot);
|
|
3671
3872
|
const policies = await loadPolicies(appRoot);
|
|
3672
|
-
const tracksPolicies = policies.length > 0 ||
|
|
3873
|
+
const tracksPolicies = policies.length > 0 || existsSync22(join21(appRoot, "policies/index.ts"));
|
|
3673
3874
|
const plan = planFiles(all, tracksPolicies ? policies : void 0);
|
|
3674
3875
|
if (!options2.check) ensureSupportFiles(appRoot, log);
|
|
3675
3876
|
const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
|
|
@@ -3681,42 +3882,42 @@ async function syncTypes(options2 = {}) {
|
|
|
3681
3882
|
if (drift.length) {
|
|
3682
3883
|
log(
|
|
3683
3884
|
`
|
|
3684
|
-
${
|
|
3885
|
+
${pc17.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
|
|
3685
3886
|
Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
|
|
3686
3887
|
);
|
|
3687
3888
|
}
|
|
3688
3889
|
if (unmarked.length) {
|
|
3689
3890
|
log(`
|
|
3690
|
-
${
|
|
3891
|
+
${pc17.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
|
|
3691
3892
|
}
|
|
3692
3893
|
if (orphans.length) {
|
|
3693
3894
|
const resources = [...new Set(orphans.map((o) => o.resource))];
|
|
3694
3895
|
log(`
|
|
3695
|
-
${
|
|
3896
|
+
${pc17.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
|
|
3696
3897
|
for (const orphan of orphans) log(` ${orphan.path}`);
|
|
3697
3898
|
log(
|
|
3698
|
-
`If the resource was deleted, remove them with ${
|
|
3899
|
+
`If the resource was deleted, remove them with ${pc17.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
|
|
3699
3900
|
);
|
|
3700
3901
|
}
|
|
3701
3902
|
const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
|
|
3702
3903
|
if (tablesChanged && !options2.check) {
|
|
3703
3904
|
log(`
|
|
3704
|
-
${
|
|
3905
|
+
${pc17.cyan("Tables changed.")} Create a migration with ${pc17.bold("flare gen migration <name> --from-schema")}, then ${pc17.bold("flare migrate")}.`);
|
|
3705
3906
|
}
|
|
3706
3907
|
const problems = drift.length + unmarked.length + orphans.length;
|
|
3707
3908
|
if (options2.check) {
|
|
3708
3909
|
const stale = changed.length;
|
|
3709
|
-
if (stale === 0 && problems === 0) log(
|
|
3910
|
+
if (stale === 0 && problems === 0) log(pc17.green("Generated files are in sync with the resource descriptors."));
|
|
3710
3911
|
else log(`
|
|
3711
|
-
${
|
|
3912
|
+
${pc17.red("Out of sync:")} ${stale} file(s) would change. Run ${pc17.bold("flare sync-types")}.`);
|
|
3712
3913
|
return stale + problems > 0 ? 1 : 0;
|
|
3713
3914
|
}
|
|
3714
|
-
if (changed.length === 0 && problems === 0) log(
|
|
3915
|
+
if (changed.length === 0 && problems === 0) log(pc17.green("Everything is in sync."));
|
|
3715
3916
|
return problems > 0 ? 1 : 0;
|
|
3716
3917
|
}
|
|
3717
3918
|
|
|
3718
3919
|
// src/commands/user-role.ts
|
|
3719
|
-
import
|
|
3920
|
+
import pc18 from "picocolors";
|
|
3720
3921
|
var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3721
3922
|
var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
|
|
3722
3923
|
function userRoleSql(email, role) {
|
|
@@ -3730,8 +3931,8 @@ async function setUserRole(email, role, options2 = {}) {
|
|
|
3730
3931
|
const sql2 = userRoleSql(email, role);
|
|
3731
3932
|
const roles = await listRoles(options2);
|
|
3732
3933
|
if (roles && !roles.includes(role)) {
|
|
3733
|
-
log(
|
|
3734
|
-
log(
|
|
3934
|
+
log(pc18.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
|
|
3935
|
+
log(pc18.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
|
|
3735
3936
|
return 1;
|
|
3736
3937
|
}
|
|
3737
3938
|
const db = readD1Databases(appRoot)[0];
|
|
@@ -3745,22 +3946,24 @@ ${result.output.trim()}`);
|
|
|
3745
3946
|
const [first] = extractJson(result.output);
|
|
3746
3947
|
const updated = first?.results ?? [];
|
|
3747
3948
|
if (updated.length === 0) {
|
|
3748
|
-
log(
|
|
3949
|
+
log(pc18.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
|
|
3749
3950
|
return 1;
|
|
3750
3951
|
}
|
|
3751
|
-
log(
|
|
3952
|
+
log(pc18.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
|
|
3752
3953
|
return 0;
|
|
3753
3954
|
}
|
|
3754
3955
|
|
|
3755
3956
|
// src/cli.ts
|
|
3756
3957
|
function createCli() {
|
|
3757
3958
|
const cli = cac("flare");
|
|
3758
|
-
cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--auth
|
|
3959
|
+
cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--theme <name>", "Look of the app: default, coral, amber, sky, mono or emerald").option("--auth <list>", "Sign-in methods: magic-link, email-otp, passkeys, 2fa-app, 2fa-email, all or none (default: all)").option("--auth-providers <list>", "Social sign-in, comma-separated: google, github, apple, microsoft").option("-y, --yes", "Don't ask: use the flags given and the defaults for the rest").option("--skip-install", "Write files without installing dependencies").example("flare create shop").example("flare create shop --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes").action(async (dir, options2) => {
|
|
3759
3960
|
const install = !options2.skipInstall;
|
|
3760
|
-
|
|
3961
|
+
let answers = { theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
|
|
3962
|
+
if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers);
|
|
3963
|
+
const result = createApp(dir, { pm: options2.pm, install, ...answers });
|
|
3761
3964
|
printNextSteps(result, install);
|
|
3762
3965
|
});
|
|
3763
|
-
cli.command("gen <generator> [name]", "Generate code. Generators: resource, migration, policy, billing, security").option("--fields <fields>",
|
|
3966
|
+
cli.command("gen <generator> [name]", "Generate code. Generators: resource, migration, policy, billing, security").option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration").option("--roles <roles>", "policy: roles allowed to read, create and update, e.g. admin,staff").option("--delete-roles <roles>", "policy: roles allowed to delete (default: the first --roles entry)").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing: write files without installing the stripe dependency").option("--skip-migration", "billing/security: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen migration backfill_contact_status").example("flare gen migration add_phone_to_contacts --from-schema").example("flare gen policy Invoice --roles admin,staff --delete-roles admin").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").action(
|
|
3764
3967
|
async (generator, name, options2) => {
|
|
3765
3968
|
if (generator === "billing")
|
|
3766
3969
|
return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
|
|
@@ -3802,6 +4005,7 @@ function createCli() {
|
|
|
3802
4005
|
cli.command("sync-types", "Regenerate schema, routes, clients, and validators from resource descriptors; report drift").option("--check", "Change nothing; exit 1 if anything is out of sync (CI)").option("--force", "Overwrite generated blocks that were edited by hand").action(async (options2) => {
|
|
3803
4006
|
process.exitCode = await syncTypes(options2);
|
|
3804
4007
|
});
|
|
4008
|
+
cli.command("theme [name]", "List the themes, or switch the app to one: default, coral, amber, sky, mono, emerald").example("flare theme").example("flare theme mono").action((name) => setTheme(name));
|
|
3805
4009
|
cli.command("tunnel [target]", "Share a local server on a public https://*.trycloudflare.com URL (Cloudflare Quick Tunnel; no account needed)").example("flare tunnel # http://localhost:3000").example("flare tunnel 8787").example("flare tunnel http://localhost:5173").action(async (target) => {
|
|
3806
4010
|
const local = toLocalUrl(target);
|
|
3807
4011
|
const tunnel = await openTunnel(local);
|
|
@@ -3837,6 +4041,6 @@ try {
|
|
|
3837
4041
|
}
|
|
3838
4042
|
}
|
|
3839
4043
|
} catch (error) {
|
|
3840
|
-
console.error(
|
|
4044
|
+
console.error(pc19.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
|
|
3841
4045
|
process.exitCode = 1;
|
|
3842
4046
|
}
|