@flaredev/cli 0.3.0 → 0.4.1

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 (49) hide show
  1. package/dist/index.js +498 -220
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +73 -5
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/app/globals.css +0 -6
  13. package/templates/app/app/layout.tsx +37 -28
  14. package/templates/app/components/account/account-nav.tsx +39 -0
  15. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  16. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  19. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  20. package/templates/app/components/dashboard/editable.ts +12 -0
  21. package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
  22. package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
  23. package/templates/app/components/dashboard/import-dialog.tsx +62 -9
  24. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  25. package/templates/app/components/dashboard/local-time.tsx +77 -0
  26. package/templates/app/components/dashboard/query.ts +2 -0
  27. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  28. package/templates/app/components/dashboard/record-detail.tsx +203 -0
  29. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  30. package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
  31. package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
  32. package/templates/app/components/dashboard/resource-form.tsx +165 -165
  33. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  34. package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
  35. package/templates/app/components/dashboard/resource-table.tsx +408 -331
  36. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  37. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  38. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  39. package/templates/app/components/ui/sonner.tsx +33 -13
  40. package/templates/app/db/flare-schema.ts +22 -0
  41. package/templates/app/lib/api.ts +12 -0
  42. package/templates/app/lib/dashboard.ts +165 -138
  43. package/templates/app/lib/views.ts +29 -0
  44. package/templates/app/migrations/0000_init.sql +11 -0
  45. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  46. package/templates/app/vite.config.ts +41 -31
  47. package/templates/locks/package-lock.json +9 -9
  48. package/templates/locks/pnpm-lock.yaml +12 -12
  49. package/templates/locks/yarn.lock +3548 -0
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/index.ts
2
- import pc21 from "picocolors";
2
+ import pc24 from "picocolors";
3
3
 
4
4
  // src/cli.ts
5
5
  import { cac } from "cac";
6
+ import pc23 from "picocolors";
6
7
  import * as prompts3 from "@clack/prompts";
7
8
  import { FLARE_VERSION as FLARE_VERSION3 } from "@flaredev/core";
8
9
 
@@ -138,6 +139,7 @@ function parseTheme(value) {
138
139
  }
139
140
 
140
141
  // src/versions.ts
142
+ var COMPATIBILITY_DATE = "2026-09-15";
141
143
  var APP_DEPENDENCIES = {
142
144
  "@better-auth/drizzle-adapter": "1.7.5",
143
145
  "@better-auth/passkey": "1.7.5",
@@ -185,43 +187,6 @@ var APP_DEV_DEPENDENCIES = {
185
187
  wrangler: "^4.132.0"
186
188
  };
187
189
 
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
-
225
190
  // src/utils/fs.ts
226
191
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
227
192
  import { dirname, join, resolve } from "path";
@@ -281,11 +246,6 @@ function childEnv(env = process.env) {
281
246
  }
282
247
  return next;
283
248
  }
284
- function runQuiet(command, args, cwd) {
285
- const env = childEnv();
286
- const result = process.platform === "win32" ? spawnSync([command, ...args].join(" "), { cwd, env, shell: true, encoding: "utf8" }) : spawnSync(command, args, { cwd, env, encoding: "utf8" });
287
- return { code: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
288
- }
289
249
  function installArgs(pm) {
290
250
  if (pm === "npm") return ["install", "--no-audit", "--no-fund", "--loglevel=error"];
291
251
  return ["install"];
@@ -321,7 +281,7 @@ function createApp(target, options2 = {}) {
321
281
  const authMethods = parseAuthMethods(options2.auth);
322
282
  const theme = parseTheme(options2.theme);
323
283
  const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
324
- const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
284
+ const compatibilityDate = options2.compatibilityDate ?? COMPATIBILITY_DATE;
325
285
  const files = copyTemplate(join2(templatesDir, "app"), dir, {
326
286
  APP_NAME: name,
327
287
  COMPAT_DATE: compatibilityDate,
@@ -361,16 +321,10 @@ ${devVarsEntries(authProviders)}`
361
321
  if (pinned) copyLockfile(packageManager, dir);
362
322
  const log2 = options2.log ?? ((message) => console.log(message));
363
323
  log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
364
- if (options2.install !== false) {
365
- const started = Date.now();
366
- const code = (options2.installer ?? run)(packageManager, installArgs(packageManager), dir);
367
- if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
368
- if (!options2.installer) log2(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
369
- }
370
324
  return { dir, name, packageManager, inWorkspace };
371
325
  }
372
326
  function copyLockfile(packageManager, dir) {
373
- const file = packageManager === "npm" ? "package-lock.json" : packageManager === "pnpm" ? "pnpm-lock.yaml" : void 0;
327
+ const file = packageManager === "pnpm" ? "pnpm-lock.yaml" : packageManager === "yarn" ? "yarn.lock" : "package-lock.json";
374
328
  if (!file) return false;
375
329
  const source = join2(templatesDir, "locks", file);
376
330
  if (!existsSync2(source)) return false;
@@ -426,6 +380,20 @@ function splitTopLevel(input) {
426
380
  parts.push(current);
427
381
  return parts.map((part) => part.trim()).filter(Boolean);
428
382
  }
383
+ var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
384
+ var SIZE_UNITS = { b: 1, kb: 1024, mb: 1024 * 1024, gb: 1024 * 1024 * 1024 };
385
+ function parseSize(text, spec) {
386
+ const match = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i.exec(text.trim());
387
+ if (!match) {
388
+ throw new FieldGrammarError(`"${spec}": "${text}" isn't a size. Write it like "file:[image]:5mb".`);
389
+ }
390
+ const bytes = Math.round(Number(match[1]) * SIZE_UNITS[match[2].toLowerCase()]);
391
+ if (bytes < 1) throw new FieldGrammarError(`"${spec}": a size limit has to be at least one byte.`);
392
+ if (bytes > MAX_UPLOAD_BYTES) {
393
+ throw new FieldGrammarError(`"${spec}": ${text} is more than a Worker will carry in one request; the most is 100mb.`);
394
+ }
395
+ return bytes;
396
+ }
429
397
  function editDistance(a, b) {
430
398
  const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
431
399
  for (let j = 1; j <= b.length; j++) dp[0][j] = j;
@@ -486,11 +454,14 @@ function parseField(spec) {
486
454
  }
487
455
  if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate ${choice} options.`);
488
456
  field.options = options2;
489
- } else if (match = /^file(?::\[(.*)\])?$/.exec(type)) {
457
+ } else if (/^file\[/.test(type)) {
458
+ throw new FieldGrammarError(`"${spec}": file fields need a colon before the list, e.g. "${field.key}:file:[${type.slice(5, -1) || "image"}]".`);
459
+ } else if (match = /^file(?::\[(.*)\])?(?::([^:]*))?$/.exec(type)) {
490
460
  field.kind = "file";
491
461
  if (match[1] === void 0) {
492
462
  throw new FieldGrammarError(`"${spec}": file fields list accepted types, e.g. "file:[image,pdf]".`);
493
463
  }
464
+ if (match[2] !== void 0) field.maxBytes = parseSize(match[2], spec);
494
465
  const categories = Object.keys(FILE_CATEGORIES);
495
466
  const accept = list(match[1], "file type", spec).map((category) => category.toLowerCase());
496
467
  for (const category of accept) {
@@ -639,6 +610,7 @@ function options(field) {
639
610
  if (field.format) opts.format = field.format;
640
611
  if (field.widget) opts.widget = field.widget;
641
612
  if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
613
+ if (field.maxBytes !== void 0) opts.maxBytes = field.maxBytes;
642
614
  return opts;
643
615
  }
644
616
  function toField(field) {
@@ -685,15 +657,21 @@ function renderFieldsBlock(fields) {
685
657
  return fields.map((f) => `${renderField(f)}
686
658
  `).join("");
687
659
  }
688
- function renderDescriptor(name, fields) {
660
+ function renderDescriptor(name, fields, options2 = {}) {
689
661
  defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
690
662
  const block = renderFieldsBlock(fields);
663
+ const heading = [
664
+ options2.icon ? ` icon: ${literal(options2.icon)},
665
+ ` : "",
666
+ options2.group ? ` group: ${literal(options2.group)},
667
+ ` : ""
668
+ ].join("");
691
669
  return joinMarkers({
692
670
  before: `import { defineResource, field } from "@flaredev/core";
693
671
 
694
672
  export default defineResource({
695
673
  name: ${literal(name)},
696
- fields: {
674
+ ${heading} fields: {
697
675
  `,
698
676
  block,
699
677
  after: ` },
@@ -1375,7 +1353,7 @@ async function loadResources(root) {
1375
1353
  const appRoot = resolve3(root);
1376
1354
  const dir = join3(appRoot, "resources");
1377
1355
  if (!existsSync4(dir)) return [];
1378
- const jiti = createJiti(join3(appRoot, "package.json"), { moduleCache: false, fsCache: false });
1356
+ const jiti = createJiti(join3(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
1379
1357
  const loaded = [];
1380
1358
  for (const file of readdirSync3(dir).filter((name) => name.endsWith(".resource.ts")).sort()) {
1381
1359
  const path = join3(dir, file);
@@ -1575,6 +1553,7 @@ function parseDeployArgs(args) {
1575
1553
  if (arg === "--skip-migrations") skipMigrations = true;
1576
1554
  else if (arg === "--skip-secrets") skipSecrets = true;
1577
1555
  else if (arg === "--skip-security") skipSecurity = true;
1556
+ else if (arg === "--yes" || arg === "-y") continue;
1578
1557
  else {
1579
1558
  forwarded.push(arg);
1580
1559
  if (arg === "--env" && args[i + 1]) env = args[i + 1];
@@ -2007,13 +1986,24 @@ function renderTableModule(entry, all) {
2007
1986
  const name = tableExport(resource);
2008
1987
  const columns = fields.map(([key, def]) => ` ${key}: ${columnExpression(key, def, resources, resource.name)},`);
2009
1988
  const extras = [];
1989
+ const indexed = /* @__PURE__ */ new Set();
1990
+ const addIndex = (key, column) => {
1991
+ if (indexed.has(key)) return;
1992
+ indexed.add(key);
1993
+ extras.push(` index(${q(`${resource.table}_${columnName(column)}_idx`)}).on(table.${key}),`);
1994
+ };
2010
1995
  for (const [key, def] of fields) {
2011
1996
  if (def.kind === "enum") {
2012
1997
  const options2 = def.options.map(sqlString).join(", ");
2013
1998
  extras.push(` check(${q(`${resource.table}_${columnName(key)}_check`)}, sql\`\${table.${key}} in (${options2})\`),`);
1999
+ if (def.filterable !== false) addIndex(key, key);
2014
2000
  }
2015
- if (def.kind === "belongsTo") extras.push(` index(${q(`${resource.table}_${columnName(key)}_idx`)}).on(table.${key}),`);
2001
+ if (def.kind === "belongsTo") addIndex(key, key);
2016
2002
  }
2003
+ const sortKey = resource.defaultSort.field;
2004
+ if (sortKey === "createdAt" || sortKey === "updatedAt") addIndex(sortKey, sortKey === "createdAt" ? "created_at" : "updated_at");
2005
+ else if (fields.some(([key]) => key === sortKey)) addIndex(sortKey, sortKey);
2006
+ addIndex("createdAt", "created_at");
2017
2007
  const targets = [...new Set(fields.flatMap(([, def]) => def.kind === "belongsTo" ? [resources.get(def.target)] : []))].filter((target) => target.name !== resource.name).sort((a, b) => a.table.localeCompare(b.table));
2018
2008
  const coreImports = ["integer", "sqliteTable", "text"];
2019
2009
  if (fields.some(([, def]) => def.kind === "float")) coreImports.push("real");
@@ -2089,7 +2079,7 @@ var handlerImports = (entry) => [
2089
2079
  `import { createResourceHandlers } from "@flaredev/core/server";`,
2090
2080
  `import { getDb } from "@/db";`,
2091
2081
  `import { ${tableExport(entry.resource)} } from "@/db/schema";`,
2092
- `import { authorize } from "@/lib/api";`,
2082
+ `import { authorize, currentUser } from "@/lib/api";`,
2093
2083
  `import { revalidateResource } from "@/lib/cache";`,
2094
2084
  `import ${resourceLocal(entry.stem)} from "@/resources/${entry.stem}.resource";`,
2095
2085
  "",
@@ -2098,6 +2088,7 @@ var handlerImports = (entry) => [
2098
2088
  ` table: ${tableExport(entry.resource)},`,
2099
2089
  ` getDb,`,
2100
2090
  ` authorize,`,
2091
+ ` currentUser,`,
2101
2092
  ` onChange: revalidateResource,`,
2102
2093
  `});`,
2103
2094
  ""
@@ -2124,6 +2115,7 @@ function renderAdminListPage(entry) {
2124
2115
  const local = resourceLocal(entry.stem);
2125
2116
  return [
2126
2117
  `import { PageHeader } from "@/components/dashboard/page-header";`,
2118
+ `import { ResourceChart } from "@/components/dashboard/resource-chart";`,
2127
2119
  `import { ResourceStats } from "@/components/dashboard/resource-stats";`,
2128
2120
  `import { ResourceTable } from "@/components/dashboard/resource-table";`,
2129
2121
  `import type { SearchParams } from "@/components/dashboard/query";`,
@@ -2139,6 +2131,7 @@ function renderAdminListPage(entry) {
2139
2131
  ` crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: ${local}.pluralLabel }]}`,
2140
2132
  " />",
2141
2133
  ` <ResourceStats resource={${local}} />`,
2134
+ ` <ResourceChart resource={${local}} />`,
2142
2135
  ` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
2143
2136
  " </>",
2144
2137
  " );",
@@ -2146,6 +2139,17 @@ function renderAdminListPage(entry) {
2146
2139
  ""
2147
2140
  ].join("\n");
2148
2141
  }
2142
+ function renderAdminDetailPage(entry) {
2143
+ return [
2144
+ `import { RecordDetail } from "@/components/dashboard/record-detail";`,
2145
+ ...adminImports(entry),
2146
+ "",
2147
+ `export default async function ${entry.resource.name}DetailPage({ params }: { params: Promise<{ id: string }> }) {`,
2148
+ ` return <RecordDetail resource={${resourceLocal(entry.stem)}} id={(await params).id} />;`,
2149
+ "}",
2150
+ ""
2151
+ ].join("\n");
2152
+ }
2149
2153
  function renderAdminNewPage(entry) {
2150
2154
  return [
2151
2155
  `import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
@@ -2228,6 +2232,7 @@ function resourceFiles(entry, all) {
2228
2232
  { path: `resources/${stem}.client.ts`, content: renderClient(entry) },
2229
2233
  { path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
2230
2234
  { path: `app/dashboard/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
2235
+ { path: `app/dashboard/${resource.slug}/[id]/page.tsx`, content: renderAdminDetailPage(entry) },
2231
2236
  { path: `app/dashboard/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
2232
2237
  { path: `app/dashboard/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
2233
2238
  ];
@@ -2290,7 +2295,7 @@ function findOrphans(appRoot, all) {
2290
2295
  for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync11(join10(appRoot, file))) candidates.push(file);
2291
2296
  }
2292
2297
  for (const dir of list2("app/dashboard", () => true)) {
2293
- for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
2298
+ for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
2294
2299
  if (existsSync11(join10(appRoot, file))) candidates.push(file);
2295
2300
  }
2296
2301
  }
@@ -3168,7 +3173,7 @@ async function genResource(rawName, options2) {
3168
3173
  validateFields(name, fields, others, log2);
3169
3174
  if (!exists) {
3170
3175
  mkdirSync7(dirname8(path), { recursive: true });
3171
- writeFileSync9(path, renderDescriptor(name, fields));
3176
+ writeFileSync9(path, renderDescriptor(name, fields, { group: options2.group, icon: options2.icon }));
3172
3177
  log2(`${pc10.green("create".padEnd(9))} ${relativePath}`);
3173
3178
  } else {
3174
3179
  try {
@@ -3216,11 +3221,74 @@ Next: ${pc10.bold("flare migrate")} to apply it locally.`);
3216
3221
  return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
3217
3222
  }
3218
3223
 
3219
- // src/commands/gen-migration.ts
3224
+ // src/commands/gen-endpoint.ts
3220
3225
  import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
3221
- import { basename as basename2, join as join15 } from "path";
3222
- import { snakeCase } from "@flaredev/core";
3226
+ import { join as join15 } from "path";
3227
+ import { kebabCase as kebabCase3, pascalCase as pascalCase2 } from "@flaredev/core";
3223
3228
  import pc11 from "picocolors";
3229
+ var METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
3230
+ var ACTIONS = ["read", "create", "update", "delete"];
3231
+ async function genEndpoint(resourceName2, rawName, options2 = {}) {
3232
+ const log2 = options2.log ?? ((message) => console.log(message));
3233
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3234
+ const method = (options2.method ?? "GET").toUpperCase();
3235
+ if (!METHODS.includes(method)) throw new Error(`Unknown method "${method}". Use one of ${METHODS.join(", ")}.`);
3236
+ const name = kebabCase3(rawName);
3237
+ if (!name) throw new Error(`Invalid endpoint name "${rawName}".`);
3238
+ const resources = await loadResources(appRoot);
3239
+ const wanted = pascalCase2(resourceName2);
3240
+ const entry = resources.find(({ resource: resource2 }) => resource2.name === wanted);
3241
+ if (!entry) {
3242
+ throw new Error(`No resource "${resourceName2}". Available: ${resources.map(({ resource: resource2 }) => resource2.name).join(", ") || "none"}.`);
3243
+ }
3244
+ const action = options2.action ?? (method === "GET" ? "read" : "update");
3245
+ if (!ACTIONS.includes(action)) throw new Error(`Unknown action "${action}". Use one of ${ACTIONS.join(", ")}.`);
3246
+ const { resource, stem } = entry;
3247
+ const relative2 = options2.record ? `app/api/${resource.slug}/[id]/${name}/route.ts` : `app/api/${resource.slug}/${name}/route.ts`;
3248
+ const path = join15(appRoot, relative2);
3249
+ if (existsSync16(path)) throw new Error(`${relative2} already exists.`);
3250
+ mkdirSync8(join15(path, ".."), { recursive: true });
3251
+ const route = options2.record ? `/api/${resource.slug}/[id]/${name}` : `/api/${resource.slug}/${name}`;
3252
+ writeFileSync10(path, renderEndpoint({ resource: resource.name, stem, method, action, record: Boolean(options2.record), name, route }));
3253
+ log2(`${pc11.green("create".padEnd(9))} ${relative2}`);
3254
+ log2(`
3255
+ It's an ordinary route handler \u2014 edit it freely; regeneration leaves it alone.`);
3256
+ return relative2;
3257
+ }
3258
+ function renderEndpoint({ resource, stem, method, action, record, route }) {
3259
+ const body = method === "GET" || method === "DELETE" ? "" : " const body = (await request.json()) as Record<string, unknown>;\n";
3260
+ const params = record ? ", { params }: { params: Promise<{ id: string }> }" : "";
3261
+ const id = record ? " const { id } = await params;\n" : "";
3262
+ return `import { authorize, currentUser } from "@/lib/api";
3263
+ import { dashboardStore } from "@/lib/dashboard";
3264
+ import ${stem.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())}Resource from "@/resources/${stem}.resource";
3265
+
3266
+ /**
3267
+ * ${method} ${route}
3268
+ *
3269
+ * Yours to change. The session and the policy are checked the same way the generated
3270
+ * CRUD routes check them, and \`store\` is the same store they use \u2014 so hooks, validation
3271
+ * and cache invalidation all still apply.
3272
+ */
3273
+ export async function ${method}(request: Request${params}) {
3274
+ ${id} const denied = await authorize({ request, resource: ${stem.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())}Resource, action: "${action}" });
3275
+ if (denied) return denied;
3276
+
3277
+ const user = await currentUser();
3278
+ const store = dashboardStore("${resource}");
3279
+ ${body}
3280
+ // Your logic here. \`store.list\`, \`store.get\`, \`store.create\`, \`store.update\` and
3281
+ // \`store.delete\` are available, or reach for Drizzle directly with \`getDb()\`.
3282
+ return Response.json({ ok: true, ${record ? "id, " : ""}${method === "GET" || method === "DELETE" ? "" : "received: body, "}user: user?.email ?? null });
3283
+ }
3284
+ `;
3285
+ }
3286
+
3287
+ // src/commands/gen-migration.ts
3288
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
3289
+ import { basename as basename2, join as join16 } from "path";
3290
+ import { snakeCase } from "@flaredev/core";
3291
+ import pc12 from "picocolors";
3224
3292
  var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
3225
3293
  -- Write SQL that undoes the up migration, e.g.:
3226
3294
  -- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
@@ -3236,31 +3304,31 @@ async function genMigration(rawName, options2 = {}) {
3236
3304
  log2(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
3237
3305
  return [];
3238
3306
  }
3239
- for (const file of files) log2(`${pc11.green("create".padEnd(9))} ${file}`);
3240
- for (const repair of repairs) log2(pc11.dim(`repaired ${repair}`));
3307
+ for (const file of files) log2(`${pc12.green("create".padEnd(9))} ${file}`);
3308
+ for (const repair of repairs) log2(pc12.dim(`repaired ${repair}`));
3241
3309
  if (!options2.fromSchema) {
3242
- const downDir = join15(appRoot, "migrations", "down");
3243
- mkdirSync8(downDir, { recursive: true });
3310
+ const downDir = join16(appRoot, "migrations", "down");
3311
+ mkdirSync9(downDir, { recursive: true });
3244
3312
  for (const file of files) {
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)}`);
3313
+ const down = join16(downDir, basename2(file));
3314
+ if (existsSync17(down)) continue;
3315
+ writeFileSync11(down, DOWN_TEMPLATE(basename2(file)));
3316
+ log2(`${pc12.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
3249
3317
  }
3250
3318
  log2(`
3251
- Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc11.bold("flare migrate")}.`);
3319
+ Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc12.bold("flare migrate")}.`);
3252
3320
  } else {
3253
3321
  log2(`
3254
- Next: ${pc11.bold("flare migrate")}.`);
3322
+ Next: ${pc12.bold("flare migrate")}.`);
3255
3323
  }
3256
3324
  return files;
3257
3325
  }
3258
3326
 
3259
3327
  // src/commands/gen-policy.ts
3260
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
3261
- import { dirname as dirname9, join as join16 } from "path";
3328
+ import { existsSync as existsSync18, mkdirSync as mkdirSync10, writeFileSync as writeFileSync12 } from "fs";
3329
+ import { dirname as dirname9, join as join17 } from "path";
3262
3330
  import { definePolicy } from "@flaredev/core";
3263
- import pc12 from "picocolors";
3331
+ import pc13 from "picocolors";
3264
3332
  function parseRoles(input, what = "--roles") {
3265
3333
  const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
3266
3334
  if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
@@ -3276,8 +3344,8 @@ async function genPolicy(rawName, options2) {
3276
3344
  throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
3277
3345
  }
3278
3346
  const relative2 = policyPath(name);
3279
- const path = join16(appRoot, relative2);
3280
- const exists = existsSync17(path);
3347
+ const path = join17(appRoot, relative2);
3348
+ const exists = existsSync18(path);
3281
3349
  let roles;
3282
3350
  let deleteRoles;
3283
3351
  if (options2.roles || !exists) {
@@ -3288,13 +3356,13 @@ async function genPolicy(rawName, options2) {
3288
3356
  if (roles && deleteRoles) {
3289
3357
  const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
3290
3358
  if (!exists) {
3291
- mkdirSync9(dirname9(path), { recursive: true });
3292
- writeFileSync11(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
3293
- log2(`${pc12.green("create".padEnd(9))} ${relative2}`);
3359
+ mkdirSync10(dirname9(path), { recursive: true });
3360
+ writeFileSync12(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
3361
+ log2(`${pc13.green("create".padEnd(9))} ${relative2}`);
3294
3362
  } else {
3295
3363
  try {
3296
3364
  const outcome = writeGenerated(path, block, { force: options2.force });
3297
- log2(`${(outcome === "identical" ? pc12.dim : pc12.yellow)(outcome.padEnd(9))} ${relative2}`);
3365
+ log2(`${(outcome === "identical" ? pc13.dim : pc13.yellow)(outcome.padEnd(9))} ${relative2}`);
3298
3366
  } catch (error) {
3299
3367
  if (error instanceof DriftError) {
3300
3368
  throw new Error(`The roles in ${relative2} were edited by hand, so --roles won't overwrite them. Edit the file directly, or pass --force.`);
@@ -3306,15 +3374,15 @@ async function genPolicy(rawName, options2) {
3306
3374
  const policies = await loadPolicies(appRoot);
3307
3375
  logResults(applyPlan(appRoot, planFiles(resources, policies)), log2);
3308
3376
  log2(`
3309
- ${pc12.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
3377
+ ${pc13.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
3310
3378
  return { name, path: relative2 };
3311
3379
  }
3312
3380
 
3313
3381
  // src/commands/migrate.ts
3314
- import { existsSync as existsSync18, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
3382
+ import { existsSync as existsSync19, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync13 } from "fs";
3315
3383
  import { tmpdir } from "os";
3316
- import { join as join17 } from "path";
3317
- import pc13 from "picocolors";
3384
+ import { join as join18 } from "path";
3385
+ import pc14 from "picocolors";
3318
3386
 
3319
3387
  // src/generator/down.ts
3320
3388
  var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
@@ -3431,7 +3499,7 @@ async function rollback(options2 = {}) {
3431
3499
  const steps = options2.steps ?? 1;
3432
3500
  if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
3433
3501
  const { appRoot, db, target, where, wrangler, log: log2 } = context(options2);
3434
- const migrationsDir = join17(appRoot, db.migrations_dir ?? "migrations");
3502
+ const migrationsDir = join18(appRoot, db.migrations_dir ?? "migrations");
3435
3503
  const applied = await appliedMigrations(options2, steps);
3436
3504
  if (applied.length === 0) {
3437
3505
  log2("Nothing to roll back.");
@@ -3440,8 +3508,8 @@ async function rollback(options2 = {}) {
3440
3508
  const plan = [];
3441
3509
  for (const { name } of applied) {
3442
3510
  if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
3443
- const handWritten = join17(migrationsDir, "down", name);
3444
- if (existsSync18(handWritten)) {
3511
+ const handWritten = join18(migrationsDir, "down", name);
3512
+ if (existsSync19(handWritten)) {
3445
3513
  const sql2 = readFileSync11(handWritten, "utf8");
3446
3514
  if (splitStatements(sql2).length === 0) {
3447
3515
  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.`);
@@ -3449,8 +3517,8 @@ async function rollback(options2 = {}) {
3449
3517
  plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
3450
3518
  continue;
3451
3519
  }
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.`);
3520
+ const upPath = join18(migrationsDir, name);
3521
+ if (!existsSync19(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
3454
3522
  const down = deriveDown(readFileSync11(upPath, "utf8"));
3455
3523
  if (!down.reversible) {
3456
3524
  throw new Error(
@@ -3460,37 +3528,37 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
3460
3528
  }
3461
3529
  plan.push({ name, sql: down.sql, source: "derived" });
3462
3530
  }
3463
- log2(`${pc13.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3531
+ log2(`${pc14.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3464
3532
  for (const step of plan) {
3465
- log2(` ${step.name} ${pc13.dim(`(${step.source})`)}`);
3466
- for (const line of step.sql.trim().split("\n")) log2(pc13.dim(` ${line}`));
3533
+ log2(` ${step.name} ${pc14.dim(`(${step.source})`)}`);
3534
+ for (const line of step.sql.trim().split("\n")) log2(pc14.dim(` ${line}`));
3467
3535
  }
3468
3536
  if (options2.remote && !options2.yes) {
3469
- log2(pc13.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3537
+ log2(pc14.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3470
3538
  return 1;
3471
3539
  }
3472
3540
  const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
3473
3541
  DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
3474
- const dir = mkdtempSync(join17(tmpdir(), "flare-rollback-"));
3542
+ const dir = mkdtempSync(join18(tmpdir(), "flare-rollback-"));
3475
3543
  try {
3476
- const file = join17(dir, "rollback.sql");
3477
- writeFileSync12(file, script);
3544
+ const file = join18(dir, "rollback.sql");
3545
+ writeFileSync13(file, script);
3478
3546
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
3479
3547
  if (result.code !== 0) throw new Error(`Rollback failed:
3480
3548
  ${result.output.trim()}`);
3481
3549
  } finally {
3482
3550
  rmSync2(dir, { recursive: true, force: true });
3483
3551
  }
3484
- log2(pc13.green(`
3552
+ log2(pc14.green(`
3485
3553
  Rolled back ${plan.map((step) => step.name).join(", ")}.`));
3486
3554
  return 0;
3487
3555
  }
3488
3556
 
3489
3557
  // src/commands/rm.ts
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";
3558
+ import { existsSync as existsSync20, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
3559
+ import { dirname as dirname10, join as join19 } from "path";
3492
3560
  import { relationGraph as relationGraph3 } from "@flaredev/core";
3493
- import pc14 from "picocolors";
3561
+ import pc15 from "picocolors";
3494
3562
  var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
3495
3563
  function handWrittenReason(source, header, options2 = {}) {
3496
3564
  const parts = splitMarkers(source);
@@ -3507,10 +3575,10 @@ function handWrittenReason(source, header, options2 = {}) {
3507
3575
  return null;
3508
3576
  }
3509
3577
  function removeEmptyDirs(appRoot, start) {
3510
- let dir = dirname10(join18(appRoot, start));
3511
- const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "dashboard")];
3578
+ let dir = dirname10(join19(appRoot, start));
3579
+ const roots = [join19(appRoot, "app", "api"), join19(appRoot, "app", "dashboard")];
3512
3580
  const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
3513
- while (inside() && existsSync19(dir) && readdirSync7(dir).length === 0) {
3581
+ while (inside() && existsSync20(dir) && readdirSync7(dir).length === 0) {
3514
3582
  rmdirSync(dir);
3515
3583
  dir = dirname10(dir);
3516
3584
  }
@@ -3539,7 +3607,7 @@ async function rmResource(rawName, options2 = {}) {
3539
3607
  if (entry) {
3540
3608
  owned.add(entry.file);
3541
3609
  for (const file of planFiles(all)) {
3542
- if (existsSync19(join18(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3610
+ if (existsSync20(join19(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3543
3611
  }
3544
3612
  }
3545
3613
  for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
@@ -3548,7 +3616,7 @@ async function rmResource(rawName, options2 = {}) {
3548
3616
  if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
3549
3617
  const findings = [];
3550
3618
  for (const path of owned) {
3551
- const source = readFileSync12(join18(appRoot, path), "utf8");
3619
+ const source = readFileSync12(join19(appRoot, path), "utf8");
3552
3620
  let wrapper;
3553
3621
  if (entry && path === entry.file) {
3554
3622
  const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
@@ -3569,34 +3637,34 @@ Move what you want to keep, or pass --force to delete anyway.`);
3569
3637
  }
3570
3638
  const removed = [...owned].sort();
3571
3639
  for (const path of removed) {
3572
- unlinkSync2(join18(appRoot, path));
3640
+ unlinkSync2(join19(appRoot, path));
3573
3641
  removeEmptyDirs(appRoot, path);
3574
- log2(`${pc14.red("remove".padEnd(9))} ${path}`);
3642
+ log2(`${pc15.red("remove".padEnd(9))} ${path}`);
3575
3643
  }
3576
3644
  logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log2);
3577
3645
  const table = entry ? tableExport(entry.resource) : void 0;
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"))
3646
+ if (table && existsSync20(join19(appRoot, "seeds"))) {
3647
+ const seeds = readdirSync7(join19(appRoot, "seeds")).filter(
3648
+ (file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join19(appRoot, "seeds", file), "utf8"))
3581
3649
  );
3582
- if (seeds.length) log2(pc14.yellow(`
3650
+ if (seeds.length) log2(pc15.yellow(`
3583
3651
  These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
3584
3652
  }
3585
3653
  let migrations = [];
3586
3654
  if (!options2.skipMigration && entry) {
3587
3655
  const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
3588
3656
  migrations = result.files;
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}`));
3657
+ for (const file of migrations) log2(`${pc15.green("create".padEnd(9))} ${file}`);
3658
+ for (const repair of result.repairs) log2(pc15.dim(`repaired ${repair}`));
3591
3659
  if (migrations.length) log2(`
3592
- Next: ${pc14.bold("flare migrate")} to drop the table locally.`);
3660
+ Next: ${pc15.bold("flare migrate")} to drop the table locally.`);
3593
3661
  }
3594
3662
  return { removed, migrations };
3595
3663
  }
3596
3664
 
3597
3665
  // src/commands/role.ts
3598
3666
  import { humanize } from "@flaredev/core";
3599
- import pc15 from "picocolors";
3667
+ import pc16 from "picocolors";
3600
3668
  var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
3601
3669
  function roleInsertSql(name, label) {
3602
3670
  if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
@@ -3641,17 +3709,17 @@ ${result.output.trim()}`);
3641
3709
  }
3642
3710
  const [first] = extractJson(result.output);
3643
3711
  const added = first?.results?.[0];
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" : ""}`));
3712
+ log2(pc16.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
3713
+ log2(pc16.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
3646
3714
  return 0;
3647
3715
  }
3648
3716
 
3649
3717
  // src/commands/seed.ts
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";
3718
+ import { existsSync as existsSync21, mkdirSync as mkdirSync11, readdirSync as readdirSync8, writeFileSync as writeFileSync14 } from "fs";
3719
+ import { join as join21 } from "path";
3720
+ import { createFake, kebabCase as kebabCase4, storedFields as storedFields2 } from "@flaredev/core";
3653
3721
  import { createJiti as createJiti4 } from "jiti";
3654
- import pc16 from "picocolors";
3722
+ import pc17 from "picocolors";
3655
3723
 
3656
3724
  // src/seed/bulk.ts
3657
3725
  var MAX_STATEMENT_BYTES = 8e4;
@@ -3746,16 +3814,16 @@ function createInsertMany(sink, drizzle, onProgress) {
3746
3814
 
3747
3815
  // src/seed/local-d1.ts
3748
3816
  import { createRequire } from "module";
3749
- import { join as join19 } from "path";
3817
+ import { join as join20 } from "path";
3750
3818
  import { pathToFileURL } from "url";
3751
3819
  var LOCAL_STATE = ".wrangler/state/v3";
3752
3820
  async function openLocalD1(appRoot, binding) {
3753
3821
  process.env.WRANGLER_LOG ??= "error";
3754
- const appRequire = createRequire(join19(appRoot, "package.json"));
3822
+ const appRequire = createRequire(join20(appRoot, "package.json"));
3755
3823
  const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
3756
3824
  const { env, dispose } = await wrangler.getPlatformProxy({
3757
- configPath: join19(appRoot, "wrangler.jsonc"),
3758
- persist: { path: join19(appRoot, LOCAL_STATE) }
3825
+ configPath: join20(appRoot, "wrangler.jsonc"),
3826
+ persist: { path: join20(appRoot, LOCAL_STATE) }
3759
3827
  });
3760
3828
  const bindings = env;
3761
3829
  const name = binding ?? "DB";
@@ -3770,8 +3838,8 @@ async function openLocalD1(appRoot, binding) {
3770
3838
  // src/commands/seed.ts
3771
3839
  var SEEDS_DIR = "seeds";
3772
3840
  function seedFiles(appRoot, names) {
3773
- const dir = join20(appRoot, SEEDS_DIR);
3774
- const all = existsSync20(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3841
+ const dir = join21(appRoot, SEEDS_DIR);
3842
+ const all = existsSync21(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3775
3843
  if (!names?.length) return all;
3776
3844
  return names.map((name) => {
3777
3845
  const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
@@ -3786,24 +3854,24 @@ async function runSeeds(options2 = {}) {
3786
3854
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3787
3855
  const files = seedFiles(appRoot, options2.names);
3788
3856
  if (files.length === 0) {
3789
- log2(`No seeds found. Create one with ${pc16.bold("flare seed:make <name>")}.`);
3857
+ log2(`No seeds found. Create one with ${pc17.bold("flare seed:make <name>")}.`);
3790
3858
  return;
3791
3859
  }
3792
- const jiti = createJiti4(join20(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3860
+ const jiti = createJiti4(join21(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3793
3861
  const { drizzle } = await jiti.import("drizzle-orm/d1");
3794
3862
  const drizzleHelpers = await jiti.import("drizzle-orm");
3795
- const schema = await jiti.import(join20(appRoot, "db/schema.ts"));
3863
+ const schema = await jiti.import(join21(appRoot, "db/schema.ts"));
3796
3864
  const local = await openLocalD1(appRoot);
3797
3865
  try {
3798
3866
  const db = drizzle(local.db, { schema });
3799
3867
  const insertMany = createInsertMany(local.db, drizzleHelpers);
3800
3868
  for (const file of files) {
3801
- const mod = await jiti.import(join20(appRoot, SEEDS_DIR, file));
3869
+ const mod = await jiti.import(join21(appRoot, SEEDS_DIR, file));
3802
3870
  if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
3803
- log2(`${pc16.cyan("seed")} ${file}`);
3871
+ log2(`${pc17.cyan("seed")} ${file}`);
3804
3872
  await mod.default({ db, env: local.env, insertMany, fake: createFake(), log: (message) => log2(` ${message}`) });
3805
3873
  }
3806
- log2(pc16.green(`
3874
+ log2(pc17.green(`
3807
3875
  Ran ${files.length} seed(s) against the local database.`));
3808
3876
  } finally {
3809
3877
  await local.dispose();
@@ -3885,32 +3953,141 @@ ${[...lines, ...todos].join("\n")}
3885
3953
  async function makeSeed(rawName, options2 = {}) {
3886
3954
  const log2 = options2.log ?? ((message) => console.log(message));
3887
3955
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3888
- const name = kebabCase3(rawName);
3956
+ const name = kebabCase4(rawName);
3889
3957
  if (!name) throw new Error(`Invalid seed name "${rawName}".`);
3890
3958
  const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
3891
- const path = join20(appRoot, relative2);
3892
- if (existsSync20(path)) throw new Error(`${relative2} already exists.`);
3959
+ const path = join21(appRoot, relative2);
3960
+ if (existsSync21(path)) throw new Error(`${relative2} already exists.`);
3893
3961
  const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
3894
3962
  let resource;
3895
3963
  if (options2.resource) {
3896
3964
  resource = resources.find((r) => r.name.toLowerCase() === options2.resource.toLowerCase());
3897
3965
  if (!resource) throw new Error(`No resource "${options2.resource}". Available: ${resources.map((r) => r.name).join(", ") || "none"}.`);
3898
3966
  } else {
3899
- resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
3967
+ resource = resources.find((r) => [kebabCase4(r.name), r.slug].includes(name));
3900
3968
  }
3901
- mkdirSync10(join20(appRoot, SEEDS_DIR), { recursive: true });
3902
- writeFileSync13(path, renderSeed(resource));
3903
- log2(`${pc16.green("create".padEnd(9))} ${relative2}`);
3969
+ mkdirSync11(join21(appRoot, SEEDS_DIR), { recursive: true });
3970
+ writeFileSync14(path, renderSeed(resource));
3971
+ log2(`${pc17.green("create".padEnd(9))} ${relative2}`);
3904
3972
  log2(`
3905
- Run it with ${pc16.bold(`flare seed ${name}`)}.`);
3973
+ Run it with ${pc17.bold(`flare seed ${name}`)}.`);
3906
3974
  return relative2;
3907
3975
  }
3908
3976
 
3909
- // src/commands/seed-resource.ts
3910
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync14 } from "fs";
3977
+ // src/commands/db-push.ts
3978
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync15 } from "fs";
3911
3979
  import { tmpdir as tmpdir2 } from "os";
3912
- import { join as join21 } from "path";
3913
- import pc17 from "picocolors";
3980
+ import { join as join22 } from "path";
3981
+ import pc18 from "picocolors";
3982
+
3983
+ // src/terminal.ts
3984
+ var isTTY = () => Boolean(process.stdout.isTTY);
3985
+ function formatDuration(ms) {
3986
+ const seconds = Math.round(ms / 1e3);
3987
+ if (seconds < 60) return `${seconds}s`;
3988
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
3989
+ return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, "0")}m`;
3990
+ }
3991
+ var formatCount = (value) => value.toLocaleString();
3992
+ function formatRate(count, ms, unit = "rows") {
3993
+ if (ms <= 0) return `${formatCount(count)} ${unit}/s`;
3994
+ return `${formatCount(Math.round(count / ms * 1e3))} ${unit}/s`;
3995
+ }
3996
+ function progressLine(everyMs = 120) {
3997
+ if (!isTTY()) return { update: () => {
3998
+ }, done: () => {
3999
+ } };
4000
+ let last = 0;
4001
+ let width = 0;
4002
+ const write = (message) => {
4003
+ process.stdout.write(`\r${message.padEnd(width)}`);
4004
+ width = Math.max(width, message.length);
4005
+ };
4006
+ return {
4007
+ update(message) {
4008
+ const now = Date.now();
4009
+ if (now - last < everyMs) return;
4010
+ last = now;
4011
+ write(message);
4012
+ },
4013
+ done() {
4014
+ if (width > 0) process.stdout.write(`\r${" ".repeat(width)}\r`);
4015
+ width = 0;
4016
+ }
4017
+ };
4018
+ }
4019
+
4020
+ // src/commands/db-push.ts
4021
+ var PAGE = 2e3;
4022
+ function pickDatabase(databases, wanted) {
4023
+ if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
4024
+ if (wanted) {
4025
+ const match = databases.find((db) => db.binding === wanted || db.database_name === wanted);
4026
+ if (!match) throw new Error(`No D1 database "${wanted}". Available: ${databases.map((db) => db.binding).join(", ")}.`);
4027
+ return match;
4028
+ }
4029
+ if (databases.length > 1) throw new Error(`Several D1 databases; pick one with --database (${databases.map((db) => db.binding).join(", ")}).`);
4030
+ return databases[0];
4031
+ }
4032
+ async function dbPush(tables, options2 = {}) {
4033
+ const log2 = options2.log ?? ((message) => console.log(message));
4034
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
4035
+ if (tables.length === 0) throw new Error("Name at least one table, e.g. flare db:push products categories --yes");
4036
+ const bad = tables.find((table) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(table));
4037
+ if (bad) throw new Error(`"${bad}" isn't a table name.`);
4038
+ if (!options2.yes) {
4039
+ throw new Error(`This writes ${tables.join(", ")} to the deployed database. Re-run with --yes to confirm.`);
4040
+ }
4041
+ const db = pickDatabase(readD1Databases(appRoot), options2.database);
4042
+ const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
4043
+ const started = Date.now();
4044
+ const local = await openLocalD1(appRoot, options2.database);
4045
+ const dir = mkdtempSync2(join22(tmpdir2(), "flare-push-"));
4046
+ try {
4047
+ const statements = [];
4048
+ const counts = [];
4049
+ for (const table of tables) {
4050
+ if (options2.truncate) statements.push(`DELETE FROM "${table}";`);
4051
+ let offset = 0;
4052
+ let rows = 0;
4053
+ for (; ; ) {
4054
+ const page = await local.db.prepare(`SELECT * FROM "${table}" LIMIT ${PAGE} OFFSET ${offset}`).all();
4055
+ const results = page.results ?? [];
4056
+ if (results.length === 0) break;
4057
+ const columns = Object.keys(results[0]);
4058
+ for (const statement of insertStatements(table, columns, results)) statements.push(statement.sql);
4059
+ rows += results.length;
4060
+ if (results.length < PAGE) break;
4061
+ offset += PAGE;
4062
+ }
4063
+ counts.push({ table, rows });
4064
+ log2(`${pc18.cyan("read")} ${table} ${pc18.dim(`(${formatCount(rows)} rows)`)}`);
4065
+ }
4066
+ const total = counts.reduce((sum, entry) => sum + entry.rows, 0);
4067
+ if (total === 0) {
4068
+ log2("Nothing to push: those tables are empty locally.");
4069
+ return { tables: counts, ms: Date.now() - started };
4070
+ }
4071
+ const file = join22(dir, "push.sql");
4072
+ writeFileSync15(file, statements.join("\n"));
4073
+ const target = ["--remote", ...options2.env ? ["--env", options2.env] : []];
4074
+ const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
4075
+ if (result.code !== 0) throw new Error(`Pushing to ${db.database_name} failed:
4076
+ ${result.output.trim()}`);
4077
+ const ms = Date.now() - started;
4078
+ log2(`${pc18.green("\u2714")} Pushed ${pc18.bold(formatCount(total))} rows to ${pc18.bold(db.database_name)} ${pc18.dim(`(${formatDuration(ms)})`)}`);
4079
+ return { tables: counts, ms };
4080
+ } finally {
4081
+ rmSync3(dir, { recursive: true, force: true });
4082
+ await local.dispose();
4083
+ }
4084
+ }
4085
+
4086
+ // src/commands/seed-resource.ts
4087
+ import { mkdtempSync as mkdtempSync3, rmSync as rmSync4, writeFileSync as writeFileSync16 } from "fs";
4088
+ import { tmpdir as tmpdir3 } from "os";
4089
+ import { join as join23 } from "path";
4090
+ import pc19 from "picocolors";
3914
4091
 
3915
4092
  // src/seed/rows.ts
3916
4093
  import { columnName as columnName2, createFake as createFake2, storedFields as storedFields3 } from "@flaredev/core";
@@ -3933,7 +4110,9 @@ function stringValue(key, def, fake, resource, index) {
3933
4110
  return fake.slug();
3934
4111
  }
3935
4112
  const name = key.toLowerCase();
3936
- const organisation = has(resource.name.toLowerCase(), "company", "vendor", "supplier", "organisation", "organization", "account", "team", "store", "brand");
4113
+ const resourceName2 = resource.name.toLowerCase();
4114
+ const organisation = has(resourceName2, "company", "vendor", "supplier", "organisation", "organization", "account", "team", "store", "brand");
4115
+ const catalogue = has(resourceName2, "product", "item", "article", "sku", "listing", "merch", "book", "course", "plan", "service");
3937
4116
  let value;
3938
4117
  if (has(name, "email")) value = fake.email();
3939
4118
  else if (has(name, "phone", "mobile", "tel", "whatsapp")) value = fake.phone();
@@ -3948,7 +4127,7 @@ function stringValue(key, def, fake, resource, index) {
3948
4127
  else if (has(name, "colour", "color")) value = fake.color();
3949
4128
  else if (has(name, "code", "sku", "reference", "number")) value = `${fake.words(1).slice(0, 3).toUpperCase()}-${String(index + 1).padStart(6, "0")}`;
3950
4129
  else if (has(name, "title", "position", "role", "job")) value = fake.jobTitle();
3951
- else if (has(name, "name")) value = organisation ? fake.company() : fake.fullName();
4130
+ else if (has(name, "name")) value = organisation ? fake.company() : catalogue ? fake.product(digitalish(resource)) : fake.fullName();
3952
4131
  else if (has(name, "description", "summary", "note", "bio", "about", "message", "comment")) value = fake.sentence();
3953
4132
  else value = `${fake.words(2)}`.replace(/^./, (letter) => letter.toUpperCase());
3954
4133
  if (def.maxLength && value.length > def.maxLength) value = value.slice(0, def.maxLength);
@@ -4010,6 +4189,11 @@ function uniquify(value, row) {
4010
4189
  if (/^[a-z0-9.-]+$/.test(value)) return `${value}-${row}`;
4011
4190
  return `${value} ${row}`;
4012
4191
  }
4192
+ function digitalish(resource) {
4193
+ return Object.entries(resource.fields).some(
4194
+ ([key, def]) => def.kind === "enum" && /kind|type/.test(key) && def.options.some((option) => /digital|download|licence|license/i.test(option))
4195
+ );
4196
+ }
4013
4197
  function seedColumns(resource) {
4014
4198
  return ["id", ...storedFields3(resource).map(([key]) => columnName2(key)), "created_at", "updated_at"];
4015
4199
  }
@@ -4018,11 +4202,13 @@ function* seedRows(resource, count, options2 = {}) {
4018
4202
  const parentIds = options2.parentIds ?? /* @__PURE__ */ new Map();
4019
4203
  const fields = storedFields3(resource);
4020
4204
  const now = options2.now ?? Date.now();
4205
+ const start = options2.startIndex ?? 0;
4021
4206
  for (let i = 0; i < count; i++) {
4207
+ const number = start + i;
4022
4208
  const row = { id: fake.id() };
4023
4209
  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);
4210
+ let value = fieldValue(key, def, fake, resource, parentIds, number);
4211
+ if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value, number + 1);
4026
4212
  row[columnName2(key)] = value;
4027
4213
  }
4028
4214
  const created = now - fake.int(0, 365) * 864e5 - fake.int(0, 86399999);
@@ -4033,6 +4219,10 @@ function* seedRows(resource, count, options2 = {}) {
4033
4219
  }
4034
4220
 
4035
4221
  // src/commands/seed-resource.ts
4222
+ async function countRows(db, table) {
4223
+ const row = await db.prepare(`SELECT count(*) AS total FROM "${table}"`).first();
4224
+ return row?.total ?? 0;
4225
+ }
4036
4226
  var PARENT_SAMPLE = 5e3;
4037
4227
  function parseCount(value, fallback = 25) {
4038
4228
  if (value === void 0 || value === "") return fallback;
@@ -4052,7 +4242,7 @@ function pickResource(resources, name) {
4052
4242
  }
4053
4243
  return match;
4054
4244
  }
4055
- function pickDatabase(databases, wanted) {
4245
+ function pickDatabase2(databases, wanted) {
4056
4246
  if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
4057
4247
  if (wanted) {
4058
4248
  const match = databases.find((db) => db.binding === wanted || db.database_name === wanted);
@@ -4081,13 +4271,13 @@ async function seedResourceCommand(name, options2 = {}) {
4081
4271
  ...options2,
4082
4272
  onProgress: (written, total) => {
4083
4273
  const elapsed = Date.now() - started;
4084
- progress.update(` ${pc17.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc17.dim(formatRate(written, elapsed))}`);
4274
+ progress.update(` ${pc19.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc19.dim(formatRate(written, elapsed))}`);
4085
4275
  }
4086
4276
  });
4087
4277
  progress.done();
4088
4278
  const { rows, ms, where } = result;
4089
4279
  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)})`)
4280
+ `${pc19.green("\u2714")} Seeded ${pc19.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc19.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
4091
4281
  );
4092
4282
  return result;
4093
4283
  } catch (error) {
@@ -4108,9 +4298,10 @@ async function seedResource(name, options2 = {}) {
4108
4298
  const columns = seedColumns(resource);
4109
4299
  const started = Date.now();
4110
4300
  let parents = /* @__PURE__ */ new Map();
4111
- const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents });
4301
+ let startIndex = 0;
4302
+ const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents, startIndex });
4112
4303
  if (options2.remote) {
4113
- const db = pickDatabase(readD1Databases(appRoot), options2.database);
4304
+ const db = pickDatabase2(readD1Databases(appRoot), options2.database);
4114
4305
  const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
4115
4306
  const target = ["--remote", ...options2.env ? ["--env", options2.env] : []];
4116
4307
  const query = async (sql2) => {
@@ -4125,8 +4316,12 @@ ${result.output.trim()}`);
4125
4316
  const found = await query(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`);
4126
4317
  return found.map((row) => row.id);
4127
4318
  });
4128
- const dir = mkdtempSync2(join21(tmpdir2(), "flare-seed-"));
4129
- const file = join21(dir, `${resource.table}.sql`);
4319
+ if (!options2.truncate) {
4320
+ const [counted] = await query(`SELECT count(*) AS total FROM "${resource.table}"`);
4321
+ startIndex = counted?.total ?? 0;
4322
+ }
4323
+ const dir = mkdtempSync3(join23(tmpdir3(), "flare-seed-"));
4324
+ const file = join23(dir, `${resource.table}.sql`);
4130
4325
  try {
4131
4326
  const statements = [];
4132
4327
  if (options2.truncate) statements.push(`DELETE FROM "${resource.table}";`);
@@ -4136,13 +4331,13 @@ ${result.output.trim()}`);
4136
4331
  written += statement.rows;
4137
4332
  options2.onProgress?.(written, total);
4138
4333
  }
4139
- writeFileSync14(file, statements.join("\n"));
4140
- log2(`${pc17.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc17.bold(db.database_name)}`);
4334
+ writeFileSync16(file, statements.join("\n"));
4335
+ log2(`${pc19.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc19.bold(db.database_name)}`);
4141
4336
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
4142
4337
  if (result.code !== 0) throw new Error(`Seeding the remote database failed:
4143
4338
  ${result.output.trim()}`);
4144
4339
  } finally {
4145
- rmSync3(dir, { recursive: true, force: true });
4340
+ rmSync4(dir, { recursive: true, force: true });
4146
4341
  }
4147
4342
  return { resource, rows: total, ms: Date.now() - started, where };
4148
4343
  }
@@ -4153,6 +4348,7 @@ ${result.output.trim()}`);
4153
4348
  return (found.results ?? []).map((row) => row.id);
4154
4349
  });
4155
4350
  if (options2.truncate) await local.db.exec(`DELETE FROM "${resource.table}";`);
4351
+ else startIndex = await countRows(local.db, resource.table);
4156
4352
  const sink = { exec: (sql2) => local.db.exec(sql2) };
4157
4353
  const written = await bulkInsert(sink, {
4158
4354
  table: resource.table,
@@ -4168,16 +4364,16 @@ ${result.output.trim()}`);
4168
4364
 
4169
4365
  // src/commands/billing-sync.ts
4170
4366
  import { createRequire as createRequire2 } from "module";
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";
4367
+ import { existsSync as existsSync22, mkdtempSync as mkdtempSync4, readFileSync as readFileSync13, rmSync as rmSync5, writeFileSync as writeFileSync17 } from "fs";
4368
+ import { tmpdir as tmpdir4 } from "os";
4369
+ import { join as join24 } from "path";
4174
4370
  import { pathToFileURL as pathToFileURL2 } from "url";
4175
- import { kebabCase as kebabCase4 } from "@flaredev/core";
4176
- import pc18 from "picocolors";
4371
+ import { kebabCase as kebabCase5 } from "@flaredev/core";
4372
+ import pc20 from "picocolors";
4177
4373
  function stripeKeys(appRoot) {
4178
4374
  const map = {};
4179
- const path = join22(appRoot, ".dev.vars");
4180
- if (existsSync21(path)) {
4375
+ const path = join24(appRoot, ".dev.vars");
4376
+ if (existsSync22(path)) {
4181
4377
  for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
4182
4378
  const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
4183
4379
  if (match) map[match[1]] = match[2].trim();
@@ -4198,7 +4394,7 @@ function planRows(catalog) {
4198
4394
  return ok;
4199
4395
  });
4200
4396
  const currencies = new Set(usable.map((price) => price.currency));
4201
- const base = product.metadata?.slug || kebabCase4(product.name);
4397
+ const base = product.metadata?.slug || kebabCase5(product.name);
4202
4398
  for (const price of usable) {
4203
4399
  const interval = price.recurring?.interval ?? null;
4204
4400
  const parts = [base];
@@ -4261,10 +4457,10 @@ async function syncPlans(options2 = {}) {
4261
4457
  if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
4262
4458
  const db = readD1Databases(appRoot)[0];
4263
4459
  if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
4264
- const appRequire = createRequire2(join22(appRoot, "package.json"));
4460
+ const appRequire = createRequire2(join24(appRoot, "package.json"));
4265
4461
  const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
4266
4462
  const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
4267
- const appName = JSON.parse(readFileSync13(join22(appRoot, "package.json"), "utf8")).name ?? "app";
4463
+ const appName = JSON.parse(readFileSync13(join24(appRoot, "package.json"), "utf8")).name ?? "app";
4268
4464
  const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
4269
4465
  const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
4270
4466
  if (products.length === 0) {
@@ -4285,41 +4481,41 @@ async function syncPlans(options2 = {}) {
4285
4481
  ${existingResult.output.trim()}`);
4286
4482
  }
4287
4483
  const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
4288
- const dir = mkdtempSync3(join22(tmpdir3(), "flare-sync-"));
4484
+ const dir = mkdtempSync4(join24(tmpdir4(), "flare-sync-"));
4289
4485
  try {
4290
- const file = join22(dir, "sync-plans.sql");
4291
- writeFileSync15(file, renderSyncSql(rows));
4486
+ const file = join24(dir, "sync-plans.sql");
4487
+ writeFileSync17(file, renderSyncSql(rows));
4292
4488
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
4293
4489
  if (result.code !== 0) throw new Error(`Could not write plans:
4294
4490
  ${result.output.trim()}`);
4295
4491
  } finally {
4296
- rmSync4(dir, { recursive: true, force: true });
4492
+ rmSync5(dir, { recursive: true, force: true });
4297
4493
  }
4298
4494
  let created = 0;
4299
4495
  for (const row of rows) {
4300
4496
  const isNew = !known.has(row.stripePriceId);
4301
4497
  if (isNew) created++;
4302
4498
  const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
4303
- log2(`${(isNew ? pc18.green("create") : pc18.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc18.dim(price)}`);
4499
+ log2(`${(isNew ? pc20.green("create") : pc20.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc20.dim(price)}`);
4304
4500
  }
4305
- for (const name of skipped) log2(pc18.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
4501
+ for (const name of skipped) log2(pc20.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
4306
4502
  const configuration = await syncPortalConfiguration(stripe, rows, appName);
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(`
4503
+ if (configuration) log2(`${pc20.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
4504
+ log2(pc20.green(`
4309
4505
  ${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
4310
4506
  return { created, updated: rows.length - created, skipped };
4311
4507
  }
4312
4508
 
4313
4509
  // src/commands/sync.ts
4314
- import { existsSync as existsSync22 } from "fs";
4315
- import { join as join23 } from "path";
4316
- import pc19 from "picocolors";
4510
+ import { existsSync as existsSync23 } from "fs";
4511
+ import { join as join25 } from "path";
4512
+ import pc21 from "picocolors";
4317
4513
  async function syncTypes(options2 = {}) {
4318
4514
  const log2 = options2.log ?? ((message) => console.log(message));
4319
4515
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
4320
4516
  const all = await loadResources(appRoot);
4321
4517
  const policies = await loadPolicies(appRoot);
4322
- const tracksPolicies = policies.length > 0 || existsSync22(join23(appRoot, "policies/index.ts"));
4518
+ const tracksPolicies = policies.length > 0 || existsSync23(join25(appRoot, "policies/index.ts"));
4323
4519
  const plan = planFiles(all, tracksPolicies ? policies : void 0);
4324
4520
  if (!options2.check) ensureSupportFiles(appRoot, log2);
4325
4521
  const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
@@ -4331,42 +4527,42 @@ async function syncTypes(options2 = {}) {
4331
4527
  if (drift.length) {
4332
4528
  log2(
4333
4529
  `
4334
- ${pc19.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
4530
+ ${pc21.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
4335
4531
  Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
4336
4532
  );
4337
4533
  }
4338
4534
  if (unmarked.length) {
4339
4535
  log2(`
4340
- ${pc19.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
4536
+ ${pc21.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
4341
4537
  }
4342
4538
  if (orphans.length) {
4343
4539
  const resources = [...new Set(orphans.map((o) => o.resource))];
4344
4540
  log2(`
4345
- ${pc19.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
4541
+ ${pc21.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
4346
4542
  for (const orphan of orphans) log2(` ${orphan.path}`);
4347
4543
  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.`
4544
+ `If the resource was deleted, remove them with ${pc21.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
4349
4545
  );
4350
4546
  }
4351
4547
  const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
4352
4548
  if (tablesChanged && !options2.check) {
4353
4549
  log2(`
4354
- ${pc19.cyan("Tables changed.")} Create a migration with ${pc19.bold("flare gen migration <name> --from-schema")}, then ${pc19.bold("flare migrate")}.`);
4550
+ ${pc21.cyan("Tables changed.")} Create a migration with ${pc21.bold("flare gen migration <name> --from-schema")}, then ${pc21.bold("flare migrate")}.`);
4355
4551
  }
4356
4552
  const problems = drift.length + unmarked.length + orphans.length;
4357
4553
  if (options2.check) {
4358
4554
  const stale = changed.length;
4359
- if (stale === 0 && problems === 0) log2(pc19.green("Generated files are in sync with the resource descriptors."));
4555
+ if (stale === 0 && problems === 0) log2(pc21.green("Generated files are in sync with the resource descriptors."));
4360
4556
  else log2(`
4361
- ${pc19.red("Out of sync:")} ${stale} file(s) would change. Run ${pc19.bold("flare sync-types")}.`);
4557
+ ${pc21.red("Out of sync:")} ${stale} file(s) would change. Run ${pc21.bold("flare sync-types")}.`);
4362
4558
  return stale + problems > 0 ? 1 : 0;
4363
4559
  }
4364
- if (changed.length === 0 && problems === 0) log2(pc19.green("Everything is in sync."));
4560
+ if (changed.length === 0 && problems === 0) log2(pc21.green("Everything is in sync."));
4365
4561
  return problems > 0 ? 1 : 0;
4366
4562
  }
4367
4563
 
4368
4564
  // src/commands/user-role.ts
4369
- import pc20 from "picocolors";
4565
+ import pc22 from "picocolors";
4370
4566
  var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
4371
4567
  var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
4372
4568
  function userRoleSql(email, role) {
@@ -4380,8 +4576,8 @@ async function setUserRole(email, role, options2 = {}) {
4380
4576
  const sql2 = userRoleSql(email, role);
4381
4577
  const roles = await listRoles(options2);
4382
4578
  if (roles && !roles.includes(role)) {
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" : ""}`));
4579
+ log2(pc22.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
4580
+ log2(pc22.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
4385
4581
  return 1;
4386
4582
  }
4387
4583
  const db = readD1Databases(appRoot)[0];
@@ -4395,51 +4591,130 @@ ${result.output.trim()}`);
4395
4591
  const [first] = extractJson(result.output);
4396
4592
  const updated = first?.results ?? [];
4397
4593
  if (updated.length === 0) {
4398
- log2(pc20.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
4594
+ log2(pc22.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
4399
4595
  return 1;
4400
4596
  }
4401
- log2(pc20.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
4597
+ log2(pc22.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
4598
+ return 0;
4599
+ }
4600
+
4601
+ // src/utils/install.ts
4602
+ import { spawn as spawn5 } from "child_process";
4603
+ import { existsSync as existsSync24, readFileSync as readFileSync14, readdirSync as readdirSync9 } from "fs";
4604
+ import { join as join26 } from "path";
4605
+ function expectedPackages(dir) {
4606
+ const npmLock = join26(dir, "package-lock.json");
4607
+ if (existsSync24(npmLock)) {
4608
+ try {
4609
+ const lock = JSON.parse(readFileSync14(npmLock, "utf8"));
4610
+ return Math.max(0, Object.keys(lock.packages ?? {}).length - 1);
4611
+ } catch {
4612
+ return 0;
4613
+ }
4614
+ }
4615
+ const pnpmLock = join26(dir, "pnpm-lock.yaml");
4616
+ if (existsSync24(pnpmLock)) {
4617
+ try {
4618
+ const text = readFileSync14(pnpmLock, "utf8");
4619
+ const packages = text.slice(text.indexOf("\npackages:"));
4620
+ return (packages.match(/\n {2}[^\s:]+:/g) ?? []).length;
4621
+ } catch {
4622
+ return 0;
4623
+ }
4624
+ }
4402
4625
  return 0;
4403
4626
  }
4627
+ function installedPackages(dir) {
4628
+ const root = join26(dir, "node_modules");
4629
+ if (!existsSync24(root)) return 0;
4630
+ let total = 0;
4631
+ try {
4632
+ for (const entry of readdirSync9(root, { withFileTypes: true })) {
4633
+ if (!entry.isDirectory() || entry.name === ".bin") continue;
4634
+ if (entry.name.startsWith("@")) {
4635
+ try {
4636
+ total += readdirSync9(join26(root, entry.name)).length;
4637
+ } catch {
4638
+ }
4639
+ } else if (entry.name !== ".pnpm" && entry.name !== ".vite") {
4640
+ total += 1;
4641
+ }
4642
+ }
4643
+ } catch {
4644
+ return total;
4645
+ }
4646
+ return total;
4647
+ }
4648
+ function install(packageManager, dir, onProgress) {
4649
+ const args = installArgs(packageManager);
4650
+ const started = Date.now();
4651
+ const expected = expectedPackages(dir);
4652
+ return new Promise((resolve6, reject) => {
4653
+ const child = process.platform === "win32" ? spawn5([packageManager, ...args].join(" "), { cwd: dir, shell: true, env: childEnv(), stdio: ["ignore", "pipe", "pipe"] }) : spawn5(packageManager, args, { cwd: dir, env: childEnv(), stdio: ["ignore", "pipe", "pipe"] });
4654
+ let output = "";
4655
+ child.stdout?.on("data", (chunk) => output += chunk);
4656
+ child.stderr?.on("data", (chunk) => output += chunk);
4657
+ const timer = onProgress ? setInterval(() => {
4658
+ const installed = installedPackages(dir);
4659
+ onProgress({
4660
+ installed,
4661
+ expected,
4662
+ percent: expected > 0 ? Math.min(99, Math.round(installed / expected * 100)) : void 0,
4663
+ elapsedMs: Date.now() - started
4664
+ });
4665
+ }, 500) : void 0;
4666
+ child.on("error", (error) => {
4667
+ clearInterval(timer);
4668
+ reject(error);
4669
+ });
4670
+ child.on("close", (code) => {
4671
+ clearInterval(timer);
4672
+ resolve6({ code: code ?? 1, output, ms: Date.now() - started });
4673
+ });
4674
+ });
4675
+ }
4404
4676
 
4405
4677
  // src/cli.ts
4406
4678
  function createCli() {
4407
4679
  const cli = cac("flare");
4408
4680
  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) => {
4409
- const install = !options2.skipInstall;
4681
+ const wanted = !options2.skipInstall;
4410
4682
  let answers = { theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
4411
4683
  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;
4684
+ const result = createApp(dir, { pm: options2.pm, install: false, ...answers, log: (message) => prompts3.log.message(message) });
4685
+ let installed = false;
4686
+ if (wanted) {
4687
+ const spin = prompts3.spinner({ indicator: "timer" });
4688
+ spin.start(`Installing dependencies with ${result.packageManager}`);
4689
+ const outcome = await install(result.packageManager, result.dir, ({ installed: done, expected, percent }) => {
4690
+ spin.message(
4691
+ percent === void 0 ? `Installing dependencies with ${result.packageManager} \xB7 ${formatCount(done)} packages` : `Installing dependencies with ${result.packageManager} \xB7 ${percent}% (${formatCount(done)} of ${formatCount(expected)})`
4692
+ );
4693
+ });
4694
+ installed = outcome.code === 0;
4695
+ if (installed) {
4696
+ spin.stop(`Installed ${formatCount(expectedPackages(result.dir) || 0)} packages with ${result.packageManager} ${pc23.dim(`(${formatDuration(outcome.ms)})`)}`);
4697
+ } else {
4698
+ spin.error(`${result.packageManager} install failed`);
4699
+ process.stderr.write(outcome.output);
4700
+ process.exitCode = 1;
4430
4701
  }
4431
- });
4432
- printNextSteps(result, install);
4702
+ }
4703
+ printNextSteps(result, installed);
4433
4704
  });
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(
4435
- async (generator, name, options2) => {
4705
+ cli.command("gen <generator> [name] [second]", "Generate code. Generators: resource, endpoint, 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("--group <name>", "resource: sidebar heading to file it under, e.g. Sales").option("--icon <name>", "resource: lucide icon for the sidebar, e.g. users").option("--method <verb>", "endpoint: GET (default), POST, PATCH, PUT or DELETE").option("--action <action>", "endpoint: policy action to require (read, create, update, delete)").option("--record", "endpoint: put it under one record, /api/<resource>/[id]/<name>").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 endpoint Order publish --method POST --record").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(
4706
+ async (generator, name, second, options2) => {
4436
4707
  if (generator === "billing")
4437
4708
  return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
4438
4709
  if (generator === "security") return genSecurity({ force: options2.force, skipMigration: options2.skipMigration });
4439
4710
  if (["resource", "migration", "policy"].includes(generator) && !name) {
4440
4711
  throw new Error(`flare gen ${generator} needs a name, e.g. flare gen ${generator} ${generator === "migration" ? "add_phone_to_contacts" : "Contact"}`);
4441
4712
  }
4442
- if (generator === "resource") return genResource(name, { fields: options2.fields, force: options2.force });
4713
+ if (generator === "resource") return genResource(name, { fields: options2.fields, force: options2.force, group: options2.group, icon: options2.icon });
4714
+ if (generator === "endpoint") {
4715
+ if (!name || !second) throw new Error("flare gen endpoint needs a resource and a name, e.g. flare gen endpoint Order publish");
4716
+ return genEndpoint(name, second, { method: options2.method, action: options2.action, record: options2.record });
4717
+ }
4443
4718
  if (generator === "migration") return genMigration(name, { fromSchema: options2.fromSchema });
4444
4719
  if (generator === "policy") return genPolicy(name, { roles: options2.roles, deleteRoles: options2.deleteRoles, force: options2.force });
4445
4720
  throw new Error(`Unknown generator "${generator}". Available: resource, migration, policy, billing, security.`);
@@ -4467,6 +4742,9 @@ function createCli() {
4467
4742
  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
4743
  await seedResourceCommand(resource, { ...options2, count: options2.count ?? count });
4469
4744
  });
4745
+ cli.command("db:push [...tables]", "Copy rows from the local database to the deployed one (seeded catalogues, reference data)").option("--truncate", "Delete the remote rows of those tables first").option("--database <binding>", "D1 binding, when the app has several").option("--env <name>", "Wrangler environment").option("-y, --yes", "Confirm writing to the deployed database").example("flare db:push products categories --yes").example("flare db:push plans --truncate --yes").action(async (tables, options2) => {
4746
+ await dbPush(tables, options2);
4747
+ });
4470
4748
  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) => {
4471
4749
  await makeSeed(name, options2);
4472
4750
  });
@@ -4512,6 +4790,6 @@ try {
4512
4790
  }
4513
4791
  }
4514
4792
  } catch (error) {
4515
- console.error(pc21.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
4793
+ console.error(pc24.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
4516
4794
  process.exitCode = 1;
4517
4795
  }