@flaredev/cli 0.1.2 → 0.3.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.
Files changed (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
package/dist/index.js CHANGED
@@ -1,47 +1,105 @@
1
1
  // src/index.ts
2
- import pc18 from "picocolors";
2
+ import pc21 from "picocolors";
3
3
 
4
4
  // src/cli.ts
5
5
  import { cac } from "cac";
6
- import { FLARE_VERSION as FLARE_VERSION2 } from "@flaredev/core";
6
+ import * as prompts3 from "@clack/prompts";
7
+ import { FLARE_VERSION as FLARE_VERSION3 } from "@flaredev/core";
7
8
 
8
9
  // src/commands/create.ts
9
10
  import { randomBytes } from "crypto";
10
- import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
11
+ import { appendFileSync, copyFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
11
12
  import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
13
+ import * as prompts from "@clack/prompts";
12
14
  import pc from "picocolors";
13
15
  import { FLARE_VERSION } from "@flaredev/core";
14
16
 
15
17
  // src/auth-providers.ts
16
18
  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" }
19
+ google: { label: "Google", envPrefix: "GOOGLE", console: "https://console.cloud.google.com/apis/credentials", extra: [] },
20
+ github: { label: "GitHub", envPrefix: "GITHUB", console: "https://github.com/settings/developers", extra: [] },
21
+ apple: {
22
+ label: "Apple",
23
+ envPrefix: "APPLE",
24
+ console: "https://developer.apple.com/account/resources/identifiers/list/serviceId",
25
+ // The client secret is a JWT signed with your key; the bundle id is only for native apps.
26
+ extra: ["# APPLE_CLIENT_SECRET is a JWT you generate from your Sign in with Apple key (see the auth guide).", "APPLE_APP_BUNDLE_ID="]
27
+ },
28
+ microsoft: {
29
+ label: "Microsoft",
30
+ envPrefix: "MICROSOFT",
31
+ console: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
32
+ extra: ['# "common" (the default) accepts personal and work accounts; a tenant id limits sign-in to one organisation.', "MICROSOFT_TENANT_ID="]
33
+ }
19
34
  };
20
- function parseAuthProviders(input) {
21
- if (!input) return [];
35
+ var AUTH_METHODS = {
36
+ "magic-link": "Magic links (a sign-in link by email)",
37
+ "email-otp": "Email codes (a 6-digit sign-in code)",
38
+ passkeys: "Passkeys (Face ID, Touch ID, Windows Hello)",
39
+ "2fa-app": "Two-factor with an authenticator app, plus backup codes",
40
+ "2fa-email": "Two-factor with codes by email"
41
+ };
42
+ var DEFAULT_AUTH_METHODS = ["magic-link", "email-otp", "passkeys", "2fa-app", "2fa-email"];
43
+ function parseList(input, known, what) {
22
44
  const names = input.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
23
- const unknown = names.filter((name) => !(name in AUTH_PROVIDERS));
45
+ const unknown = names.filter((name) => !known.includes(name));
24
46
  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
- );
47
+ throw new Error(`Unknown ${what}${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Supported: ${known.join(", ")}.`);
28
48
  }
29
49
  return [...new Set(names)];
30
50
  }
31
- function socialProvidersCode(providers) {
32
- if (providers.length === 0) return "{}";
33
- const entries = providers.map((provider) => {
34
- const id = `env.${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_ID`;
35
- const secret = `env.${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_SECRET`;
36
- return ` ...(${id} && ${secret} ? { ${provider}: { clientId: ${id}, clientSecret: ${secret} } } : {}),`;
37
- });
38
- return [
39
- "{",
40
- " // Each provider switches on once its credentials are set: .dev.vars locally,",
41
- " // `wrangler secret put` in production.",
42
- ...entries,
43
- " }"
44
- ].join("\n");
51
+ function parseAuthProviders(input) {
52
+ if (!input) return [];
53
+ return parseList(input, Object.keys(AUTH_PROVIDERS), "auth provider");
54
+ }
55
+ function parseAuthMethods(input) {
56
+ if (input === void 0) return DEFAULT_AUTH_METHODS;
57
+ const value = input.trim().toLowerCase();
58
+ if (value === "all") return [...DEFAULT_AUTH_METHODS];
59
+ if (value === "none" || value === "") return [];
60
+ return parseList(value, Object.keys(AUTH_METHODS), "sign-in method");
61
+ }
62
+ function renderAuthConfig(methods, providers) {
63
+ const on = (method) => methods.includes(method);
64
+ return `/**
65
+ * How people sign in to this app. \`flare create\` wrote the choices you made; change
66
+ * them here any time. Every method's tables already exist, so switching one on or off
67
+ * needs no migration, and a method that's off is refused by the API, not just hidden.
68
+ *
69
+ * Email and password sign-in is always available.
70
+ */
71
+ export const authConfig = {
72
+ /** A sign-in link by email. */
73
+ magicLink: ${on("magic-link")},
74
+ /** A 6-digit sign-in code by email. */
75
+ emailOtp: ${on("email-otp")},
76
+ /** Face ID, Touch ID, Windows Hello or a security key. */
77
+ passkeys: ${on("passkeys")},
78
+ twoFactor: {
79
+ /** Codes from an authenticator app (TOTP), with backup codes. */
80
+ authenticator: ${on("2fa-app")},
81
+ /** Codes by email as the second step. */
82
+ email: ${on("2fa-email")},
83
+ },
84
+ /**
85
+ * Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
86
+ * without them its button stays hidden.
87
+ */
88
+ social: ${JSON.stringify(providers).replace(/","/g, '", "')} as SocialProvider[],
89
+ /** Refuse password sign-in until the email address is verified. */
90
+ requireEmailVerification: false,
91
+ /**
92
+ * Check new passwords against Have I Been Pwned's breach list. Only the first five
93
+ * characters of the password's hash are sent, and an outage lets the password through.
94
+ */
95
+ checkBreachedPasswords: true,
96
+ };
97
+
98
+ export type SocialProvider = "google" | "github" | "apple" | "microsoft";
99
+
100
+ /** Whether any second factor can be set up. */
101
+ export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
102
+ `;
45
103
  }
46
104
  function devVarsEntries(providers) {
47
105
  return providers.map((provider) => `${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_ID=
@@ -50,21 +108,39 @@ ${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_SECRET=
50
108
  }
51
109
  function devVarsExampleEntries(providers) {
52
110
  return providers.map((provider) => {
53
- const { envPrefix, console: console2 } = AUTH_PROVIDERS[provider];
111
+ const { label, envPrefix, console: console2, extra } = AUTH_PROVIDERS[provider];
54
112
  return [
55
113
  "",
56
- `# ${provider} OAuth app: ${console2}`,
114
+ `# ${label} sign-in: ${console2}`,
57
115
  `# Callback URL: <your app URL>/api/auth/callback/${provider}`,
58
116
  `${envPrefix}_CLIENT_ID=`,
59
117
  `${envPrefix}_CLIENT_SECRET=`,
118
+ ...extra,
60
119
  ""
61
120
  ].join("\n");
62
121
  }).join("");
63
122
  }
64
123
 
124
+ // src/themes.ts
125
+ var THEMES = {
126
+ default: "Calm and centred, indigo accents",
127
+ coral: "Warm and rounded, sign-in as a card over the page",
128
+ amber: "Plain and direct, boxed forms with pill buttons",
129
+ sky: "Crisp blue, bold headings, social sign-in first",
130
+ mono: "Black and white on a fine grid",
131
+ emerald: "Fresh green, sign-in beside a customer quote"
132
+ };
133
+ function parseTheme(value) {
134
+ if (value === void 0) return "default";
135
+ const name = value.trim().toLowerCase();
136
+ if (!(name in THEMES)) throw new Error(`Unknown theme "${value}". Themes: ${Object.keys(THEMES).join(", ")}.`);
137
+ return name;
138
+ }
139
+
65
140
  // src/versions.ts
66
141
  var APP_DEPENDENCIES = {
67
142
  "@better-auth/drizzle-adapter": "1.7.5",
143
+ "@better-auth/passkey": "1.7.5",
68
144
  // Server components import Slot from here rather than the "radix-ui" umbrella (see components/ui/button.tsx).
69
145
  "@radix-ui/react-slot": "1.3.3",
70
146
  "@vinext/cloudflare": "1.0.0-beta.8",
@@ -80,6 +156,14 @@ var APP_DEPENDENCIES = {
80
156
  sonner: "^2.0.8",
81
157
  "tailwind-merge": "^3.7.0",
82
158
  "tw-animate-css": "^1.4.0",
159
+ // Typefaces for the six themes (app/globals.css).
160
+ "@fontsource-variable/figtree": "5.3.0",
161
+ "@fontsource-variable/geist": "5.3.0",
162
+ "@fontsource-variable/inter": "5.3.0",
163
+ "@fontsource-variable/noto-sans": "5.3.0",
164
+ "@fontsource-variable/plus-jakarta-sans": "5.3.0",
165
+ // QR codes for authenticator-app (2FA) setup.
166
+ uqr: "0.1.3",
83
167
  "drizzle-orm": "^0.45.2",
84
168
  react: "19.3.0",
85
169
  "react-dom": "19.3.0",
@@ -101,6 +185,43 @@ var APP_DEV_DEPENDENCIES = {
101
185
  wrangler: "^4.132.0"
102
186
  };
103
187
 
188
+ // src/terminal.ts
189
+ var isTTY = () => Boolean(process.stdout.isTTY);
190
+ function formatDuration(ms) {
191
+ const seconds = Math.round(ms / 1e3);
192
+ if (seconds < 60) return `${seconds}s`;
193
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
194
+ return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, "0")}m`;
195
+ }
196
+ var formatCount = (value) => value.toLocaleString();
197
+ function formatRate(count, ms, unit = "rows") {
198
+ if (ms <= 0) return `${formatCount(count)} ${unit}/s`;
199
+ return `${formatCount(Math.round(count / ms * 1e3))} ${unit}/s`;
200
+ }
201
+ function progressLine(everyMs = 120) {
202
+ if (!isTTY()) return { update: () => {
203
+ }, done: () => {
204
+ } };
205
+ let last = 0;
206
+ let width = 0;
207
+ const write = (message) => {
208
+ process.stdout.write(`\r${message.padEnd(width)}`);
209
+ width = Math.max(width, message.length);
210
+ };
211
+ return {
212
+ update(message) {
213
+ const now = Date.now();
214
+ if (now - last < everyMs) return;
215
+ last = now;
216
+ write(message);
217
+ },
218
+ done() {
219
+ if (width > 0) process.stdout.write(`\r${" ".repeat(width)}\r`);
220
+ width = 0;
221
+ }
222
+ };
223
+ }
224
+
104
225
  // src/utils/fs.ts
105
226
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
106
227
  import { dirname, join, resolve } from "path";
@@ -108,16 +229,19 @@ import { fileURLToPath } from "url";
108
229
  var RENAMES = { _gitignore: ".gitignore" };
109
230
  function copyTemplate(from, to, tokens) {
110
231
  mkdirSync(to, { recursive: true });
232
+ let files = 0;
111
233
  for (const entry of readdirSync(from)) {
112
234
  const src = join(from, entry);
113
235
  const dest = join(to, RENAMES[entry] ?? entry);
114
236
  if (statSync(src).isDirectory()) {
115
- copyTemplate(src, dest, tokens);
237
+ files += copyTemplate(src, dest, tokens);
116
238
  continue;
117
239
  }
118
240
  const content = readFileSync(src, "utf8").replace(/__([A-Z_]+)__/g, (match, key) => tokens[key] ?? match);
119
241
  writeFileSync(dest, content);
242
+ files++;
120
243
  }
244
+ return files;
121
245
  }
122
246
  function writeJson(path, value) {
123
247
  mkdirSync(dirname(path), { recursive: true });
@@ -194,14 +318,17 @@ function createApp(target, options2 = {}) {
194
318
  }
195
319
  const packageManager = options2.pm ?? detectPackageManager();
196
320
  const authProviders = parseAuthProviders(options2.authProviders);
321
+ const authMethods = parseAuthMethods(options2.auth);
322
+ const theme = parseTheme(options2.theme);
197
323
  const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
198
324
  const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
199
- copyTemplate(join2(templatesDir, "app"), dir, {
325
+ const files = copyTemplate(join2(templatesDir, "app"), dir, {
200
326
  APP_NAME: name,
201
327
  COMPAT_DATE: compatibilityDate,
202
328
  PM: packageManager,
203
- SOCIAL_PROVIDERS: socialProvidersCode(authProviders)
329
+ THEME: theme
204
330
  });
331
+ writeFileSync2(join2(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders));
205
332
  appendFileSync(join2(dir, ".dev.vars.example"), devVarsExampleEntries(authProviders));
206
333
  writeJson(join2(dir, "package.json"), {
207
334
  name,
@@ -230,42 +357,40 @@ ${devVarsEntries(authProviders)}`
230
357
  if (packageManager === "pnpm" && !inWorkspace) {
231
358
  writeFileSync2(join2(dir, "pnpm-workspace.yaml"), "allowBuilds:\n esbuild: true\n workerd: true\n sharp: false\n");
232
359
  }
233
- const log = options2.log ?? ((message) => console.log(message));
360
+ const pinned = flarePackageSpec("core", dir, packageManager).startsWith("^");
361
+ if (pinned) copyLockfile(packageManager, dir);
362
+ const log2 = options2.log ?? ((message) => console.log(message));
363
+ log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
234
364
  if (options2.install !== false) {
235
- log(`${pc.green("\u2714")} Wrote the app to ${relative(process.cwd(), dir) || "."}`);
236
- log(`${pc.cyan("\u25CF")} Installing dependencies with ${packageManager}. The first install downloads the Workers runtime and toolchain, so it can take a few minutes.
237
- `);
238
365
  const started = Date.now();
239
- const code = run(packageManager, installArgs(packageManager), dir);
366
+ const code = (options2.installer ?? run)(packageManager, installArgs(packageManager), dir);
240
367
  if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
241
- log(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
242
- const typegen = runQuiet(packageManager, ["run", "cf-typegen"], dir);
243
- if (typegen.code !== 0) {
244
- process.stderr.write(typegen.output);
245
- throw new Error(`wrangler types failed (exit code ${typegen.code}).`);
246
- }
247
- log(`${pc.green("\u2714")} Generated types for the Cloudflare bindings`);
368
+ if (!options2.installer) log2(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
248
369
  }
249
370
  return { dir, name, packageManager, inWorkspace };
250
371
  }
251
- function formatDuration(ms) {
252
- const seconds = Math.round(ms / 1e3);
253
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
372
+ function copyLockfile(packageManager, dir) {
373
+ const file = packageManager === "npm" ? "package-lock.json" : packageManager === "pnpm" ? "pnpm-lock.yaml" : void 0;
374
+ if (!file) return false;
375
+ const source = join2(templatesDir, "locks", file);
376
+ if (!existsSync2(source)) return false;
377
+ copyFileSync(source, join2(dir, file));
378
+ return true;
254
379
  }
255
380
  function printNextSteps(result, installed) {
256
381
  const rel = relative(process.cwd(), result.dir) || ".";
257
382
  const pm = result.packageManager;
258
- const step = (command, note) => ` ${command.padEnd(30)}${pc.dim(note)}`;
259
- console.log(`
260
- ${pc.green("\u2714")} Created ${pc.bold(result.name)}
261
- `);
262
- console.log("Next steps:");
263
- console.log(` cd ${rel}`);
264
- if (!installed) console.log(` ${pm} install`);
265
- console.log(step(`${pm} run dev`, "start it at http://localhost:3000"));
266
- console.log(step("npx flare gen resource ...", "add your first resource"));
267
- console.log(`
268
- Quickstart: ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
383
+ const commands = [
384
+ [`cd ${rel}`, "your new app"],
385
+ ...installed ? [] : [[`${pm} install`, "dependencies first"]],
386
+ [`${pm} run dev`, "http://localhost:3000"],
387
+ ["npx flare gen resource Contact", "a table, API and admin screens"],
388
+ ["npx flare seed:resource Contact 1000", "fill it with sample rows"]
389
+ ];
390
+ const width = Math.max(...commands.map(([command]) => command.length)) + 2;
391
+ const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
392
+ prompts.note(steps.join("\n"), "Next");
393
+ prompts.outro(`${pc.bold(result.name)} is ready ${pc.dim("\xB7")} ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
269
394
  }
270
395
 
271
396
  // src/commands/gen-billing.ts
@@ -274,9 +399,10 @@ import { dirname as dirname6, join as join11 } from "path";
274
399
  import pc6 from "picocolors";
275
400
 
276
401
  // src/generator/grammar.ts
277
- import { camelCase, FILE_CATEGORIES } from "@flaredev/core";
402
+ import { camelCase, FILE_CATEGORIES, STRING_FORMATS } from "@flaredev/core";
278
403
  var SIMPLE_KINDS = ["string", "text", "int", "float", "boolean", "date", "datetime"];
279
- var ALL_KINDS = [...SIMPLE_KINDS, "enum", "file", "belongsTo", "hasMany"];
404
+ var FORMAT_ALIASES = { phone: "tel" };
405
+ var ALL_KINDS = [...SIMPLE_KINDS, ...STRING_FORMATS, "phone", "enum", "select", "radio", "multiselect", "file", "belongsTo", "hasMany"];
280
406
  var FieldGrammarError = class extends Error {
281
407
  constructor(message) {
282
408
  super(message);
@@ -344,15 +470,21 @@ function parseField(spec) {
344
470
  let match;
345
471
  if (SIMPLE_KINDS.includes(type)) {
346
472
  field.kind = type;
347
- } else if (match = /^enum\((.*)\)$/.exec(type)) {
348
- field.kind = "enum";
349
- const options2 = list(match[1], "enum option", spec);
473
+ } else if (STRING_FORMATS.includes(type) || type in FORMAT_ALIASES) {
474
+ field.kind = "string";
475
+ field.format = FORMAT_ALIASES[type] ?? type;
476
+ } else if (match = /^(enum|select|radio|multiselect)\((.*)\)$/.exec(type)) {
477
+ const choice = match[1];
478
+ field.kind = choice === "multiselect" ? "multiselect" : "enum";
479
+ if (choice === "radio") field.widget = "radio";
480
+ if (choice === "multiselect" && unique) throw new FieldGrammarError(`"${spec}": multiselect fields can't be unique.`);
481
+ const options2 = list(match[2], `${choice} option`, spec);
350
482
  for (const option of options2) {
351
483
  if (!/^[A-Za-z0-9_-]+$/.test(option)) {
352
- throw new FieldGrammarError(`"${spec}": enum option "${option}" may only use letters, digits, _ and -.`);
484
+ throw new FieldGrammarError(`"${spec}": ${choice} option "${option}" may only use letters, digits, _ and -.`);
353
485
  }
354
486
  }
355
- if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate enum options.`);
487
+ if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate ${choice} options.`);
356
488
  field.options = options2;
357
489
  } else if (match = /^file(?::\[(.*)\])?$/.exec(type)) {
358
490
  field.kind = "file";
@@ -387,7 +519,7 @@ function parseField(spec) {
387
519
  const base = type.replace(/[([].*$/, "").replace(/:$/, "");
388
520
  throw new FieldGrammarError(`"${spec}": unknown type "${type}".${suggest(base, ALL_KINDS)} Types: ${ALL_KINDS.join(", ")}.`);
389
521
  }
390
- if (field.kind === "string") {
522
+ if (field.kind === "string" && !field.format) {
391
523
  if (/^email$|Email$/.test(key)) field.format = "email";
392
524
  else if (/^(url|website)$|(Url|Website)$/.test(key)) field.format = "url";
393
525
  }
@@ -505,6 +637,7 @@ function options(field) {
505
637
  if (!field.required) opts.required = false;
506
638
  if (field.unique) opts.unique = true;
507
639
  if (field.format) opts.format = field.format;
640
+ if (field.widget) opts.widget = field.widget;
508
641
  if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
509
642
  return opts;
510
643
  }
@@ -513,6 +646,8 @@ function toField(field) {
513
646
  switch (field.kind) {
514
647
  case "enum":
515
648
  return builders.enum(field.options, opts);
649
+ case "multiselect":
650
+ return builders.multiselect(field.options, opts);
516
651
  case "file":
517
652
  return builders.file(field.accept, opts);
518
653
  case "belongsTo":
@@ -534,6 +669,8 @@ function renderField(field) {
534
669
  switch (field.kind) {
535
670
  case "enum":
536
671
  return `${field.key}: field.enum(${args(literal(field.options), opts)}),`;
672
+ case "multiselect":
673
+ return `${field.key}: field.multiselect(${args(literal(field.options), opts)}),`;
537
674
  case "file":
538
675
  return `${field.key}: field.file(${args(literal(field.accept), opts)}),`;
539
676
  case "belongsTo":
@@ -1302,12 +1439,12 @@ function onPath() {
1302
1439
  const probe = spawnSync2(process.platform === "win32" ? "where" : "which", ["cloudflared"], { encoding: "utf8" });
1303
1440
  return probe.status === 0 ? probe.stdout.split(/\r?\n/)[0]?.trim() ?? null : null;
1304
1441
  }
1305
- async function download(log) {
1442
+ async function download(log2) {
1306
1443
  const asset = releaseAsset();
1307
1444
  const dir = cacheDir();
1308
1445
  mkdirSync3(dir, { recursive: true });
1309
1446
  const target = join4(dir, binName);
1310
- log(pc2.dim(`Downloading cloudflared (one time) into ${dir}\u2026`));
1447
+ log2(pc2.dim(`Downloading cloudflared (one time) into ${dir}\u2026`));
1311
1448
  const response = await fetch(`https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`);
1312
1449
  if (!response.ok) throw new Error(`Downloading cloudflared failed: HTTP ${response.status}.`);
1313
1450
  const bytes = Buffer.from(await response.arrayBuffer());
@@ -1324,12 +1461,12 @@ async function download(log) {
1324
1461
  if (process.platform !== "win32") chmodSync(target, 493);
1325
1462
  return target;
1326
1463
  }
1327
- async function ensureCloudflared(log = console.log) {
1464
+ async function ensureCloudflared(log2 = console.log) {
1328
1465
  const found = onPath();
1329
1466
  if (found) return found;
1330
1467
  const cached = join4(cacheDir(), binName);
1331
1468
  if (existsSync5(cached)) return cached;
1332
- return download(log);
1469
+ return download(log2);
1333
1470
  }
1334
1471
  async function openTunnel(localUrl, options2 = {}) {
1335
1472
  const bin = await ensureCloudflared(options2.log);
@@ -1483,9 +1620,9 @@ async function runDeploy(args, context3) {
1483
1620
  if (plan.passthroughOnly) return context3.deploy(plan.forwarded);
1484
1621
  const { appRoot, wranglerBin } = context3;
1485
1622
  const shellEnv = context3.env ?? process.env;
1486
- const log = context3.log ?? ((message) => console.log(message));
1623
+ const log2 = context3.log ?? ((message) => console.log(message));
1487
1624
  const envArgs = plan.env ? ["--env", plan.env] : [];
1488
- const step = (message) => log(`
1625
+ const step = (message) => log2(`
1489
1626
  ${pc3.bold(pc3.cyan("flare"))} ${message}`);
1490
1627
  const databases = plan.skipMigrations ? [] : readD1Databases(appRoot).filter((db) => existsSync7(join6(appRoot, db.migrations_dir ?? "migrations")));
1491
1628
  const migrateAfterDeploy = [];
@@ -1517,7 +1654,7 @@ ${info.output.trim()}`);
1517
1654
  const apiToken = shellEnv.FLARE_SECURITY_API_TOKEN;
1518
1655
  if (security && zoneId && apiToken) {
1519
1656
  step("Provisioning zone security rules");
1520
- await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log, fetch: context3.fetch });
1657
+ await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log: log2, fetch: context3.fetch });
1521
1658
  } else if (security) {
1522
1659
  step("Zone security rules skipped (Worker-layer protection is on). To add them, deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set.");
1523
1660
  }
@@ -1550,7 +1687,7 @@ ${put.output.trim()}`);
1550
1687
  const optional = existsSync7(examplePath) ? declaredSecretNames(readFileSync4(examplePath, "utf8")).filter((name) => !existing.has(name)) : [];
1551
1688
  if (optional.length > 0) {
1552
1689
  step("Optional secrets not set in production (features using them stay off):");
1553
- for (const name of optional) log(` wrangler secret put ${name}${plan.env ? ` --env ${plan.env}` : ""}`);
1690
+ for (const name of optional) log2(` wrangler secret put ${name}${plan.env ? ` --env ${plan.env}` : ""}`);
1554
1691
  }
1555
1692
  }
1556
1693
  return 0;
@@ -1757,7 +1894,7 @@ function previousSnapshot(dir, migrationFile) {
1757
1894
  }
1758
1895
 
1759
1896
  // src/generator/plan.ts
1760
- import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
1897
+ import { copyFileSync as copyFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
1761
1898
  import { dirname as dirname5, join as join10 } from "path";
1762
1899
  import pc5 from "picocolors";
1763
1900
 
@@ -1843,6 +1980,9 @@ function columnExpression(key, def, resources, selfName) {
1843
1980
  case "enum":
1844
1981
  expr = `text(${col}, { enum: ${q(def.options)} })`;
1845
1982
  break;
1983
+ case "multiselect":
1984
+ expr = `text(${col}, { mode: "json" }).$type<(${def.options.map((option) => q(option)).join(" | ")})[]>()`;
1985
+ break;
1846
1986
  case "belongsTo": {
1847
1987
  const target = resources.get(def.target);
1848
1988
  if (!target) {
@@ -1856,7 +1996,7 @@ function columnExpression(key, def, resources, selfName) {
1856
1996
  expr = `text(${col})`;
1857
1997
  }
1858
1998
  if (def.required) expr += ".notNull()";
1859
- if (def.unique) expr += ".unique()";
1999
+ if ("unique" in def && def.unique) expr += ".unique()";
1860
2000
  if ("default" in def && def.default !== void 0) expr += `.default(${q(def.default)})`;
1861
2001
  return expr;
1862
2002
  }
@@ -1983,16 +2123,24 @@ var adminImports = (entry) => [
1983
2123
  function renderAdminListPage(entry) {
1984
2124
  const local = resourceLocal(entry.stem);
1985
2125
  return [
1986
- `import { ResourceTable } from "@/components/admin/resource-table";`,
1987
- `import type { SearchParams } from "@/components/admin/query";`,
2126
+ `import { PageHeader } from "@/components/dashboard/page-header";`,
2127
+ `import { ResourceStats } from "@/components/dashboard/resource-stats";`,
2128
+ `import { ResourceTable } from "@/components/dashboard/resource-table";`,
2129
+ `import type { SearchParams } from "@/components/dashboard/query";`,
1988
2130
  ...adminImports(entry),
1989
2131
  "",
2132
+ `export const metadata = { title: ${local}.pluralLabel };`,
2133
+ "",
1990
2134
  `export default async function ${entry.resource.name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {`,
1991
2135
  " return (",
1992
- ` <div className="flex flex-col gap-6">`,
1993
- ` <h1 className="text-2xl font-semibold">{${local}.pluralLabel}</h1>`,
2136
+ " <>",
2137
+ " <PageHeader",
2138
+ ` title={${local}.pluralLabel}`,
2139
+ ` crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: ${local}.pluralLabel }]}`,
2140
+ " />",
2141
+ ` <ResourceStats resource={${local}} />`,
1994
2142
  ` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
1995
- " </div>",
2143
+ " </>",
1996
2144
  " );",
1997
2145
  "}",
1998
2146
  ""
@@ -2000,7 +2148,7 @@ function renderAdminListPage(entry) {
2000
2148
  }
2001
2149
  function renderAdminNewPage(entry) {
2002
2150
  return [
2003
- `import { ResourceFormPage } from "@/components/admin/resource-form-page";`,
2151
+ `import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
2004
2152
  ...adminImports(entry),
2005
2153
  "",
2006
2154
  `export default function New${entry.resource.name}Page() {`,
@@ -2011,7 +2159,7 @@ function renderAdminNewPage(entry) {
2011
2159
  }
2012
2160
  function renderAdminEditPage(entry) {
2013
2161
  return [
2014
- `import { ResourceFormPage } from "@/components/admin/resource-form-page";`,
2162
+ `import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
2015
2163
  ...adminImports(entry),
2016
2164
  "",
2017
2165
  `export default async function Edit${entry.resource.name}Page({ params }: { params: Promise<{ id: string }> }) {`,
@@ -2079,9 +2227,9 @@ function resourceFiles(entry, all) {
2079
2227
  { path: `app/api/${resource.slug}/[id]/route.ts`, content: renderItemRoute(entry) },
2080
2228
  { path: `resources/${stem}.client.ts`, content: renderClient(entry) },
2081
2229
  { path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
2082
- { path: `app/admin/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
2083
- { path: `app/admin/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
2084
- { path: `app/admin/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
2230
+ { path: `app/dashboard/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
2231
+ { path: `app/dashboard/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
2232
+ { path: `app/dashboard/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
2085
2233
  ];
2086
2234
  }
2087
2235
 
@@ -2141,7 +2289,7 @@ function findOrphans(appRoot, all) {
2141
2289
  for (const dir of list2("app/api", () => true)) {
2142
2290
  for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync11(join10(appRoot, file))) candidates.push(file);
2143
2291
  }
2144
- for (const dir of list2("app/admin", () => true)) {
2292
+ for (const dir of list2("app/dashboard", () => true)) {
2145
2293
  for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
2146
2294
  if (existsSync11(join10(appRoot, file))) candidates.push(file);
2147
2295
  }
@@ -2153,20 +2301,20 @@ function findOrphans(appRoot, all) {
2153
2301
  }
2154
2302
  return orphans;
2155
2303
  }
2156
- function ensureSupportFiles(appRoot, log) {
2304
+ function ensureSupportFiles(appRoot, log2) {
2157
2305
  for (const path of ["lib/api.ts", "lib/cache.ts"]) {
2158
2306
  const target = join10(appRoot, path);
2159
2307
  if (existsSync11(target)) continue;
2160
2308
  mkdirSync4(dirname5(target), { recursive: true });
2161
- copyFileSync(join10(templatesDir, "app", path), target);
2162
- log(`${pc5.green("create".padEnd(9))} ${path}`);
2309
+ copyFileSync2(join10(templatesDir, "app", path), target);
2310
+ log2(`${pc5.green("create".padEnd(9))} ${path}`);
2163
2311
  }
2164
2312
  }
2165
- function logResults(results, log, options2 = {}) {
2313
+ function logResults(results, log2, options2 = {}) {
2166
2314
  for (const { path, outcome } of results) {
2167
2315
  if (outcome === "identical" && !options2.showIdentical) continue;
2168
2316
  const label = outcome === "create" ? pc5.green("create") : outcome === "update" ? pc5.yellow("update") : outcome === "identical" ? pc5.dim("identical") : pc5.red(outcome === "skipped-drift" ? "drift" : "hand-written");
2169
- log(`${label.padEnd(18)} ${path}`);
2317
+ log2(`${label.padEnd(18)} ${path}`);
2170
2318
  }
2171
2319
  }
2172
2320
 
@@ -2201,20 +2349,20 @@ function addStripeDependency(appRoot) {
2201
2349
  writeJson(path, pkg);
2202
2350
  return true;
2203
2351
  }
2204
- function writeBillingResource(appRoot, name, force, log) {
2352
+ function writeBillingResource(appRoot, name, force, log2) {
2205
2353
  const relative2 = descriptorPath(name);
2206
2354
  const path = join11(appRoot, relative2);
2207
2355
  if (!existsSync12(path)) {
2208
2356
  mkdirSync5(dirname6(path), { recursive: true });
2209
2357
  writeFileSync6(path, renderBillingDescriptor(name));
2210
- log(`${pc6.green("create".padEnd(9))} ${relative2}`);
2358
+ log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
2211
2359
  return;
2212
2360
  }
2213
2361
  const parts = splitMarkers(readFileSync8(path, "utf8"));
2214
2362
  if (!parts) throw new Error(`${relative2} has no generated fields block, so the billing fields can't be merged. Add them by hand: ${BILLING_RESOURCES[name]}`);
2215
2363
  const merged = mergeBillingFields(name, parts.block);
2216
2364
  if (!merged) {
2217
- log(`${pc6.dim("identical".padEnd(9))} ${relative2} (has every billing field)`);
2365
+ log2(`${pc6.dim("identical".padEnd(9))} ${relative2} (has every billing field)`);
2218
2366
  return;
2219
2367
  }
2220
2368
  try {
@@ -2225,22 +2373,22 @@ function writeBillingResource(appRoot, name, force, log) {
2225
2373
  }
2226
2374
  throw error;
2227
2375
  }
2228
- log(`${pc6.yellow("update".padEnd(9))} ${relative2} (added billing fields, kept yours)`);
2376
+ log2(`${pc6.yellow("update".padEnd(9))} ${relative2} (added billing fields, kept yours)`);
2229
2377
  }
2230
2378
  async function genBilling(options2 = {}) {
2231
- const log = options2.log ?? ((message) => console.log(message));
2379
+ const log2 = options2.log ?? ((message) => console.log(message));
2232
2380
  validateBillingOptions(options2.provider, options2.mode);
2233
2381
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2234
- for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force, log);
2382
+ for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force, log2);
2235
2383
  for (const [name, roles] of Object.entries(BILLING_POLICIES)) {
2236
2384
  const relative2 = policyPath(name);
2237
2385
  if (existsSync12(join11(appRoot, relative2))) {
2238
- log(`${pc6.dim("kept".padEnd(9))} ${relative2} (already exists)`);
2386
+ log2(`${pc6.dim("kept".padEnd(9))} ${relative2} (already exists)`);
2239
2387
  continue;
2240
2388
  }
2241
2389
  mkdirSync5(join11(appRoot, "policies"), { recursive: true });
2242
2390
  writeFileSync6(join11(appRoot, relative2), renderPolicy(name, roles));
2243
- log(`${pc6.green("create".padEnd(9))} ${relative2}`);
2391
+ log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
2244
2392
  }
2245
2393
  const files = [];
2246
2394
  const appName = JSON.parse(readFileSync8(join11(appRoot, "package.json"), "utf8")).name ?? "app";
@@ -2248,7 +2396,7 @@ async function genBilling(options2 = {}) {
2248
2396
  const outcome = writeGenerated(join11(appRoot, path), content, { header: billingHeader(), force: options2.force });
2249
2397
  files.push({ path, status: "missing", outcome });
2250
2398
  const label = outcome === "create" ? pc6.green("create") : outcome === "update" ? pc6.yellow("update") : pc6.dim("identical");
2251
- log(`${label.padEnd(18)} ${path}`);
2399
+ log2(`${label.padEnd(18)} ${path}`);
2252
2400
  }
2253
2401
  for (const path of RETIRED_FILES) {
2254
2402
  const absolute = join11(appRoot, path);
@@ -2257,15 +2405,15 @@ async function genBilling(options2 = {}) {
2257
2405
  const untouched = parts && parts.before.includes("flare gen billing") && !parts.after.trim() && parts.hash !== void 0 && hashBlock(parts.block) === parts.hash;
2258
2406
  if (untouched) {
2259
2407
  unlinkSync(absolute);
2260
- log(`${pc6.red("remove".padEnd(9))} ${path} (no longer generated)`);
2408
+ log2(`${pc6.red("remove".padEnd(9))} ${path} (no longer generated)`);
2261
2409
  } else {
2262
- log(pc6.yellow(`${path} is no longer generated but has your changes; remove it when you're done with it.`));
2410
+ log2(pc6.yellow(`${path} is no longer generated but has your changes; remove it when you're done with it.`));
2263
2411
  }
2264
2412
  }
2265
- if (appendVars(appRoot, ".dev.vars.example")) log(`${pc6.green("append".padEnd(9))} .dev.vars.example`);
2266
- if (appendVars(appRoot, ".dev.vars")) log(`${pc6.green("append".padEnd(9))} .dev.vars`);
2413
+ if (appendVars(appRoot, ".dev.vars.example")) log2(`${pc6.green("append".padEnd(9))} .dev.vars.example`);
2414
+ if (appendVars(appRoot, ".dev.vars")) log2(`${pc6.green("append".padEnd(9))} .dev.vars`);
2267
2415
  const addedStripe = addStripeDependency(appRoot);
2268
- if (addedStripe) log(`${pc6.yellow("update".padEnd(9))} package.json (stripe@${STRIPE_DEP})`);
2416
+ if (addedStripe) log2(`${pc6.yellow("update".padEnd(9))} package.json (stripe@${STRIPE_DEP})`);
2269
2417
  if (!options2.skipInstall) {
2270
2418
  const pm = detectPackageManager();
2271
2419
  const code = run(pm, ["install"], appRoot);
@@ -2273,16 +2421,16 @@ async function genBilling(options2 = {}) {
2273
2421
  run(pm, ["run", "cf-typegen"], appRoot);
2274
2422
  }
2275
2423
  const all = await loadResources(appRoot);
2276
- ensureSupportFiles(appRoot, log);
2277
- logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log);
2424
+ ensureSupportFiles(appRoot, log2);
2425
+ logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
2278
2426
  let migrations = [];
2279
2427
  if (!options2.skipMigration) {
2280
2428
  const result = await generateSchemaMigration(appRoot, "billing");
2281
2429
  migrations = result.files;
2282
- for (const file of migrations) log(`${pc6.green("create".padEnd(9))} ${file}`);
2283
- for (const repair of result.repairs) log(pc6.dim(`repaired ${repair}`));
2430
+ for (const file of migrations) log2(`${pc6.green("create".padEnd(9))} ${file}`);
2431
+ for (const repair of result.repairs) log2(pc6.dim(`repaired ${repair}`));
2284
2432
  }
2285
- log(
2433
+ log2(
2286
2434
  [
2287
2435
  "",
2288
2436
  `Next:`,
@@ -2424,7 +2572,7 @@ var renderSecurityActions = () => `"use server";
2424
2572
  import { revalidatePath } from "next/cache";
2425
2573
  import { can } from "@flaredev/core";
2426
2574
  import { isIp } from "@flaredev/core/security";
2427
- import { adminSession, policyFor } from "@/lib/admin";
2575
+ import { dashboardSession, policyFor } from "@/lib/dashboard";
2428
2576
  import { logSecurityEvent, security } from "@/lib/security";
2429
2577
 
2430
2578
  /** On failure the submitted values come back, so the form (which React resets) keeps them. */
@@ -2433,7 +2581,7 @@ export type BanResult = { ok: true } | { ok: false; error: string; ip?: string;
2433
2581
  const DURATIONS: Record<string, number | null> = { "1h": 3600, "24h": 86_400, "7d": 604_800, permanent: null };
2434
2582
 
2435
2583
  async function adminOnly(): Promise<{ email: string } | { error: string }> {
2436
- const { session, role } = await adminSession();
2584
+ const { session, role } = await dashboardSession();
2437
2585
  if (!session) return { error: "Sign in required." };
2438
2586
  if (!can(policyFor("SecurityEvent"), role, "create")) return { error: "Only admins can ban or unban." };
2439
2587
  return { email: session.user.email };
@@ -2454,8 +2602,8 @@ export async function banAction(_previous: BanResult | null, form: FormData): Pr
2454
2602
  } catch (error) {
2455
2603
  return { ok: false, error: \`The ban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\`, ip, reason };
2456
2604
  }
2457
- await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/admin/security", userAgent: null, count: 0, banned: true, detail: \`\${reason} (by \${admin.email})\` });
2458
- revalidatePath("/admin/security");
2605
+ await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: true, detail: \`\${reason} (by \${admin.email})\` });
2606
+ revalidatePath("/dashboard/security");
2459
2607
  return { ok: true };
2460
2608
  }
2461
2609
 
@@ -2468,8 +2616,8 @@ export async function unbanAction(ip: string): Promise<BanResult> {
2468
2616
  } catch (error) {
2469
2617
  return { ok: false, error: \`The unban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\` };
2470
2618
  }
2471
- await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/admin/security", userAgent: null, count: 0, banned: false, detail: \`by \${admin.email}\` });
2472
- revalidatePath("/admin/security");
2619
+ await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: false, detail: \`by \${admin.email}\` });
2620
+ revalidatePath("/dashboard/security");
2473
2621
  return { ok: true };
2474
2622
  }
2475
2623
  `;
@@ -2562,7 +2710,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
2562
2710
  import { Separator } from "@/components/ui/separator";
2563
2711
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
2564
2712
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
2565
- import { adminStore, policyFor, requireAdmin } from "@/lib/admin";
2713
+ import { dashboardStore, policyFor, requireDashboard } from "@/lib/dashboard";
2566
2714
  import { security, zoneClient } from "@/lib/security";
2567
2715
  import { BanForm, UnbanButton } from "./ban-controls";
2568
2716
 
@@ -2571,7 +2719,7 @@ const when = (value: string | number | Date | null) =>
2571
2719
  value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Until unbanned";
2572
2720
 
2573
2721
  export default async function SecurityPage() {
2574
- const { role } = await requireAdmin("/admin/security");
2722
+ const { role } = await requireDashboard("/dashboard/security");
2575
2723
  if (!can(policyFor("SecurityEvent"), role, "read")) {
2576
2724
  return (
2577
2725
  <Alert>
@@ -2584,7 +2732,7 @@ export default async function SecurityPage() {
2584
2732
 
2585
2733
  const [bans, events, edge] = await Promise.all([
2586
2734
  security().bans(),
2587
- adminStore("SecurityEvent").list(new URLSearchParams({ sort: "-createdAt", perPage: "20" })),
2735
+ dashboardStore("SecurityEvent").list(new URLSearchParams({ sort: "-createdAt", perPage: "20" })),
2588
2736
  zone
2589
2737
  ? Promise.all([zone.bans(), zone.firewallEvents(new Date(Date.now() - 24 * 3600 * 1000))]).then(
2590
2738
  ([zoneBans, firewall]) => ({ ok: true as const, zoneBans, firewall }),
@@ -2695,7 +2843,7 @@ export default async function SecurityPage() {
2695
2843
  <CardHeader>
2696
2844
  <CardTitle>Recent events</CardTitle>
2697
2845
  <CardDescription>
2698
- Detectors that tripped, and manual bans. <Link href="/admin/security-events" className="underline underline-offset-4">All events</Link>
2846
+ Detectors that tripped, and manual bans. <Link href="/dashboard/security-events" className="underline underline-offset-4">All events</Link>
2699
2847
  </CardDescription>
2700
2848
  </CardHeader>
2701
2849
  <CardContent>
@@ -2805,43 +2953,43 @@ function addSecurityBindings(source) {
2805
2953
  }
2806
2954
  return { text, added };
2807
2955
  }
2808
- var renderAdminNavBlock = () => `export interface AdminLink {
2956
+ var renderAdminNavBlock = () => `export interface DashboardLink {
2809
2957
  label: string;
2810
2958
  href: string;
2811
- /** A resource icon name, e.g. "shield" (see components/admin/resource-icon.tsx). */
2959
+ /** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
2812
2960
  icon: string;
2813
2961
  }
2814
2962
 
2815
- export const generatedAdminLinks: AdminLink[] = [{ label: "Security", href: "/admin/security", icon: "shield" }];
2963
+ export const generatedDashboardLinks: DashboardLink[] = [{ label: "Security", href: "/dashboard/security", icon: "shield" }];
2816
2964
  `;
2817
2965
 
2818
2966
  // src/commands/gen-security.ts
2819
2967
  function securityFiles(appName) {
2820
2968
  return [
2821
2969
  { path: "lib/security.ts", content: renderLibSecurity(appName) },
2822
- { path: "app/admin/security/page.tsx", content: renderSecurityPage() },
2823
- { path: "app/admin/security/actions.ts", content: renderSecurityActions() },
2824
- { path: "app/admin/security/ban-controls.tsx", content: renderBanControls() }
2970
+ { path: "app/dashboard/security/page.tsx", content: renderSecurityPage() },
2971
+ { path: "app/dashboard/security/actions.ts", content: renderSecurityActions() },
2972
+ { path: "app/dashboard/security/ban-controls.tsx", content: renderBanControls() }
2825
2973
  ];
2826
2974
  }
2827
2975
  var PASS_THROUGH_HEADER = /^\/\/ Request protection for worker\/index\.ts\.[\s\S]*?\n(?=\/\/ generated:start)/;
2828
- function writeOnce(appRoot, relative2, content, log) {
2976
+ function writeOnce(appRoot, relative2, content, log2) {
2829
2977
  const path = join12(appRoot, relative2);
2830
2978
  if (existsSync13(path)) {
2831
- log(`${pc7.dim("kept".padEnd(9))} ${relative2} (already exists)`);
2979
+ log2(`${pc7.dim("kept".padEnd(9))} ${relative2} (already exists)`);
2832
2980
  return;
2833
2981
  }
2834
2982
  mkdirSync6(dirname7(path), { recursive: true });
2835
2983
  writeFileSync7(path, content);
2836
- log(`${pc7.green("create".padEnd(9))} ${relative2}`);
2984
+ log2(`${pc7.green("create".padEnd(9))} ${relative2}`);
2837
2985
  }
2838
2986
  async function genSecurity(options2 = {}) {
2839
- const log = options2.log ?? ((message) => console.log(message));
2987
+ const log2 = options2.log ?? ((message) => console.log(message));
2840
2988
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2841
2989
  const appName = JSON.parse(readFileSync9(join12(appRoot, "package.json"), "utf8")).name ?? "app";
2842
- writeOnce(appRoot, "security.config.ts", renderSecurityConfig(), log);
2843
- writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(), log);
2844
- writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY), log);
2990
+ writeOnce(appRoot, "security.config.ts", renderSecurityConfig(), log2);
2991
+ writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(), log2);
2992
+ writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY), log2);
2845
2993
  const files = [];
2846
2994
  const write = (path, content, header) => {
2847
2995
  const absolute = join12(appRoot, path);
@@ -2852,12 +3000,12 @@ async function genSecurity(options2 = {}) {
2852
3000
  const outcome = writeGenerated(absolute, content, { header, force: options2.force });
2853
3001
  files.push({ path, status: "missing", outcome });
2854
3002
  const label = outcome === "create" ? pc7.green("create") : outcome === "update" ? pc7.yellow("update") : pc7.dim("identical");
2855
- log(`${label.padEnd(18)} ${path}`);
3003
+ log2(`${label.padEnd(18)} ${path}`);
2856
3004
  };
2857
3005
  for (const { path, content } of securityFiles(appName)) write(path, content, securityHeader());
2858
- const navPath = join12(appRoot, "lib/admin-nav.ts");
2859
- if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/admin-nav.ts", renderAdminNavBlock());
2860
- else log(pc7.yellow(`lib/admin-nav.ts not found: link to /admin/security from your admin sidebar yourself.`));
3006
+ const navPath = join12(appRoot, "lib/dashboard-nav.ts");
3007
+ if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/dashboard-nav.ts", renderAdminNavBlock());
3008
+ else log2(pc7.yellow(`lib/dashboard-nav.ts not found: link to /dashboard/security from your dashboard sidebar yourself.`));
2861
3009
  const wranglerPath = join12(appRoot, "wrangler.jsonc");
2862
3010
  let bindings = [];
2863
3011
  if (existsSync13(wranglerPath)) {
@@ -2865,27 +3013,27 @@ async function genSecurity(options2 = {}) {
2865
3013
  bindings = added;
2866
3014
  if (added.length) {
2867
3015
  writeFileSync7(wranglerPath, text);
2868
- for (const line of added) log(`${pc7.yellow("update".padEnd(9))} wrangler.jsonc (${line})`);
3016
+ for (const line of added) log2(`${pc7.yellow("update".padEnd(9))} wrangler.jsonc (${line})`);
2869
3017
  }
2870
3018
  } else {
2871
- log(pc7.yellow("wrangler.jsonc not found: add the FLARE_SECURITY, FLARE_SECURITY_MONITOR and FLARE_RATE_LIMIT bindings yourself."));
3019
+ log2(pc7.yellow("wrangler.jsonc not found: add the FLARE_SECURITY, FLARE_SECURITY_MONITOR and FLARE_RATE_LIMIT bindings yourself."));
2872
3020
  }
2873
3021
  const all = await loadResources(appRoot);
2874
- ensureSupportFiles(appRoot, log);
2875
- logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log);
3022
+ ensureSupportFiles(appRoot, log2);
3023
+ logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
2876
3024
  let migrations = [];
2877
3025
  if (!options2.skipMigration) {
2878
3026
  const result = await generateSchemaMigration(appRoot, "security");
2879
3027
  migrations = result.files;
2880
- for (const file of migrations) log(`${pc7.green("create".padEnd(9))} ${file}`);
2881
- for (const repair of result.repairs) log(pc7.dim(`repaired ${repair}`));
3028
+ for (const file of migrations) log2(`${pc7.green("create".padEnd(9))} ${file}`);
3029
+ for (const repair of result.repairs) log2(pc7.dim(`repaired ${repair}`));
2882
3030
  }
2883
- log(
3031
+ log2(
2884
3032
  [
2885
3033
  "",
2886
3034
  `Next:`,
2887
3035
  ` 1. ${pc7.bold("flare migrate")} to create the security_events table.`,
2888
- ` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/admin/security")}. Tune detectors in security.config.ts.`,
3036
+ ` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/dashboard/security")}. Tune detectors in security.config.ts.`,
2889
3037
  ` 3. Optional zone layer: deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set (a token scoped to that`,
2890
3038
  ` one zone with Zone WAF Edit, Firewall Services Edit, Analytics Read, Zone Read). ${pc7.bold("flare deploy")} pushes the`,
2891
3039
  ` zone rules and uploads both as secrets.`
@@ -2894,12 +3042,103 @@ async function genSecurity(options2 = {}) {
2894
3042
  return { files, migrations, bindings };
2895
3043
  }
2896
3044
 
3045
+ // src/commands/theme.ts
3046
+ import { existsSync as existsSync14, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
3047
+ import { join as join13 } from "path";
3048
+ import pc8 from "picocolors";
3049
+ var THEME_LINE = /theme: "([a-z]+)" as ThemeName,/;
3050
+ function setTheme(name, cwd = process.cwd(), log2 = console.log) {
3051
+ const appRoot = findAppRoot(cwd);
3052
+ const path = join13(appRoot, "lib/site.ts");
3053
+ if (!existsSync14(path)) throw new Error("lib/site.ts not found: this app was created before themes. Add it from the Flare app template.");
3054
+ const source = readFileSync10(path, "utf8");
3055
+ const current = THEME_LINE.exec(source)?.[1];
3056
+ if (!name) {
3057
+ for (const [theme2, description] of Object.entries(THEMES)) {
3058
+ log2(`${theme2 === current ? pc8.green("\u25CF") : " "} ${theme2.padEnd(8)} ${pc8.dim(description)}`);
3059
+ }
3060
+ log2(pc8.dim("\nSwitch with `flare theme <name>`. FLARE_THEME in .env overrides it for a build."));
3061
+ return;
3062
+ }
3063
+ const theme = parseTheme(name);
3064
+ if (!current) throw new Error('lib/site.ts has no `theme: "\u2026" as ThemeName,` line to change.');
3065
+ writeFileSync8(path, source.replace(THEME_LINE, `theme: "${theme}" as ThemeName,`));
3066
+ log2(`${pc8.green("\u2714")} Theme is now ${pc8.bold(theme)}. Restart \`flare dev\` (or rebuild) to see it.`);
3067
+ }
3068
+
3069
+ // src/commands/create-prompts.ts
3070
+ import * as prompts2 from "@clack/prompts";
3071
+ import pc9 from "picocolors";
3072
+ import { FLARE_VERSION as FLARE_VERSION2 } from "@flaredev/core";
3073
+ var canPrompt = (yes) => !yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
3074
+ var AUTH_HINTS = {
3075
+ "magic-link": "a sign-in link by email",
3076
+ "email-otp": "a 6-digit sign-in code by email",
3077
+ passkeys: "Face ID, Touch ID, Windows Hello",
3078
+ "2fa-app": "authenticator app, with backup codes",
3079
+ "2fa-email": "a code by email as the second step"
3080
+ };
3081
+ var AUTH_LABELS = {
3082
+ "magic-link": "Magic links",
3083
+ "email-otp": "Email codes",
3084
+ passkeys: "Passkeys",
3085
+ "2fa-app": "Two-factor: authenticator app",
3086
+ "2fa-email": "Two-factor: email code"
3087
+ };
3088
+ function stopIfCancelled(value) {
3089
+ if (prompts2.isCancel(value)) {
3090
+ prompts2.cancel("Cancelled: nothing was created.");
3091
+ process.exit(1);
3092
+ }
3093
+ return value;
3094
+ }
3095
+ async function askCreateQuestions(given, appName) {
3096
+ prompts2.intro(`${pc9.bgRed(pc9.white(" Flare "))} ${pc9.dim(`v${FLARE_VERSION2}`)}${appName ? ` ${pc9.dim("\xB7")} ${pc9.bold(appName)}` : ""}`);
3097
+ const answers = { ...given };
3098
+ if (given.theme === void 0) {
3099
+ answers.theme = stopIfCancelled(
3100
+ await prompts2.select({
3101
+ message: "Pick a look",
3102
+ initialValue: "default",
3103
+ options: Object.entries(THEMES).map(([value, hint]) => ({ value, label: value, hint }))
3104
+ })
3105
+ );
3106
+ }
3107
+ if (given.auth === void 0) {
3108
+ const methods = stopIfCancelled(
3109
+ await prompts2.multiselect({
3110
+ message: `How can people sign in? ${pc9.dim("Email and password is always on")}`,
3111
+ initialValues: DEFAULT_AUTH_METHODS,
3112
+ required: false,
3113
+ options: Object.keys(AUTH_METHODS).map((value) => ({
3114
+ value,
3115
+ label: AUTH_LABELS[value],
3116
+ hint: AUTH_HINTS[value]
3117
+ }))
3118
+ })
3119
+ );
3120
+ answers.auth = methods.length ? methods.join(",") : "none";
3121
+ }
3122
+ if (given.authProviders === void 0) {
3123
+ const providers = stopIfCancelled(
3124
+ await prompts2.multiselect({
3125
+ message: `Social sign-in? ${pc9.dim("each needs its own credentials later")}`,
3126
+ initialValues: [],
3127
+ required: false,
3128
+ options: Object.entries(AUTH_PROVIDERS).map(([value, { label }]) => ({ value, label }))
3129
+ })
3130
+ );
3131
+ answers.authProviders = providers.join(",");
3132
+ }
3133
+ return answers;
3134
+ }
3135
+
2897
3136
  // src/commands/gen.ts
2898
- import { existsSync as existsSync14, mkdirSync as mkdirSync7, writeFileSync as writeFileSync8 } from "fs";
2899
- import { dirname as dirname8, join as join13 } from "path";
3137
+ import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
3138
+ import { dirname as dirname8, join as join14 } from "path";
2900
3139
  import { camelCase as camelCase4, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
2901
- import pc8 from "picocolors";
2902
- function validateFields(name, fields, others, log) {
3140
+ import pc10 from "picocolors";
3141
+ function validateFields(name, fields, others, log2) {
2903
3142
  const known = /* @__PURE__ */ new Set([name, ...others.map((resource) => resource.name)]);
2904
3143
  for (const field of fields) {
2905
3144
  if (field.kind === "belongsTo" && !known.has(field.target)) {
@@ -2909,16 +3148,16 @@ function validateFields(name, fields, others, log) {
2909
3148
  const candidate = defineResource2({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
2910
3149
  const graph = relationGraph2([...others, candidate]);
2911
3150
  for (const pending of graph.pending.filter((p) => p.resource === name)) {
2912
- log(pc8.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
3151
+ log2(pc10.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
2913
3152
  }
2914
3153
  }
2915
3154
  async function genResource(rawName, options2) {
2916
- const log = options2.log ?? ((message) => console.log(message));
3155
+ const log2 = options2.log ?? ((message) => console.log(message));
2917
3156
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2918
3157
  const name = resourceName(rawName);
2919
3158
  const relativePath = descriptorPath(name);
2920
- const path = join13(appRoot, relativePath);
2921
- const exists = existsSync14(path);
3159
+ const path = join14(appRoot, relativePath);
3160
+ const exists = existsSync15(path);
2922
3161
  if (!exists && !options2.fields) {
2923
3162
  throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
2924
3163
  }
@@ -2926,15 +3165,15 @@ async function genResource(rawName, options2) {
2926
3165
  const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
2927
3166
  if (options2.fields) {
2928
3167
  const fields = parseFields(options2.fields);
2929
- validateFields(name, fields, others, log);
3168
+ validateFields(name, fields, others, log2);
2930
3169
  if (!exists) {
2931
3170
  mkdirSync7(dirname8(path), { recursive: true });
2932
- writeFileSync8(path, renderDescriptor(name, fields));
2933
- log(`${pc8.green("create".padEnd(9))} ${relativePath}`);
3171
+ writeFileSync9(path, renderDescriptor(name, fields));
3172
+ log2(`${pc10.green("create".padEnd(9))} ${relativePath}`);
2934
3173
  } else {
2935
3174
  try {
2936
3175
  const outcome = writeGenerated(path, renderFieldsBlock(fields), { force: options2.force });
2937
- log(`${(outcome === "identical" ? pc8.dim : pc8.yellow)(outcome.padEnd(9))} ${relativePath}`);
3176
+ log2(`${(outcome === "identical" ? pc10.dim : pc10.yellow)(outcome.padEnd(9))} ${relativePath}`);
2938
3177
  } catch (error) {
2939
3178
  if (error instanceof DriftError) {
2940
3179
  throw new Error(
@@ -2950,9 +3189,9 @@ async function genResource(rawName, options2) {
2950
3189
  try {
2951
3190
  const all = await loadResources(appRoot);
2952
3191
  table = all.find(({ resource }) => resource.name === name).resource.table;
2953
- ensureSupportFiles(appRoot, log);
3192
+ ensureSupportFiles(appRoot, log2);
2954
3193
  written = applyPlan(appRoot, planFiles(all), { force: options2.force });
2955
- logResults(written, log);
3194
+ logResults(written, log2);
2956
3195
  } catch (error) {
2957
3196
  const hint = exists ? "" : `
2958
3197
  (${relativePath} was written; fix it and run \`flare sync-types\`.)`;
@@ -2960,75 +3199,75 @@ async function genResource(rawName, options2) {
2960
3199
  }
2961
3200
  const skipped = written.filter((r) => r.outcome.startsWith("skipped"));
2962
3201
  if (skipped.length) {
2963
- log(pc8.yellow(`
3202
+ log2(pc10.yellow(`
2964
3203
  Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for details.`));
2965
3204
  }
2966
3205
  let migrations = [];
2967
3206
  if (!options2.skipMigration) {
2968
3207
  const result = await generateSchemaMigration(appRoot, `${exists ? "update" : "create"}_${table}`);
2969
3208
  migrations = result.files;
2970
- for (const file of migrations) log(`${pc8.green("create".padEnd(9))} ${file}`);
2971
- for (const repair of result.repairs) log(pc8.dim(`repaired ${repair}`));
2972
- if (migrations.length) log(`
2973
- Next: ${pc8.bold("flare migrate")} to apply it locally.`);
2974
- else if (exists) log(pc8.dim("\nNo table changes, so no migration."));
3209
+ for (const file of migrations) log2(`${pc10.green("create".padEnd(9))} ${file}`);
3210
+ for (const repair of result.repairs) log2(pc10.dim(`repaired ${repair}`));
3211
+ if (migrations.length) log2(`
3212
+ Next: ${pc10.bold("flare migrate")} to apply it locally.`);
3213
+ else if (exists) log2(pc10.dim("\nNo table changes, so no migration."));
2975
3214
  }
2976
3215
  const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
2977
3216
  return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
2978
3217
  }
2979
3218
 
2980
3219
  // src/commands/gen-migration.ts
2981
- import { existsSync as existsSync15, mkdirSync as mkdirSync8, writeFileSync as writeFileSync9 } from "fs";
2982
- import { basename as basename2, join as join14 } from "path";
3220
+ import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
3221
+ import { basename as basename2, join as join15 } from "path";
2983
3222
  import { snakeCase } from "@flaredev/core";
2984
- import pc9 from "picocolors";
3223
+ import pc11 from "picocolors";
2985
3224
  var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
2986
3225
  -- Write SQL that undoes the up migration, e.g.:
2987
3226
  -- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
2988
3227
  -- While this file has no statements, rollback of ${up} is refused.
2989
3228
  `;
2990
3229
  async function genMigration(rawName, options2 = {}) {
2991
- const log = options2.log ?? ((message) => console.log(message));
3230
+ const log2 = options2.log ?? ((message) => console.log(message));
2992
3231
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2993
3232
  const name = snakeCase(rawName);
2994
3233
  if (!name) throw new Error(`Invalid migration name "${rawName}". Use letters, digits, and underscores.`);
2995
3234
  const { files, repairs } = await generateSchemaMigration(appRoot, name, { custom: !options2.fromSchema });
2996
3235
  if (files.length === 0) {
2997
- log(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
3236
+ log2(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
2998
3237
  return [];
2999
3238
  }
3000
- for (const file of files) log(`${pc9.green("create".padEnd(9))} ${file}`);
3001
- for (const repair of repairs) log(pc9.dim(`repaired ${repair}`));
3239
+ for (const file of files) log2(`${pc11.green("create".padEnd(9))} ${file}`);
3240
+ for (const repair of repairs) log2(pc11.dim(`repaired ${repair}`));
3002
3241
  if (!options2.fromSchema) {
3003
- const downDir = join14(appRoot, "migrations", "down");
3242
+ const downDir = join15(appRoot, "migrations", "down");
3004
3243
  mkdirSync8(downDir, { recursive: true });
3005
3244
  for (const file of files) {
3006
- const down = join14(downDir, basename2(file));
3007
- if (existsSync15(down)) continue;
3008
- writeFileSync9(down, DOWN_TEMPLATE(basename2(file)));
3009
- log(`${pc9.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
3245
+ const down = join15(downDir, basename2(file));
3246
+ if (existsSync16(down)) continue;
3247
+ writeFileSync10(down, DOWN_TEMPLATE(basename2(file)));
3248
+ log2(`${pc11.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
3010
3249
  }
3011
- log(`
3012
- Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc9.bold("flare migrate")}.`);
3250
+ log2(`
3251
+ Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc11.bold("flare migrate")}.`);
3013
3252
  } else {
3014
- log(`
3015
- Next: ${pc9.bold("flare migrate")}.`);
3253
+ log2(`
3254
+ Next: ${pc11.bold("flare migrate")}.`);
3016
3255
  }
3017
3256
  return files;
3018
3257
  }
3019
3258
 
3020
3259
  // src/commands/gen-policy.ts
3021
- import { existsSync as existsSync16, mkdirSync as mkdirSync9, writeFileSync as writeFileSync10 } from "fs";
3022
- import { dirname as dirname9, join as join15 } from "path";
3260
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
3261
+ import { dirname as dirname9, join as join16 } from "path";
3023
3262
  import { definePolicy } from "@flaredev/core";
3024
- import pc10 from "picocolors";
3263
+ import pc12 from "picocolors";
3025
3264
  function parseRoles(input, what = "--roles") {
3026
3265
  const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
3027
3266
  if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
3028
3267
  return [...new Set(roles)];
3029
3268
  }
3030
3269
  async function genPolicy(rawName, options2) {
3031
- const log = options2.log ?? ((message) => console.log(message));
3270
+ const log2 = options2.log ?? ((message) => console.log(message));
3032
3271
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3033
3272
  const name = resourceName(rawName);
3034
3273
  const resources = await loadResources(appRoot);
@@ -3037,8 +3276,8 @@ async function genPolicy(rawName, options2) {
3037
3276
  throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
3038
3277
  }
3039
3278
  const relative2 = policyPath(name);
3040
- const path = join15(appRoot, relative2);
3041
- const exists = existsSync16(path);
3279
+ const path = join16(appRoot, relative2);
3280
+ const exists = existsSync17(path);
3042
3281
  let roles;
3043
3282
  let deleteRoles;
3044
3283
  if (options2.roles || !exists) {
@@ -3050,12 +3289,12 @@ async function genPolicy(rawName, options2) {
3050
3289
  const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
3051
3290
  if (!exists) {
3052
3291
  mkdirSync9(dirname9(path), { recursive: true });
3053
- writeFileSync10(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
3054
- log(`${pc10.green("create".padEnd(9))} ${relative2}`);
3292
+ writeFileSync11(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
3293
+ log2(`${pc12.green("create".padEnd(9))} ${relative2}`);
3055
3294
  } else {
3056
3295
  try {
3057
3296
  const outcome = writeGenerated(path, block, { force: options2.force });
3058
- log(`${(outcome === "identical" ? pc10.dim : pc10.yellow)(outcome.padEnd(9))} ${relative2}`);
3297
+ log2(`${(outcome === "identical" ? pc12.dim : pc12.yellow)(outcome.padEnd(9))} ${relative2}`);
3059
3298
  } catch (error) {
3060
3299
  if (error instanceof DriftError) {
3061
3300
  throw new Error(`The roles in ${relative2} were edited by hand, so --roles won't overwrite them. Edit the file directly, or pass --force.`);
@@ -3065,17 +3304,17 @@ async function genPolicy(rawName, options2) {
3065
3304
  }
3066
3305
  }
3067
3306
  const policies = await loadPolicies(appRoot);
3068
- logResults(applyPlan(appRoot, planFiles(resources, policies)), log);
3069
- log(`
3070
- ${pc10.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
3307
+ logResults(applyPlan(appRoot, planFiles(resources, policies)), log2);
3308
+ log2(`
3309
+ ${pc12.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
3071
3310
  return { name, path: relative2 };
3072
3311
  }
3073
3312
 
3074
3313
  // src/commands/migrate.ts
3075
- import { existsSync as existsSync17, mkdtempSync, readFileSync as readFileSync10, rmSync as rmSync2, writeFileSync as writeFileSync11 } from "fs";
3314
+ import { existsSync as existsSync18, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
3076
3315
  import { tmpdir } from "os";
3077
- import { join as join16 } from "path";
3078
- import pc11 from "picocolors";
3316
+ import { join as join17 } from "path";
3317
+ import pc13 from "picocolors";
3079
3318
 
3080
3319
  // src/generator/down.ts
3081
3320
  var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
@@ -3191,28 +3430,28 @@ ${result.output.trim()}`);
3191
3430
  async function rollback(options2 = {}) {
3192
3431
  const steps = options2.steps ?? 1;
3193
3432
  if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
3194
- const { appRoot, db, target, where, wrangler, log } = context(options2);
3195
- const migrationsDir = join16(appRoot, db.migrations_dir ?? "migrations");
3433
+ const { appRoot, db, target, where, wrangler, log: log2 } = context(options2);
3434
+ const migrationsDir = join17(appRoot, db.migrations_dir ?? "migrations");
3196
3435
  const applied = await appliedMigrations(options2, steps);
3197
3436
  if (applied.length === 0) {
3198
- log("Nothing to roll back.");
3437
+ log2("Nothing to roll back.");
3199
3438
  return 0;
3200
3439
  }
3201
3440
  const plan = [];
3202
3441
  for (const { name } of applied) {
3203
3442
  if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
3204
- const handWritten = join16(migrationsDir, "down", name);
3205
- if (existsSync17(handWritten)) {
3206
- const sql2 = readFileSync10(handWritten, "utf8");
3443
+ const handWritten = join17(migrationsDir, "down", name);
3444
+ if (existsSync18(handWritten)) {
3445
+ const sql2 = readFileSync11(handWritten, "utf8");
3207
3446
  if (splitStatements(sql2).length === 0) {
3208
3447
  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.`);
3209
3448
  }
3210
3449
  plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
3211
3450
  continue;
3212
3451
  }
3213
- const upPath = join16(migrationsDir, name);
3214
- if (!existsSync17(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
3215
- const down = deriveDown(readFileSync10(upPath, "utf8"));
3452
+ const upPath = join17(migrationsDir, name);
3453
+ if (!existsSync18(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
3454
+ const down = deriveDown(readFileSync11(upPath, "utf8"));
3216
3455
  if (!down.reversible) {
3217
3456
  throw new Error(
3218
3457
  `Can't roll back ${name}: ${down.reason}
@@ -3221,37 +3460,37 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
3221
3460
  }
3222
3461
  plan.push({ name, sql: down.sql, source: "derived" });
3223
3462
  }
3224
- log(`${pc11.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3463
+ log2(`${pc13.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3225
3464
  for (const step of plan) {
3226
- log(` ${step.name} ${pc11.dim(`(${step.source})`)}`);
3227
- for (const line of step.sql.trim().split("\n")) log(pc11.dim(` ${line}`));
3465
+ log2(` ${step.name} ${pc13.dim(`(${step.source})`)}`);
3466
+ for (const line of step.sql.trim().split("\n")) log2(pc13.dim(` ${line}`));
3228
3467
  }
3229
3468
  if (options2.remote && !options2.yes) {
3230
- log(pc11.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3469
+ log2(pc13.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3231
3470
  return 1;
3232
3471
  }
3233
3472
  const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
3234
3473
  DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
3235
- const dir = mkdtempSync(join16(tmpdir(), "flare-rollback-"));
3474
+ const dir = mkdtempSync(join17(tmpdir(), "flare-rollback-"));
3236
3475
  try {
3237
- const file = join16(dir, "rollback.sql");
3238
- writeFileSync11(file, script);
3476
+ const file = join17(dir, "rollback.sql");
3477
+ writeFileSync12(file, script);
3239
3478
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
3240
3479
  if (result.code !== 0) throw new Error(`Rollback failed:
3241
3480
  ${result.output.trim()}`);
3242
3481
  } finally {
3243
3482
  rmSync2(dir, { recursive: true, force: true });
3244
3483
  }
3245
- log(pc11.green(`
3484
+ log2(pc13.green(`
3246
3485
  Rolled back ${plan.map((step) => step.name).join(", ")}.`));
3247
3486
  return 0;
3248
3487
  }
3249
3488
 
3250
3489
  // src/commands/rm.ts
3251
- import { existsSync as existsSync18, readdirSync as readdirSync7, readFileSync as readFileSync11, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
3252
- import { dirname as dirname10, join as join17 } from "path";
3490
+ import { existsSync as existsSync19, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
3491
+ import { dirname as dirname10, join as join18 } from "path";
3253
3492
  import { relationGraph as relationGraph3 } from "@flaredev/core";
3254
- import pc12 from "picocolors";
3493
+ import pc14 from "picocolors";
3255
3494
  var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
3256
3495
  function handWrittenReason(source, header, options2 = {}) {
3257
3496
  const parts = splitMarkers(source);
@@ -3268,16 +3507,16 @@ function handWrittenReason(source, header, options2 = {}) {
3268
3507
  return null;
3269
3508
  }
3270
3509
  function removeEmptyDirs(appRoot, start) {
3271
- let dir = dirname10(join17(appRoot, start));
3272
- const roots = [join17(appRoot, "app", "api"), join17(appRoot, "app", "admin")];
3510
+ let dir = dirname10(join18(appRoot, start));
3511
+ const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "dashboard")];
3273
3512
  const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
3274
- while (inside() && existsSync18(dir) && readdirSync7(dir).length === 0) {
3513
+ while (inside() && existsSync19(dir) && readdirSync7(dir).length === 0) {
3275
3514
  rmdirSync(dir);
3276
3515
  dir = dirname10(dir);
3277
3516
  }
3278
3517
  }
3279
3518
  async function rmResource(rawName, options2 = {}) {
3280
- const log = options2.log ?? ((message) => console.log(message));
3519
+ const log2 = options2.log ?? ((message) => console.log(message));
3281
3520
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3282
3521
  const name = resourceName(rawName);
3283
3522
  const all = await loadResources(appRoot);
@@ -3300,7 +3539,7 @@ async function rmResource(rawName, options2 = {}) {
3300
3539
  if (entry) {
3301
3540
  owned.add(entry.file);
3302
3541
  for (const file of planFiles(all)) {
3303
- if (existsSync18(join17(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3542
+ if (existsSync19(join18(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3304
3543
  }
3305
3544
  }
3306
3545
  for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
@@ -3309,7 +3548,7 @@ async function rmResource(rawName, options2 = {}) {
3309
3548
  if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
3310
3549
  const findings = [];
3311
3550
  for (const path of owned) {
3312
- const source = readFileSync11(join17(appRoot, path), "utf8");
3551
+ const source = readFileSync12(join18(appRoot, path), "utf8");
3313
3552
  let wrapper;
3314
3553
  if (entry && path === entry.file) {
3315
3554
  const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
@@ -3330,34 +3569,34 @@ Move what you want to keep, or pass --force to delete anyway.`);
3330
3569
  }
3331
3570
  const removed = [...owned].sort();
3332
3571
  for (const path of removed) {
3333
- unlinkSync2(join17(appRoot, path));
3572
+ unlinkSync2(join18(appRoot, path));
3334
3573
  removeEmptyDirs(appRoot, path);
3335
- log(`${pc12.red("remove".padEnd(9))} ${path}`);
3574
+ log2(`${pc14.red("remove".padEnd(9))} ${path}`);
3336
3575
  }
3337
- logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log);
3576
+ logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log2);
3338
3577
  const table = entry ? tableExport(entry.resource) : void 0;
3339
- if (table && existsSync18(join17(appRoot, "seeds"))) {
3340
- const seeds = readdirSync7(join17(appRoot, "seeds")).filter(
3341
- (file) => new RegExp(`\\b${table}\\b`).test(readFileSync11(join17(appRoot, "seeds", file), "utf8"))
3578
+ if (table && existsSync19(join18(appRoot, "seeds"))) {
3579
+ const seeds = readdirSync7(join18(appRoot, "seeds")).filter(
3580
+ (file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join18(appRoot, "seeds", file), "utf8"))
3342
3581
  );
3343
- if (seeds.length) log(pc12.yellow(`
3582
+ if (seeds.length) log2(pc14.yellow(`
3344
3583
  These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
3345
3584
  }
3346
3585
  let migrations = [];
3347
3586
  if (!options2.skipMigration && entry) {
3348
3587
  const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
3349
3588
  migrations = result.files;
3350
- for (const file of migrations) log(`${pc12.green("create".padEnd(9))} ${file}`);
3351
- for (const repair of result.repairs) log(pc12.dim(`repaired ${repair}`));
3352
- if (migrations.length) log(`
3353
- Next: ${pc12.bold("flare migrate")} to drop the table locally.`);
3589
+ for (const file of migrations) log2(`${pc14.green("create".padEnd(9))} ${file}`);
3590
+ for (const repair of result.repairs) log2(pc14.dim(`repaired ${repair}`));
3591
+ if (migrations.length) log2(`
3592
+ Next: ${pc14.bold("flare migrate")} to drop the table locally.`);
3354
3593
  }
3355
3594
  return { removed, migrations };
3356
3595
  }
3357
3596
 
3358
3597
  // src/commands/role.ts
3359
3598
  import { humanize } from "@flaredev/core";
3360
- import pc13 from "picocolors";
3599
+ import pc15 from "picocolors";
3361
3600
  var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
3362
3601
  function roleInsertSql(name, label) {
3363
3602
  if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
@@ -3390,7 +3629,7 @@ ${result.output.trim()}`);
3390
3629
  return (first?.results ?? []).map((row) => row.name);
3391
3630
  }
3392
3631
  async function addRole(name, options2 = {}) {
3393
- const { appRoot, binding, wrangler, target, log } = context2(options2);
3632
+ const { appRoot, binding, wrangler, target, log: log2 } = context2(options2);
3394
3633
  const sql2 = roleInsertSql(name, options2.label ?? humanize(name));
3395
3634
  const result = await runWrangler(wrangler, ["d1", "execute", binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
3396
3635
  if (result.code !== 0) {
@@ -3402,24 +3641,137 @@ ${result.output.trim()}`);
3402
3641
  }
3403
3642
  const [first] = extractJson(result.output);
3404
3643
  const added = first?.results?.[0];
3405
- log(pc13.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
3406
- log(pc13.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
3644
+ log2(pc15.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
3645
+ log2(pc15.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
3407
3646
  return 0;
3408
3647
  }
3409
3648
 
3410
3649
  // src/commands/seed.ts
3411
- import { existsSync as existsSync19, mkdirSync as mkdirSync10, readdirSync as readdirSync8, writeFileSync as writeFileSync12 } from "fs";
3650
+ import { existsSync as existsSync20, mkdirSync as mkdirSync10, readdirSync as readdirSync8, writeFileSync as writeFileSync13 } from "fs";
3651
+ import { join as join20 } from "path";
3652
+ import { createFake, kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
3653
+ import { createJiti as createJiti4 } from "jiti";
3654
+ import pc16 from "picocolors";
3655
+
3656
+ // src/seed/bulk.ts
3657
+ var MAX_STATEMENT_BYTES = 8e4;
3658
+ var STATEMENTS_PER_CALL = 25;
3659
+ function sqlLiteral(value) {
3660
+ if (value === null || value === void 0) return "NULL";
3661
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
3662
+ if (typeof value === "boolean") return value ? "1" : "0";
3663
+ if (value instanceof Date) return String(value.getTime());
3664
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
3665
+ return `'${text.replaceAll("'", "''")}'`;
3666
+ }
3667
+ var tuple = (row, columns) => `(${columns.map((column) => sqlLiteral(row[column])).join(",")})`;
3668
+ function* insertStatements(table, columns, rows, maxBytes = MAX_STATEMENT_BYTES) {
3669
+ const head = `INSERT INTO "${table}" (${columns.map((column) => `"${column}"`).join(",")}) VALUES `;
3670
+ let values = [];
3671
+ let bytes = head.length;
3672
+ for (const row of rows) {
3673
+ const text = tuple(row, columns);
3674
+ if (values.length > 0 && bytes + text.length + 1 > maxBytes) {
3675
+ yield { sql: `${head}${values.join(",")};`, rows: values.length };
3676
+ values = [];
3677
+ bytes = head.length;
3678
+ }
3679
+ values.push(text);
3680
+ bytes += text.length + 1;
3681
+ }
3682
+ if (values.length > 0) yield { sql: `${head}${values.join(",")};`, rows: values.length };
3683
+ }
3684
+ async function bulkInsert(sink, options2) {
3685
+ const perCall = options2.statementsPerCall ?? STATEMENTS_PER_CALL;
3686
+ let written = 0;
3687
+ let batch = [];
3688
+ let batchRows = 0;
3689
+ const flush = async () => {
3690
+ if (batch.length === 0) return;
3691
+ await sink.exec(batch.join("\n"));
3692
+ written += batchRows;
3693
+ batch = [];
3694
+ batchRows = 0;
3695
+ options2.onProgress?.(written);
3696
+ };
3697
+ for (const statement of insertStatements(options2.table, options2.columns, options2.rows, options2.maxBytes)) {
3698
+ batch.push(statement.sql);
3699
+ batchRows += statement.rows;
3700
+ if (batch.length === perCall) await flush();
3701
+ }
3702
+ await flush();
3703
+ return written;
3704
+ }
3705
+
3706
+ // src/seed/insert-many.ts
3707
+ var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
3708
+ function createInsertMany(sink, drizzle, onProgress) {
3709
+ return async function insertMany(table, count, build) {
3710
+ const name = drizzle.getTableName(table);
3711
+ const columns = drizzle.getTableColumns(table);
3712
+ if (typeof count === "number" && !build) throw new Error(`insertMany(${name}, ${count}) needs a function to build each row.`);
3713
+ const source = typeof count === "number" ? (function* () {
3714
+ for (let i = 0; i < count; i++) yield build(i);
3715
+ })() : isIterable(count) ? count : [];
3716
+ const iterator = source[Symbol.iterator]();
3717
+ const first = iterator.next();
3718
+ if (first.done) return 0;
3719
+ const keys = new Set(Object.keys(first.value));
3720
+ for (const [key, column] of Object.entries(columns)) if (column.defaultFn) keys.add(key);
3721
+ const used = Object.keys(columns).filter((key) => keys.has(key));
3722
+ const unknownKeys = [...keys].filter((key) => !columns[key]);
3723
+ if (unknownKeys.length) throw new Error(`${name} has no column called ${unknownKeys.join(", ")}.`);
3724
+ const toRow = (value) => {
3725
+ const row = {};
3726
+ for (const key of used) {
3727
+ const column = columns[key];
3728
+ let raw = value[key];
3729
+ if (raw === void 0 && column.defaultFn) raw = column.defaultFn();
3730
+ row[column.name] = raw === void 0 || raw === null ? null : column.mapToDriverValue?.(raw) ?? raw;
3731
+ }
3732
+ return row;
3733
+ };
3734
+ const rows = (function* () {
3735
+ yield toRow(first.value);
3736
+ for (let next = iterator.next(); !next.done; next = iterator.next()) yield toRow(next.value);
3737
+ })();
3738
+ return bulkInsert(sink, {
3739
+ table: name,
3740
+ columns: used.map((key) => columns[key].name),
3741
+ rows,
3742
+ onProgress: onProgress && ((written) => onProgress(written, name))
3743
+ });
3744
+ };
3745
+ }
3746
+
3747
+ // src/seed/local-d1.ts
3412
3748
  import { createRequire } from "module";
3413
- import { join as join18 } from "path";
3749
+ import { join as join19 } from "path";
3414
3750
  import { pathToFileURL } from "url";
3415
- import { kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
3416
- import { createJiti as createJiti4 } from "jiti";
3417
- import pc14 from "picocolors";
3418
- var SEEDS_DIR = "seeds";
3419
3751
  var LOCAL_STATE = ".wrangler/state/v3";
3752
+ async function openLocalD1(appRoot, binding) {
3753
+ process.env.WRANGLER_LOG ??= "error";
3754
+ const appRequire = createRequire(join19(appRoot, "package.json"));
3755
+ const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
3756
+ const { env, dispose } = await wrangler.getPlatformProxy({
3757
+ configPath: join19(appRoot, "wrangler.jsonc"),
3758
+ persist: { path: join19(appRoot, LOCAL_STATE) }
3759
+ });
3760
+ const bindings = env;
3761
+ const name = binding ?? "DB";
3762
+ const db = bindings[name];
3763
+ if (!db) {
3764
+ const available = Object.keys(bindings).filter((key) => typeof bindings[key]?.prepare === "function");
3765
+ throw new Error(`No "${name}" database binding in wrangler.jsonc.${available.length ? ` Available: ${available.join(", ")}.` : ""}`);
3766
+ }
3767
+ return { db, env: bindings, dispose };
3768
+ }
3769
+
3770
+ // src/commands/seed.ts
3771
+ var SEEDS_DIR = "seeds";
3420
3772
  function seedFiles(appRoot, names) {
3421
- const dir = join18(appRoot, SEEDS_DIR);
3422
- const all = existsSync19(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3773
+ const dir = join20(appRoot, SEEDS_DIR);
3774
+ const all = existsSync20(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3423
3775
  if (!names?.length) return all;
3424
3776
  return names.map((name) => {
3425
3777
  const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
@@ -3430,58 +3782,66 @@ function seedFiles(appRoot, names) {
3430
3782
  });
3431
3783
  }
3432
3784
  async function runSeeds(options2 = {}) {
3433
- const log = options2.log ?? ((message) => console.log(message));
3785
+ const log2 = options2.log ?? ((message) => console.log(message));
3434
3786
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3435
3787
  const files = seedFiles(appRoot, options2.names);
3436
3788
  if (files.length === 0) {
3437
- log(`No seeds found. Create one with ${pc14.bold("flare seed:make <name>")}.`);
3789
+ log2(`No seeds found. Create one with ${pc16.bold("flare seed:make <name>")}.`);
3438
3790
  return;
3439
3791
  }
3440
- const jiti = createJiti4(join18(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3441
- const appRequire = createRequire(join18(appRoot, "package.json"));
3442
- const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
3792
+ const jiti = createJiti4(join20(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3443
3793
  const { drizzle } = await jiti.import("drizzle-orm/d1");
3444
- const schema = await jiti.import(join18(appRoot, "db/schema.ts"));
3445
- const { env, dispose } = await wrangler.getPlatformProxy({
3446
- configPath: join18(appRoot, "wrangler.jsonc"),
3447
- persist: { path: join18(appRoot, LOCAL_STATE) }
3448
- });
3794
+ const drizzleHelpers = await jiti.import("drizzle-orm");
3795
+ const schema = await jiti.import(join20(appRoot, "db/schema.ts"));
3796
+ const local = await openLocalD1(appRoot);
3449
3797
  try {
3450
- const bindings = env;
3451
- if (!bindings.DB) throw new Error("No DB binding found in wrangler.jsonc.");
3452
- const db = drizzle(bindings.DB, { schema });
3798
+ const db = drizzle(local.db, { schema });
3799
+ const insertMany = createInsertMany(local.db, drizzleHelpers);
3453
3800
  for (const file of files) {
3454
- const mod = await jiti.import(join18(appRoot, SEEDS_DIR, file));
3801
+ const mod = await jiti.import(join20(appRoot, SEEDS_DIR, file));
3455
3802
  if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
3456
- log(`${pc14.cyan("seed")} ${file}`);
3457
- await mod.default({ db, env: bindings, log: (message) => log(` ${message}`) });
3803
+ log2(`${pc16.cyan("seed")} ${file}`);
3804
+ await mod.default({ db, env: local.env, insertMany, fake: createFake(), log: (message) => log2(` ${message}`) });
3458
3805
  }
3459
- log(pc14.green(`
3806
+ log2(pc16.green(`
3460
3807
  Ran ${files.length} seed(s) against the local database.`));
3461
3808
  } finally {
3462
- await dispose();
3809
+ await local.dispose();
3463
3810
  }
3464
3811
  }
3465
- function sampleValue(key, def, row) {
3812
+ function sampleValue(key, def) {
3813
+ const name = key.toLowerCase();
3466
3814
  switch (def.kind) {
3467
3815
  case "string":
3468
- if (def.format === "email") return `\`${kebabCase3(key).replace(/-?email$/, "") || "user"}\${i}@example.com\``;
3469
- if (def.format === "url") return `\`https://example.com/\${i}\``;
3470
- return `\`${def.label} \${i}\``;
3816
+ if (def.format === "email") return "fake.email()";
3817
+ if (def.format === "url") return "fake.url()";
3818
+ if (def.format === "tel") return "fake.phone()";
3819
+ if (def.format === "domain") return "fake.domain()";
3820
+ if (def.format === "country") return "fake.country()";
3821
+ if (def.format === "color") return "fake.color()";
3822
+ if (def.format === "slug") return "fake.slug()";
3823
+ if (name.includes("email")) return "fake.email()";
3824
+ if (name.includes("phone") || name.includes("mobile")) return "fake.phone()";
3825
+ if (name.includes("company")) return "fake.company()";
3826
+ if (name.includes("city")) return "fake.city()";
3827
+ if (name.includes("name")) return "fake.fullName()";
3828
+ return "fake.words(2)";
3471
3829
  case "text":
3472
- return `"Lorem ipsum dolor sit amet."`;
3830
+ return "fake.paragraph()";
3473
3831
  case "int":
3474
- return "i";
3832
+ return "fake.int(1, 1000)";
3475
3833
  case "float":
3476
- return "i * 1.5";
3834
+ return "fake.float(1, 1000)";
3477
3835
  case "boolean":
3478
- return "i % 2 === 0";
3836
+ return "fake.bool()";
3479
3837
  case "date":
3480
- return "`2026-01-0${i}`";
3838
+ return "fake.date()";
3481
3839
  case "datetime":
3482
- return "new Date().toISOString()";
3840
+ return "fake.datetime()";
3483
3841
  case "enum":
3484
- return JSON.stringify(def.options[row % def.options.length]);
3842
+ return `fake.pick(${JSON.stringify(def.options)})`;
3843
+ case "multiselect":
3844
+ return `fake.some(${JSON.stringify(def.options)}, ${Math.max(1, def.minItems ?? 1)})`;
3485
3845
  case "file":
3486
3846
  return void 0;
3487
3847
  case "belongsTo":
@@ -3492,9 +3852,10 @@ function renderSeed(resource) {
3492
3852
  if (!resource) {
3493
3853
  return `import { defineSeed } from "@flaredev/core";
3494
3854
 
3495
- export default defineSeed(async ({ db, log }) => {
3855
+ export default defineSeed(async ({ db, insertMany, fake, log }) => {
3496
3856
  // Insert rows with Drizzle, e.g.:
3497
3857
  // await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
3858
+ // Or many at once, batched: insertMany(contacts, 10_000, () => ({ name: fake.fullName() }));
3498
3859
  log("nothing to seed yet");
3499
3860
  });
3500
3861
  `;
@@ -3503,30 +3864,32 @@ export default defineSeed(async ({ db, log }) => {
3503
3864
  const lines = [];
3504
3865
  const todos = [];
3505
3866
  for (const [key, def] of storedFields2(resource)) {
3506
- const value = sampleValue(key, def, 0);
3867
+ const value = sampleValue(key, def);
3507
3868
  if (value !== void 0) lines.push(` ${key}: ${value},`);
3508
3869
  else if (def.required) todos.push(` // ${key}: TODO (${def.kind === "belongsTo" ? `id of an existing ${def.target}` : "R2 object key"}),`);
3509
3870
  }
3510
3871
  return `import { defineSeed } from "@flaredev/core";
3511
3872
  import { ${table} } from "@/db/schema";
3512
3873
 
3513
- export default defineSeed(async ({ db, log }) => {
3514
- const rows = [1, 2, 3].map((i) => ({
3874
+ /** How many to create. insertMany batches them, so hundreds of thousands are fine. */
3875
+ const COUNT = 50;
3876
+
3877
+ export default defineSeed(async ({ insertMany, fake, log }) => {
3878
+ const rows = await insertMany(${table}, COUNT, () => ({
3515
3879
  ${[...lines, ...todos].join("\n")}
3516
3880
  }));
3517
- await db.insert(${table}).values(rows);
3518
- log(\`inserted \${rows.length} ${resource.pluralLabel.toLowerCase()}\`);
3881
+ log(\`inserted \${rows.toLocaleString()} ${resource.pluralLabel.toLowerCase()}\`);
3519
3882
  });
3520
3883
  `;
3521
3884
  }
3522
3885
  async function makeSeed(rawName, options2 = {}) {
3523
- const log = options2.log ?? ((message) => console.log(message));
3886
+ const log2 = options2.log ?? ((message) => console.log(message));
3524
3887
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3525
3888
  const name = kebabCase3(rawName);
3526
3889
  if (!name) throw new Error(`Invalid seed name "${rawName}".`);
3527
3890
  const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
3528
- const path = join18(appRoot, relative2);
3529
- if (existsSync19(path)) throw new Error(`${relative2} already exists.`);
3891
+ const path = join20(appRoot, relative2);
3892
+ if (existsSync20(path)) throw new Error(`${relative2} already exists.`);
3530
3893
  const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
3531
3894
  let resource;
3532
3895
  if (options2.resource) {
@@ -3535,27 +3898,287 @@ async function makeSeed(rawName, options2 = {}) {
3535
3898
  } else {
3536
3899
  resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
3537
3900
  }
3538
- mkdirSync10(join18(appRoot, SEEDS_DIR), { recursive: true });
3539
- writeFileSync12(path, renderSeed(resource));
3540
- log(`${pc14.green("create".padEnd(9))} ${relative2}`);
3541
- log(`
3542
- Run it with ${pc14.bold(`flare seed ${name}`)}.`);
3901
+ mkdirSync10(join20(appRoot, SEEDS_DIR), { recursive: true });
3902
+ writeFileSync13(path, renderSeed(resource));
3903
+ log2(`${pc16.green("create".padEnd(9))} ${relative2}`);
3904
+ log2(`
3905
+ Run it with ${pc16.bold(`flare seed ${name}`)}.`);
3543
3906
  return relative2;
3544
3907
  }
3545
3908
 
3909
+ // src/commands/seed-resource.ts
3910
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync14 } from "fs";
3911
+ import { tmpdir as tmpdir2 } from "os";
3912
+ import { join as join21 } from "path";
3913
+ import pc17 from "picocolors";
3914
+
3915
+ // src/seed/rows.ts
3916
+ import { columnName as columnName2, createFake as createFake2, storedFields as storedFields3 } from "@flaredev/core";
3917
+ var has = (key, ...words) => words.some((word) => key.includes(word));
3918
+ function stringValue(key, def, fake, resource, index) {
3919
+ switch (def.format) {
3920
+ case "email":
3921
+ return fake.email();
3922
+ case "url":
3923
+ return fake.url();
3924
+ case "tel":
3925
+ return fake.phone();
3926
+ case "domain":
3927
+ return fake.domain();
3928
+ case "country":
3929
+ return fake.country();
3930
+ case "color":
3931
+ return fake.color();
3932
+ case "slug":
3933
+ return fake.slug();
3934
+ }
3935
+ const name = key.toLowerCase();
3936
+ const organisation = has(resource.name.toLowerCase(), "company", "vendor", "supplier", "organisation", "organization", "account", "team", "store", "brand");
3937
+ let value;
3938
+ if (has(name, "email")) value = fake.email();
3939
+ else if (has(name, "phone", "mobile", "tel", "whatsapp")) value = fake.phone();
3940
+ else if (has(name, "firstname", "givenname")) value = fake.firstName();
3941
+ else if (has(name, "lastname", "surname", "familyname")) value = fake.lastName();
3942
+ else if (has(name, "company", "organisation", "organization", "employer", "vendor", "supplier", "business")) value = fake.company();
3943
+ else if (has(name, "city", "town")) value = fake.city();
3944
+ else if (has(name, "country")) value = fake.country();
3945
+ else if (has(name, "address", "street")) value = `${fake.int(1, 240)} ${fake.words(1)} Street, ${fake.city()}`;
3946
+ else if (has(name, "website", "url", "link")) value = fake.url();
3947
+ else if (has(name, "slug", "handle", "username")) value = fake.slug();
3948
+ else if (has(name, "colour", "color")) value = fake.color();
3949
+ else if (has(name, "code", "sku", "reference", "number")) value = `${fake.words(1).slice(0, 3).toUpperCase()}-${String(index + 1).padStart(6, "0")}`;
3950
+ else if (has(name, "title", "position", "role", "job")) value = fake.jobTitle();
3951
+ else if (has(name, "name")) value = organisation ? fake.company() : fake.fullName();
3952
+ else if (has(name, "description", "summary", "note", "bio", "about", "message", "comment")) value = fake.sentence();
3953
+ else value = `${fake.words(2)}`.replace(/^./, (letter) => letter.toUpperCase());
3954
+ if (def.maxLength && value.length > def.maxLength) value = value.slice(0, def.maxLength);
3955
+ if (def.minLength && value.length < def.minLength) value = value.padEnd(def.minLength, "x");
3956
+ return value;
3957
+ }
3958
+ function numberValue(key, def, fake) {
3959
+ const name = key.toLowerCase();
3960
+ const money = has(name, "price", "amount", "total", "cost", "fee", "revenue", "salary", "balance");
3961
+ let min = def.min;
3962
+ let max = def.max;
3963
+ if (min === void 0 || max === void 0) {
3964
+ const [low, high] = money ? [5, 5e3] : has(name, "age") ? [18, 78] : has(name, "year") ? [2015, (/* @__PURE__ */ new Date()).getFullYear()] : has(name, "quantity", "qty", "stock", "seats", "items") ? [1, 250] : has(name, "percent", "rate", "score", "discount") ? [0, 100] : [1, 1e3];
3965
+ min = min ?? low;
3966
+ max = max ?? high;
3967
+ }
3968
+ return def.kind === "int" ? fake.int(Math.ceil(min), Math.floor(max)) : fake.float(min, max, money ? 2 : 2);
3969
+ }
3970
+ function fieldValue(key, def, fake, resource, parentIds, index) {
3971
+ const name = key.toLowerCase();
3972
+ if (!def.required && def.kind !== "belongsTo" && fake.bool(0.15)) return null;
3973
+ switch (def.kind) {
3974
+ case "string":
3975
+ return stringValue(key, def, fake, resource, index);
3976
+ case "text":
3977
+ return fake.paragraph(fake.int(1, 3));
3978
+ case "int":
3979
+ case "float":
3980
+ return numberValue(key, def, fake);
3981
+ case "boolean":
3982
+ return fake.bool(has(name, "active", "enabled", "published", "verified", "visible") ? 0.85 : 0.4);
3983
+ case "date":
3984
+ return has(name, "due", "expire", "end", "renew") ? fake.date(-90) : fake.date();
3985
+ case "datetime":
3986
+ return has(name, "due", "expire", "end", "renew") ? fake.datetime(-90) : fake.datetime();
3987
+ case "enum":
3988
+ return fake.pick(def.options);
3989
+ case "multiselect":
3990
+ return fake.some(def.options, Math.max(1, def.minItems ?? 1), def.maxItems ?? def.options.length);
3991
+ case "file":
3992
+ return def.required ? `seed/${resource.table}/${fake.count}.bin` : null;
3993
+ case "belongsTo": {
3994
+ const ids = parentIds.get(def.target) ?? [];
3995
+ if (ids.length === 0) {
3996
+ if (def.required) {
3997
+ throw new Error(`${resource.name}.${key} needs a ${def.target} to belong to, and there are none. Seed ${def.target} first.`);
3998
+ }
3999
+ return null;
4000
+ }
4001
+ return fake.pick(ids);
4002
+ }
4003
+ }
4004
+ }
4005
+ function uniquify(value, row) {
4006
+ const trailing = /(\d+)$/.exec(value)?.[1];
4007
+ if (trailing && Number(trailing) === row) return value;
4008
+ const at = value.indexOf("@");
4009
+ if (at > 0) return `${value.slice(0, at)}.${row}${value.slice(at)}`;
4010
+ if (/^[a-z0-9.-]+$/.test(value)) return `${value}-${row}`;
4011
+ return `${value} ${row}`;
4012
+ }
4013
+ function seedColumns(resource) {
4014
+ return ["id", ...storedFields3(resource).map(([key]) => columnName2(key)), "created_at", "updated_at"];
4015
+ }
4016
+ function* seedRows(resource, count, options2 = {}) {
4017
+ const fake = createFake2(options2.seed ?? Math.floor(Math.random() * 2 ** 31));
4018
+ const parentIds = options2.parentIds ?? /* @__PURE__ */ new Map();
4019
+ const fields = storedFields3(resource);
4020
+ const now = options2.now ?? Date.now();
4021
+ for (let i = 0; i < count; i++) {
4022
+ const row = { id: fake.id() };
4023
+ for (const [key, def] of fields) {
4024
+ let value = fieldValue(key, def, fake, resource, parentIds, i);
4025
+ if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value, i + 1);
4026
+ row[columnName2(key)] = value;
4027
+ }
4028
+ const created = now - fake.int(0, 365) * 864e5 - fake.int(0, 86399999);
4029
+ row.created_at = created;
4030
+ row.updated_at = created + fake.int(0, Math.min(now - created, 30 * 864e5));
4031
+ yield row;
4032
+ }
4033
+ }
4034
+
4035
+ // src/commands/seed-resource.ts
4036
+ var PARENT_SAMPLE = 5e3;
4037
+ function parseCount(value, fallback = 25) {
4038
+ if (value === void 0 || value === "") return fallback;
4039
+ if (typeof value === "number") return Math.floor(value);
4040
+ const text = String(value).trim().toLowerCase().replaceAll(",", "").replaceAll("_", "").replaceAll(" ", "");
4041
+ const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(text);
4042
+ if (!match) throw new Error(`"${value}" is not a number of rows. Try 1000, 25k or 1m.`);
4043
+ const size = Number(match[1]) * (match[2] === "k" ? 1e3 : match[2] === "m" ? 1e6 : 1);
4044
+ if (size < 1) throw new Error("Ask for at least one row.");
4045
+ return Math.floor(size);
4046
+ }
4047
+ function pickResource(resources, name) {
4048
+ const wanted = name.toLowerCase();
4049
+ const match = resources.find((resource) => [resource.name.toLowerCase(), resource.slug, resource.table].includes(wanted));
4050
+ if (!match) {
4051
+ throw new Error(`No resource "${name}". Available: ${resources.map((resource) => resource.name).join(", ") || "none"}.`);
4052
+ }
4053
+ return match;
4054
+ }
4055
+ function pickDatabase(databases, wanted) {
4056
+ if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
4057
+ if (wanted) {
4058
+ const match = databases.find((db) => db.binding === wanted || db.database_name === wanted);
4059
+ if (!match) throw new Error(`No D1 database "${wanted}". Available: ${databases.map((db) => db.binding).join(", ")}.`);
4060
+ return match;
4061
+ }
4062
+ if (databases.length > 1) throw new Error(`Several D1 databases; pick one with --database (${databases.map((db) => db.binding).join(", ")}).`);
4063
+ return databases[0];
4064
+ }
4065
+ async function parentsOf(resource, all, ids) {
4066
+ const targets = new Set(
4067
+ Object.values(resource.fields).filter((field) => field.kind === "belongsTo").map((field) => field.target)
4068
+ );
4069
+ const parents = /* @__PURE__ */ new Map();
4070
+ for (const target of targets) {
4071
+ const parent = all.find((candidate) => candidate.name === target);
4072
+ if (parent) parents.set(target, await ids(parent.table));
4073
+ }
4074
+ return parents;
4075
+ }
4076
+ async function seedResourceCommand(name, options2 = {}) {
4077
+ const progress = progressLine();
4078
+ const started = Date.now();
4079
+ try {
4080
+ const result = await seedResource(name, {
4081
+ ...options2,
4082
+ onProgress: (written, total) => {
4083
+ const elapsed = Date.now() - started;
4084
+ progress.update(` ${pc17.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc17.dim(formatRate(written, elapsed))}`);
4085
+ }
4086
+ });
4087
+ progress.done();
4088
+ const { rows, ms, where } = result;
4089
+ console.log(
4090
+ `${pc17.green("\u2714")} Seeded ${pc17.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc17.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
4091
+ );
4092
+ return result;
4093
+ } catch (error) {
4094
+ progress.done();
4095
+ throw error;
4096
+ }
4097
+ }
4098
+ async function seedResource(name, options2 = {}) {
4099
+ const log2 = options2.log ?? ((message) => console.log(message));
4100
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
4101
+ const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
4102
+ const resource = pickResource(resources, name);
4103
+ const total = parseCount(options2.count);
4104
+ const where = options2.remote ? "remote" : "local";
4105
+ if (options2.remote && !options2.yes) {
4106
+ throw new Error(`Seeding the remote database writes ${total.toLocaleString()} rows to ${resource.table}. Re-run with --yes to confirm.`);
4107
+ }
4108
+ const columns = seedColumns(resource);
4109
+ const started = Date.now();
4110
+ let parents = /* @__PURE__ */ new Map();
4111
+ const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents });
4112
+ if (options2.remote) {
4113
+ const db = pickDatabase(readD1Databases(appRoot), options2.database);
4114
+ const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
4115
+ const target = ["--remote", ...options2.env ? ["--env", options2.env] : []];
4116
+ const query = async (sql2) => {
4117
+ const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
4118
+ if (result.code !== 0) throw new Error(`Query failed on the remote database:
4119
+ ${result.output.trim()}`);
4120
+ const start = result.output.indexOf("[");
4121
+ const parsed = JSON.parse(result.output.slice(start, result.output.lastIndexOf("]") + 1));
4122
+ return parsed[0]?.results ?? [];
4123
+ };
4124
+ parents = await parentsOf(resource, resources, async (table) => {
4125
+ const found = await query(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`);
4126
+ return found.map((row) => row.id);
4127
+ });
4128
+ const dir = mkdtempSync2(join21(tmpdir2(), "flare-seed-"));
4129
+ const file = join21(dir, `${resource.table}.sql`);
4130
+ try {
4131
+ const statements = [];
4132
+ if (options2.truncate) statements.push(`DELETE FROM "${resource.table}";`);
4133
+ let written = 0;
4134
+ for (const statement of insertStatements(resource.table, columns, rows())) {
4135
+ statements.push(statement.sql);
4136
+ written += statement.rows;
4137
+ options2.onProgress?.(written, total);
4138
+ }
4139
+ writeFileSync14(file, statements.join("\n"));
4140
+ log2(`${pc17.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc17.bold(db.database_name)}`);
4141
+ const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
4142
+ if (result.code !== 0) throw new Error(`Seeding the remote database failed:
4143
+ ${result.output.trim()}`);
4144
+ } finally {
4145
+ rmSync3(dir, { recursive: true, force: true });
4146
+ }
4147
+ return { resource, rows: total, ms: Date.now() - started, where };
4148
+ }
4149
+ const local = await openLocalD1(appRoot, options2.database);
4150
+ try {
4151
+ parents = await parentsOf(resource, resources, async (table) => {
4152
+ const found = await local.db.prepare(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`).all();
4153
+ return (found.results ?? []).map((row) => row.id);
4154
+ });
4155
+ if (options2.truncate) await local.db.exec(`DELETE FROM "${resource.table}";`);
4156
+ const sink = { exec: (sql2) => local.db.exec(sql2) };
4157
+ const written = await bulkInsert(sink, {
4158
+ table: resource.table,
4159
+ columns,
4160
+ rows: rows(),
4161
+ onProgress: (count) => options2.onProgress?.(count, total)
4162
+ });
4163
+ return { resource, rows: written, ms: Date.now() - started, where };
4164
+ } finally {
4165
+ await local.dispose();
4166
+ }
4167
+ }
4168
+
3546
4169
  // src/commands/billing-sync.ts
3547
4170
  import { createRequire as createRequire2 } from "module";
3548
- import { existsSync as existsSync20, mkdtempSync as mkdtempSync2, readFileSync as readFileSync12, rmSync as rmSync3, writeFileSync as writeFileSync13 } from "fs";
3549
- import { tmpdir as tmpdir2 } from "os";
3550
- import { join as join19 } from "path";
4171
+ import { existsSync as existsSync21, mkdtempSync as mkdtempSync3, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync15 } from "fs";
4172
+ import { tmpdir as tmpdir3 } from "os";
4173
+ import { join as join22 } from "path";
3551
4174
  import { pathToFileURL as pathToFileURL2 } from "url";
3552
4175
  import { kebabCase as kebabCase4 } from "@flaredev/core";
3553
- import pc15 from "picocolors";
4176
+ import pc18 from "picocolors";
3554
4177
  function stripeKeys(appRoot) {
3555
4178
  const map = {};
3556
- const path = join19(appRoot, ".dev.vars");
3557
- if (existsSync20(path)) {
3558
- for (const line of readFileSync12(path, "utf8").split(/\r?\n/)) {
4179
+ const path = join22(appRoot, ".dev.vars");
4180
+ if (existsSync21(path)) {
4181
+ for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
3559
4182
  const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
3560
4183
  if (match) map[match[1]] = match[2].trim();
3561
4184
  }
@@ -3632,16 +4255,16 @@ async function syncPortalConfiguration(stripe, rows, appName) {
3632
4255
  return existing ? (await stripe.billingPortal.configurations.update(existing.id, settings)).id : (await stripe.billingPortal.configurations.create(settings)).id;
3633
4256
  }
3634
4257
  async function syncPlans(options2 = {}) {
3635
- const log = options2.log ?? ((message) => console.log(message));
4258
+ const log2 = options2.log ?? ((message) => console.log(message));
3636
4259
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3637
4260
  const key = stripeKeys(appRoot).STRIPE_SECRET_KEY;
3638
4261
  if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
3639
4262
  const db = readD1Databases(appRoot)[0];
3640
4263
  if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
3641
- const appRequire = createRequire2(join19(appRoot, "package.json"));
4264
+ const appRequire = createRequire2(join22(appRoot, "package.json"));
3642
4265
  const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
3643
4266
  const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
3644
- const appName = JSON.parse(readFileSync12(join19(appRoot, "package.json"), "utf8")).name ?? "app";
4267
+ const appName = JSON.parse(readFileSync13(join22(appRoot, "package.json"), "utf8")).name ?? "app";
3645
4268
  const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
3646
4269
  const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
3647
4270
  if (products.length === 0) {
@@ -3662,88 +4285,88 @@ async function syncPlans(options2 = {}) {
3662
4285
  ${existingResult.output.trim()}`);
3663
4286
  }
3664
4287
  const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
3665
- const dir = mkdtempSync2(join19(tmpdir2(), "flare-sync-"));
4288
+ const dir = mkdtempSync3(join22(tmpdir3(), "flare-sync-"));
3666
4289
  try {
3667
- const file = join19(dir, "sync-plans.sql");
3668
- writeFileSync13(file, renderSyncSql(rows));
4290
+ const file = join22(dir, "sync-plans.sql");
4291
+ writeFileSync15(file, renderSyncSql(rows));
3669
4292
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
3670
4293
  if (result.code !== 0) throw new Error(`Could not write plans:
3671
4294
  ${result.output.trim()}`);
3672
4295
  } finally {
3673
- rmSync3(dir, { recursive: true, force: true });
4296
+ rmSync4(dir, { recursive: true, force: true });
3674
4297
  }
3675
4298
  let created = 0;
3676
4299
  for (const row of rows) {
3677
4300
  const isNew = !known.has(row.stripePriceId);
3678
4301
  if (isNew) created++;
3679
4302
  const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
3680
- log(`${(isNew ? pc15.green("create") : pc15.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc15.dim(price)}`);
4303
+ log2(`${(isNew ? pc18.green("create") : pc18.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc18.dim(price)}`);
3681
4304
  }
3682
- for (const name of skipped) log(pc15.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
4305
+ for (const name of skipped) log2(pc18.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
3683
4306
  const configuration = await syncPortalConfiguration(stripe, rows, appName);
3684
- if (configuration) log(`${pc15.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
3685
- log(pc15.green(`
4307
+ if (configuration) log2(`${pc18.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
4308
+ log2(pc18.green(`
3686
4309
  ${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
3687
4310
  return { created, updated: rows.length - created, skipped };
3688
4311
  }
3689
4312
 
3690
4313
  // src/commands/sync.ts
3691
- import { existsSync as existsSync21 } from "fs";
3692
- import { join as join20 } from "path";
3693
- import pc16 from "picocolors";
4314
+ import { existsSync as existsSync22 } from "fs";
4315
+ import { join as join23 } from "path";
4316
+ import pc19 from "picocolors";
3694
4317
  async function syncTypes(options2 = {}) {
3695
- const log = options2.log ?? ((message) => console.log(message));
4318
+ const log2 = options2.log ?? ((message) => console.log(message));
3696
4319
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3697
4320
  const all = await loadResources(appRoot);
3698
4321
  const policies = await loadPolicies(appRoot);
3699
- const tracksPolicies = policies.length > 0 || existsSync21(join20(appRoot, "policies/index.ts"));
4322
+ const tracksPolicies = policies.length > 0 || existsSync22(join23(appRoot, "policies/index.ts"));
3700
4323
  const plan = planFiles(all, tracksPolicies ? policies : void 0);
3701
- if (!options2.check) ensureSupportFiles(appRoot, log);
4324
+ if (!options2.check) ensureSupportFiles(appRoot, log2);
3702
4325
  const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
3703
- logResults(results, log);
4326
+ logResults(results, log2);
3704
4327
  const drift = results.filter((r) => r.outcome === "skipped-drift");
3705
4328
  const unmarked = results.filter((r) => r.outcome === "skipped-unmarked");
3706
4329
  const changed = results.filter((r) => r.outcome === "create" || r.outcome === "update");
3707
4330
  const orphans = findOrphans(appRoot, all);
3708
4331
  if (drift.length) {
3709
- log(
4332
+ log2(
3710
4333
  `
3711
- ${pc16.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
4334
+ ${pc19.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
3712
4335
  Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
3713
4336
  );
3714
4337
  }
3715
4338
  if (unmarked.length) {
3716
- log(`
3717
- ${pc16.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
4339
+ log2(`
4340
+ ${pc19.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
3718
4341
  }
3719
4342
  if (orphans.length) {
3720
4343
  const resources = [...new Set(orphans.map((o) => o.resource))];
3721
- log(`
3722
- ${pc16.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
3723
- for (const orphan of orphans) log(` ${orphan.path}`);
3724
- log(
3725
- `If the resource was deleted, remove them with ${pc16.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
4344
+ log2(`
4345
+ ${pc19.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
4346
+ for (const orphan of orphans) log2(` ${orphan.path}`);
4347
+ log2(
4348
+ `If the resource was deleted, remove them with ${pc19.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
3726
4349
  );
3727
4350
  }
3728
4351
  const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
3729
4352
  if (tablesChanged && !options2.check) {
3730
- log(`
3731
- ${pc16.cyan("Tables changed.")} Create a migration with ${pc16.bold("flare gen migration <name> --from-schema")}, then ${pc16.bold("flare migrate")}.`);
4353
+ log2(`
4354
+ ${pc19.cyan("Tables changed.")} Create a migration with ${pc19.bold("flare gen migration <name> --from-schema")}, then ${pc19.bold("flare migrate")}.`);
3732
4355
  }
3733
4356
  const problems = drift.length + unmarked.length + orphans.length;
3734
4357
  if (options2.check) {
3735
4358
  const stale = changed.length;
3736
- if (stale === 0 && problems === 0) log(pc16.green("Generated files are in sync with the resource descriptors."));
3737
- else log(`
3738
- ${pc16.red("Out of sync:")} ${stale} file(s) would change. Run ${pc16.bold("flare sync-types")}.`);
4359
+ if (stale === 0 && problems === 0) log2(pc19.green("Generated files are in sync with the resource descriptors."));
4360
+ else log2(`
4361
+ ${pc19.red("Out of sync:")} ${stale} file(s) would change. Run ${pc19.bold("flare sync-types")}.`);
3739
4362
  return stale + problems > 0 ? 1 : 0;
3740
4363
  }
3741
- if (changed.length === 0 && problems === 0) log(pc16.green("Everything is in sync."));
4364
+ if (changed.length === 0 && problems === 0) log2(pc19.green("Everything is in sync."));
3742
4365
  return problems > 0 ? 1 : 0;
3743
4366
  }
3744
4367
 
3745
4368
  // src/commands/user-role.ts
3746
- import pc17 from "picocolors";
4369
+ import pc20 from "picocolors";
3747
4370
  var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
3748
4371
  var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
3749
4372
  function userRoleSql(email, role) {
@@ -3752,13 +4375,13 @@ function userRoleSql(email, role) {
3752
4375
  return `UPDATE "user" SET role = '${role}', updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE lower(email) = lower('${email}') RETURNING email, role`;
3753
4376
  }
3754
4377
  async function setUserRole(email, role, options2 = {}) {
3755
- const log = options2.log ?? ((message) => console.log(message));
4378
+ const log2 = options2.log ?? ((message) => console.log(message));
3756
4379
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3757
4380
  const sql2 = userRoleSql(email, role);
3758
4381
  const roles = await listRoles(options2);
3759
4382
  if (roles && !roles.includes(role)) {
3760
- log(pc17.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
3761
- log(pc17.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
4383
+ log2(pc20.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
4384
+ log2(pc20.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
3762
4385
  return 1;
3763
4386
  }
3764
4387
  const db = readD1Databases(appRoot)[0];
@@ -3772,19 +4395,40 @@ ${result.output.trim()}`);
3772
4395
  const [first] = extractJson(result.output);
3773
4396
  const updated = first?.results ?? [];
3774
4397
  if (updated.length === 0) {
3775
- log(pc17.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
4398
+ log2(pc20.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
3776
4399
  return 1;
3777
4400
  }
3778
- log(pc17.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
4401
+ log2(pc20.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
3779
4402
  return 0;
3780
4403
  }
3781
4404
 
3782
4405
  // src/cli.ts
3783
4406
  function createCli() {
3784
4407
  const cli = cac("flare");
3785
- cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--auth-providers <list>", "OAuth providers to scaffold, comma-separated (google, github)").option("--skip-install", "Write files without installing dependencies").example("flare create shop --auth-providers google,github").action((dir, options2) => {
4408
+ 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) => {
3786
4409
  const install = !options2.skipInstall;
3787
- const result = createApp(dir, { pm: options2.pm, install, authProviders: options2.authProviders });
4410
+ let answers = { theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
4411
+ if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers, toAppName(dir));
4412
+ const result = createApp(dir, {
4413
+ pm: options2.pm,
4414
+ install,
4415
+ ...answers,
4416
+ log: (message) => prompts3.log.message(message),
4417
+ // The install is the long part of `flare create`, so it gets a timer rather
4418
+ // than a wall of package manager output. Output is kept for when it fails.
4419
+ installer: (packageManager, args, cwd) => {
4420
+ const spin = prompts3.spinner({ indicator: "timer" });
4421
+ spin.start(`Installing dependencies with ${packageManager}`);
4422
+ const { code, output } = runQuiet(packageManager, args, cwd);
4423
+ if (code === 0) {
4424
+ spin.stop(`Installed dependencies with ${packageManager}`);
4425
+ } else {
4426
+ spin.error(`${packageManager} install failed`);
4427
+ process.stderr.write(output);
4428
+ }
4429
+ return code;
4430
+ }
4431
+ });
3788
4432
  printNextSteps(result, install);
3789
4433
  });
3790
4434
  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(
@@ -3820,6 +4464,9 @@ function createCli() {
3820
4464
  cli.command("seed [...names]", "Run seed files from seeds/ against the local D1 database").example("flare seed # every seed, in file-name order").example("flare seed contacts # just seeds/contacts.seed.ts").action(async (names) => {
3821
4465
  await runSeeds({ names });
3822
4466
  });
4467
+ cli.command("seed:resource <resource> [count]", "Fill a resource's table with sample rows built from its descriptor").option("--count <rows>", "How many rows: 1000, 25k, 1m (default: 25)").option("--remote", "Seed the deployed database instead of the local one (needs --yes)").option("--truncate", "Delete the table's rows first").option("--seed <number>", "Same number, same rows").option("--env <name>", "Wrangler environment").option("--database <binding>", "D1 binding, when the app has several").option("-y, --yes", "Confirm writing to the remote database").example("flare seed:resource Contact --count 1000").example("flare seed:resource Contact 1m # a million rows").example("flare seed:resource Contact 5k --remote --yes").action(async (resource, count, options2) => {
4468
+ await seedResourceCommand(resource, { ...options2, count: options2.count ?? count });
4469
+ });
3823
4470
  cli.command("seed:make <name>", "Create seeds/<name>.seed.ts (with example rows for a matching resource)").option("--resource <name>", "Resource to write example rows for").action(async (name, options2) => {
3824
4471
  await makeSeed(name, options2);
3825
4472
  });
@@ -3829,6 +4476,7 @@ function createCli() {
3829
4476
  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) => {
3830
4477
  process.exitCode = await syncTypes(options2);
3831
4478
  });
4479
+ 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));
3832
4480
  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) => {
3833
4481
  const local = toLocalUrl(target);
3834
4482
  const tunnel = await openTunnel(local);
@@ -3845,7 +4493,7 @@ function createCli() {
3845
4493
  cli.command(`${name} [...args]`, spec.description).allowUnknownOptions();
3846
4494
  }
3847
4495
  cli.help();
3848
- cli.version(FLARE_VERSION2);
4496
+ cli.version(FLARE_VERSION3);
3849
4497
  return cli;
3850
4498
  }
3851
4499
 
@@ -3864,6 +4512,6 @@ try {
3864
4512
  }
3865
4513
  }
3866
4514
  } catch (error) {
3867
- console.error(pc18.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
4515
+ console.error(pc21.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
3868
4516
  process.exitCode = 1;
3869
4517
  }