@flaredev/cli 0.12.2 → 0.14.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 (61) hide show
  1. package/dist/index.js +126 -57
  2. package/package.json +2 -2
  3. package/templates/app/README.md +1 -1
  4. package/templates/app/app/dashboard/error.tsx +18 -5
  5. package/templates/app/app/dashboard/layout.tsx +2 -2
  6. package/templates/app/app/dashboard/page.tsx +101 -26
  7. package/templates/app/app/dashboard/roles/actions.ts +57 -0
  8. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  9. package/templates/app/app/dashboard/users/actions.ts +86 -0
  10. package/templates/app/app/dashboard/users/page.tsx +54 -0
  11. package/templates/app/app/error.tsx +14 -5
  12. package/templates/app/components/account/sections.tsx +23 -3
  13. package/templates/app/components/auth/password-field.tsx +51 -32
  14. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  15. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  16. package/templates/app/components/dashboard/cell-value.ts +67 -0
  17. package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
  18. package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
  19. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  20. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  21. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  22. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  23. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  24. package/templates/app/components/dashboard/user-actions.tsx +217 -0
  25. package/templates/app/components/dashboard/user-menu.tsx +17 -23
  26. package/templates/app/components/dashboard/users-table.tsx +109 -0
  27. package/templates/app/components/ui/combobox.tsx +133 -0
  28. package/templates/app/db/flare-schema.ts +27 -1
  29. package/templates/app/lib/api-docs.ts +1 -1
  30. package/templates/app/lib/api.ts +4 -1
  31. package/templates/app/lib/auth.ts +3 -3
  32. package/templates/app/lib/dashboard.ts +20 -5
  33. package/templates/app/lib/mail.ts +2 -2
  34. package/templates/app/lib/permissions.ts +105 -0
  35. package/templates/app/lib/site.ts +2 -2
  36. package/templates/app/lib/stale-chunk.ts +50 -0
  37. package/templates/app/migrations/0000_init.sql +9 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  39. package/templates/app/migrations/meta/_journal.json +1 -1
  40. package/templates/app/vite.config.ts +3 -1
  41. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  42. package/templates/locks/next/pnpm-lock.yaml +4 -4
  43. package/templates/next/README.md +1 -1
  44. package/templates/next/app/dashboard/layout.tsx +2 -2
  45. package/templates/next/app/dashboard/observability/page.tsx +156 -0
  46. package/templates/next/app/dashboard/page.tsx +87 -23
  47. package/templates/next/app/dashboard/users/page.tsx +51 -0
  48. package/templates/next/lib/auth.ts +3 -3
  49. package/templates/next/lib/dashboard-nav.ts +29 -0
  50. package/templates/next/lib/dashboard.ts +20 -5
  51. package/templates/next/lib/mail.ts +2 -2
  52. package/templates/next/lib/permissions.ts +100 -0
  53. package/templates/next/prisma/schema/base.prisma +37 -0
  54. package/templates/next/tsconfig.json +2 -2
  55. /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
  56. /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
  57. /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
  58. /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
  59. /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
  60. /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
  61. /package/templates/app/app/{verify-email → (auth)/verify-email}/page.tsx +0 -0
package/dist/index.js CHANGED
@@ -414,7 +414,11 @@ var CLOUDFLARE_ONLY = [
414
414
  ".dev.vars.example",
415
415
  "worker-configuration.d.ts",
416
416
  // The engine's Drizzle adapter; the Next.js overlay brings prisma-rows.ts instead.
417
- "lib/resource/drizzle-rows.ts"
417
+ "lib/resource/drizzle-rows.ts",
418
+ // Estimates Workers, D1 and R2 bills from Cloudflare's own price sheet — numbers that
419
+ // mean nothing to an app on Vercel and Neon, so a Next.js app doesn't get the page.
420
+ "app/dashboard/costs",
421
+ "components/dashboard/cost-estimator.tsx"
418
422
  ];
419
423
 
420
424
  // src/stack.ts
@@ -519,6 +523,9 @@ function installArgs(pm) {
519
523
 
520
524
  // src/commands/create.ts
521
525
  var SEED_ADMIN = { email: "admin@example.com", password: "Password", name: "Admin" };
526
+ function toAppTitle(name) {
527
+ return name.split("-").map((word) => word ? word[0].toUpperCase() + word.slice(1) : word).join(" ");
528
+ }
522
529
  var ALLOW_BUILDS = [
523
530
  "'@prisma/engines': false",
524
531
  "esbuild: true",
@@ -568,13 +575,14 @@ function createApp(target, options2 = {}) {
568
575
  const compatibilityDate = options2.compatibilityDate ?? COMPATIBILITY_DATE;
569
576
  const files = copyTemplate(join4(templatesDir, "app"), dir, {
570
577
  APP_NAME: name,
578
+ APP_TITLE: toAppTitle(name),
571
579
  COMPAT_DATE: compatibilityDate,
572
580
  PM: packageManager,
573
581
  THEME: theme,
574
582
  SIGNUP: signup
575
583
  });
576
584
  if (stack === "next") {
577
- copyTemplate(join4(templatesDir, "next"), dir, { APP_NAME: name, PM: packageManager, THEME: theme, SIGNUP: signup });
585
+ copyTemplate(join4(templatesDir, "next"), dir, { APP_NAME: name, APP_TITLE: toAppTitle(name), PM: packageManager, THEME: theme, SIGNUP: signup });
578
586
  for (const path of CLOUDFLARE_ONLY) rmSync(join4(dir, path), { recursive: true, force: true });
579
587
  }
580
588
  writeFileSync3(join4(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders, signup));
@@ -610,10 +618,15 @@ storage/
610
618
  start: "next start",
611
619
  deploy: "vercel deploy --prod",
612
620
  "db:generate": "prisma generate",
613
- "db:migrate": "prisma migrate dev",
621
+ // Thin aliases for the flare commands, so the scripts and the printed steps
622
+ // can never say different things. `flare migrate` is `prisma migrate dev`
623
+ // plus `prisma generate` — the generate is not optional; Prisma 7 removed
624
+ // generator runs from `migrate dev`, and a client without the new model
625
+ // fails the very next command, usually `flare seed:resource`.
626
+ "db:migrate": "flare migrate",
614
627
  // An account to sign in with, so the app is reachable the moment the tables
615
628
  // exist. Safe to run twice: --skip-existing makes a second run a no-op.
616
- "db:seed": `flare user:add ${SEED_ADMIN.email} --password ${SEED_ADMIN.password} --name ${SEED_ADMIN.name} --role admin --skip-existing`,
629
+ "db:seed": "flare db:seed",
617
630
  "db:studio": "prisma studio"
618
631
  } : {
619
632
  dev: "flare dev",
@@ -690,30 +703,34 @@ function printNextSteps(result, installed) {
690
703
  const pm = result.packageManager;
691
704
  const database = result.stack === "next" ? [
692
705
  ["docker compose up -d", "Postgres, on the address .env already has"],
693
- [`${pm} run db:migrate`, "create the tables"],
694
- [`${pm} run db:seed`, "an account to sign in with"]
706
+ ["npx flare migrate", "create the tables"],
707
+ ["npx flare db:seed", "an account to sign in with"]
695
708
  ] : [];
696
709
  const commands = [
697
710
  [`cd ${rel}`, "your new app"],
698
711
  ...installed ? [] : [[`${pm} install`, "dependencies first"]],
699
712
  ...database,
700
- [`${pm} run dev`, "http://localhost:3000"],
713
+ ["npx flare dev", "http://localhost:3000"]
714
+ ];
715
+ const firstResource = [
701
716
  ["npx flare gen resource Contact", "a table, API and admin screens"],
717
+ ["npx flare migrate", "create its table"],
702
718
  ["npx flare seed:resource Contact 1000", "fill it with sample rows"]
703
719
  ];
704
- const width = Math.max(...commands.map(([command]) => command.length)) + 2;
705
- const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
706
- prompts.note(steps.join("\n"), "Next");
720
+ const width = Math.max(...[...commands, ...firstResource].map(([command]) => command.length)) + 2;
721
+ const line = ([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`;
722
+ prompts.note(commands.map(line).join("\n"), "Next");
723
+ prompts.note(firstResource.map(line).join("\n"), "Your first resource");
707
724
  const storage = storageSetupSteps(result.storage, result.stack, { local: true });
708
725
  if (storage.length > 0) {
709
- prompts.note(storage.map((line) => line.startsWith("http") ? pc.cyan(line) : line).join("\n"), `Uploads \xB7 ${result.storage}`);
726
+ prompts.note(storage.map((line2) => line2.startsWith("http") ? pc.cyan(line2) : line2).join("\n"), `Uploads \xB7 ${result.storage}`);
710
727
  }
711
728
  if (result.stack === "next") {
712
729
  prompts.note(
713
730
  [
714
731
  `${pc.cyan(SEED_ADMIN.email)} ${pc.dim("\xB7")} ${pc.cyan(SEED_ADMIN.password)}`,
715
732
  "",
716
- pc.dim(`Made by ${pm} run db:seed, as an admin. Change it before this app is anywhere but your machine.`)
733
+ pc.dim("Made by npx flare db:seed, as an admin. Change it before this app is anywhere but your machine.")
717
734
  ].join("\n"),
718
735
  "Sign in with"
719
736
  );
@@ -2703,12 +2720,20 @@ async function migrate(options2 = {}) {
2703
2720
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2704
2721
  const log2 = options2.log ?? ((message) => console.log(message));
2705
2722
  if (readStack(appRoot) === "next") {
2723
+ const prisma = resolveBin(appRoot, "prisma", "prisma");
2724
+ if (options2.deploy) {
2725
+ log2(`${pc6.cyan("prisma")} migrate deploy`);
2726
+ return runNode([prisma, "migrate", "deploy"], appRoot);
2727
+ }
2706
2728
  if (options2.remote) {
2707
- log2(pc6.dim("On Next.js, `--remote` is whichever database DATABASE_URL points at."));
2729
+ log2(pc6.dim("On Next.js, the database is whichever one DATABASE_URL points at."));
2708
2730
  }
2709
- const prisma = resolveBin(appRoot, "prisma", "prisma");
2710
- log2(`${pc6.cyan("prisma")} migrate deploy`);
2711
- return runNode([prisma, "migrate", "deploy"], appRoot);
2731
+ const args = ["migrate", "dev", ...options2.name ? ["--name", options2.name] : []];
2732
+ log2(`${pc6.cyan("prisma")} ${args.join(" ")}`);
2733
+ const code = await runNode([prisma, ...args], appRoot);
2734
+ if (code !== 0) return code;
2735
+ log2(`${pc6.cyan("prisma")} generate`);
2736
+ return runNode([prisma, "generate"], appRoot);
2712
2737
  }
2713
2738
  const { db, target, wrangler } = context(options2);
2714
2739
  const result = await runWrangler(wrangler, ["d1", "migrations", "apply", db.binding, ...target], appRoot);
@@ -3091,7 +3116,7 @@ async function diagnose(options2 = {}) {
3091
3116
  }
3092
3117
  }
3093
3118
  if (stack === "next") {
3094
- findings.push({ check: "Migrations", verdict: "skip", detail: "Prisma tracks these in the database; run `npx prisma migrate status`." });
3119
+ findings.push({ check: "Migrations", verdict: "skip", detail: "Prisma tracks these in the database; run `npx flare migrate --deploy` to apply any that are pending." });
3095
3120
  } else if (options2.offline) {
3096
3121
  findings.push({ check: "Migrations", verdict: "skip", detail: "Skipped (--offline)." });
3097
3122
  } else {
@@ -3269,7 +3294,7 @@ async function addUser(email, options2 = {}) {
3269
3294
  async function addOnD1(appRoot, rows, options2, log2) {
3270
3295
  if (options2.role) {
3271
3296
  const roles = await listRoles(options2);
3272
- if (roles && !roles.includes(options2.role)) {
3297
+ if (roles && roles.length > 0 && !roles.includes(options2.role)) {
3273
3298
  log2(pc9.red(`No role "${options2.role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
3274
3299
  log2(pc9.dim(`Register it with: flare role:add ${options2.role}${options2.remote ? " --remote" : ""}`));
3275
3300
  return 1;
@@ -3864,7 +3889,7 @@ async function genApiKeys(options2 = {}) {
3864
3889
  }
3865
3890
  const steps = [
3866
3891
  ...installed ? [`${pc10.bold("install")} (pnpm install) to fetch ${API_KEY_PACKAGE}.`] : [],
3867
- stack === "next" ? `${pc10.bold("npx prisma migrate dev --name apikeys")} to create the table.` : `${pc10.bold("flare migrate")} to create the apikey table.`,
3892
+ stack === "next" ? `${pc10.bold("npx flare migrate --name apikeys")} to create the table.` : `${pc10.bold("flare migrate")} to create the apikey table.`,
3868
3893
  `${pc10.bold("flare dev")}, then ${pc10.bold("/dashboard/api-keys")} to mint one.`,
3869
3894
  `Call the API with it: ${pc10.bold("-H 'Authorization: Bearer <key>'")} (or ${pc10.bold("x-api-key")}).`
3870
3895
  ];
@@ -4216,15 +4241,20 @@ function renderFieldsBlock(fields) {
4216
4241
  function renderDescriptor(name, fields, options2 = {}) {
4217
4242
  defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
4218
4243
  const block = renderFieldsBlock(fields);
4219
- const heading = [
4220
- options2.icon ? ` icon: ${literal(options2.icon)},
4221
- ` : "",
4222
- options2.group ? ` group: ${literal(options2.group)},
4223
- ` : "",
4224
- options2.softDelete ? ` // A delete stamps deletedAt instead of removing the row; the dashboard grows a Trash view.
4244
+ const set = (key, value) => ` ${key}: ${value},
4245
+ `;
4246
+ const suggestion = (code, note2 = "") => {
4247
+ const line = ` // ${code}`;
4248
+ return note2 && line.length <= 52 ? `${line.padEnd(53)}// ${note2}
4249
+ ` : `${line}
4250
+ `;
4251
+ };
4252
+ const heading = (options2.icon ? set("icon", literal(options2.icon)) : suggestion(`icon: "file-text",`, "any lucide-react name")) + (options2.group ? set("group", literal(options2.group)) : suggestion(`group: "Sales",`, "sidebar heading")) + suggestion(`titleField: "${fields[0]?.key ?? "name"}",`, "what lists and pickers show") + suggestion(`defaultSort: { field: "createdAt", direction: "desc" },`) + suggestion(`perPage: 25,`, "rows per page") + (options2.softDelete ? ` // A delete stamps deletedAt instead of removing the row; the dashboard grows a Trash view.
4225
4253
  softDelete: true,
4226
- ` : ""
4227
- ].join("");
4254
+ ` : suggestion(`softDelete: true,`, "keep deleted rows, with a Trash view")) + suggestion(`rateLimit: { create: "10/min", "*": "120/min" },`) + ` // Uncomment what you need. Hooks, computed values, table and slug:
4255
+ // https://flare-docs.codetotech.com/concepts/resource-descriptor/
4256
+
4257
+ `;
4228
4258
  return joinMarkers({
4229
4259
  before: `import { defineResource, field } from "@flaredev/core";
4230
4260
 
@@ -5736,7 +5766,7 @@ async function askCreateQuestions(given, appName) {
5736
5766
  }
5737
5767
 
5738
5768
  // src/commands/gen.ts
5739
- import { existsSync as existsSync20, mkdirSync as mkdirSync8, writeFileSync as writeFileSync14 } from "fs";
5769
+ import { existsSync as existsSync20, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync14 } from "fs";
5740
5770
  import { dirname as dirname9, join as join20 } from "path";
5741
5771
  import { AUTH_USER as AUTH_USER2, camelCase as camelCase5, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
5742
5772
  import pc15 from "picocolors";
@@ -5760,11 +5790,37 @@ async function genResource(rawName, options2) {
5760
5790
  const relativePath = descriptorPath(name);
5761
5791
  const path = join20(appRoot, relativePath);
5762
5792
  const exists = existsSync20(path);
5763
- if (!exists && !options2.fields) {
5793
+ if (!exists && !options2.fields && !options2.add) {
5764
5794
  throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
5765
5795
  }
5796
+ if (options2.add && options2.fields) {
5797
+ throw new Error("--add appends to the fields you have; --fields replaces them. Use one or the other.");
5798
+ }
5799
+ if (options2.add && !exists) {
5800
+ throw new Error(`There is no ${relativePath} to add to. Create it first: flare gen resource ${name} --fields "${options2.add}"`);
5801
+ }
5766
5802
  const existing = await loadResources(appRoot);
5767
5803
  const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
5804
+ if (options2.add) {
5805
+ const additions = parseFields(options2.add);
5806
+ const current = existing.find(({ resource }) => resource.name === name)?.resource;
5807
+ const taken = new Set(Object.keys(current?.fields ?? {}));
5808
+ const clash = additions.find((candidate) => taken.has(candidate.key));
5809
+ if (clash) {
5810
+ throw new Error(
5811
+ `${name} already has a field called "${clash.key}". Edit it in ${relativePath}, or rename it there with \`${clash.key}: field.string({ rename: { from: "old" } })\`.`
5812
+ );
5813
+ }
5814
+ validateFields(name, additions, others, log2);
5815
+ const source = readFileSync15(path, "utf8");
5816
+ const marked = splitMarkers(source);
5817
+ if (!marked) throw new Error(`${relativePath} has no generated block to add to. Add the field by hand.`);
5818
+ const existingBlock = marked.block === "" || marked.block.endsWith("\n") ? marked.block : `${marked.block}
5819
+ `;
5820
+ const appended = `${existingBlock}${renderFieldsBlock(additions)}`;
5821
+ const outcome = writeGenerated(path, appended, { force: true });
5822
+ log2(`${pc15.yellow(outcome.padEnd(9))} ${relativePath} ${pc15.dim(`(+${additions.map((f) => f.key).join(", ")})`)}`);
5823
+ }
5768
5824
  if (options2.fields) {
5769
5825
  const fields = parseFields(options2.fields);
5770
5826
  validateFields(name, fields, others, log2);
@@ -5817,7 +5873,7 @@ Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for deta
5817
5873
  if (!options2.skipMigration) {
5818
5874
  if (readStack(appRoot) === "next") {
5819
5875
  log2(`
5820
- Next: ${pc15.bold("npx prisma migrate dev")} to turn the schema into a migration and apply it.`);
5876
+ Next: ${pc15.bold("npx flare migrate")} to turn the schema into a migration and apply it.`);
5821
5877
  } else {
5822
5878
  const renamed = await applyDeclaredRenames(appRoot, await loadResources(appRoot));
5823
5879
  for (const [index, file] of renamed.files.entries()) {
@@ -5912,7 +5968,7 @@ ${body}
5912
5968
  }
5913
5969
 
5914
5970
  // src/commands/gen-migration.ts
5915
- import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as readFileSync15, readdirSync as readdirSync8, writeFileSync as writeFileSync16 } from "fs";
5971
+ import { existsSync as existsSync22, mkdirSync as mkdirSync10, readFileSync as readFileSync16, readdirSync as readdirSync8, writeFileSync as writeFileSync16 } from "fs";
5916
5972
  import { basename as basename2, join as join22 } from "path";
5917
5973
  import { snakeCase } from "@flaredev/core";
5918
5974
  import pc17 from "picocolors";
@@ -6107,7 +6163,7 @@ ${applied.output.trim()}`);
6107
6163
  const relative3 = `prisma/migrations/${entry}/migration.sql`;
6108
6164
  written.push(relative3);
6109
6165
  log2(`${pc17.green("create".padEnd(9))} ${relative3}`);
6110
- const found = destructiveStatements(readFileSync15(file, "utf8"));
6166
+ const found = destructiveStatements(readFileSync16(file, "utf8"));
6111
6167
  if (found.length > 0) destructive.push({ file: relative3, found });
6112
6168
  }
6113
6169
  if (destructive.length > 0) {
@@ -6221,16 +6277,19 @@ ${pc18.dim("Roles come from `flare role:add`; the API and the admin both enforce
6221
6277
  }
6222
6278
 
6223
6279
  // src/commands/rm.ts
6224
- import { existsSync as existsSync24, readdirSync as readdirSync9, readFileSync as readFileSync16, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
6280
+ import { existsSync as existsSync24, readdirSync as readdirSync9, readFileSync as readFileSync17, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
6225
6281
  import { dirname as dirname11, join as join24 } from "path";
6226
6282
  import { relationGraph as relationGraph3 } from "@flaredev/core";
6227
6283
  import pc19 from "picocolors";
6228
6284
  var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
6285
+ var squashCode = (text) => squash(
6286
+ text.replace(/\r\n/g, "\n").split("\n").filter((line) => !line.trim().startsWith("//")).join("\n")
6287
+ );
6229
6288
  function handWrittenReason(source, header2, options2 = {}) {
6230
6289
  const parts = splitMarkers(source);
6231
6290
  if (!parts) return "has no generated block (entirely hand-written)";
6232
6291
  if (options2.descriptorWrapper) {
6233
- if (squash(parts.before) !== squash(options2.descriptorWrapper.before) || squash(parts.after) !== squash(options2.descriptorWrapper.after)) {
6292
+ if (squashCode(parts.before) !== squashCode(options2.descriptorWrapper.before) || squashCode(parts.after) !== squashCode(options2.descriptorWrapper.after)) {
6234
6293
  return "has hand-written changes outside the fields block";
6235
6294
  }
6236
6295
  return null;
@@ -6282,7 +6341,7 @@ async function rmResource(rawName, options2 = {}) {
6282
6341
  if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
6283
6342
  const findings = [];
6284
6343
  for (const path of owned) {
6285
- const source = readFileSync16(join24(appRoot, path), "utf8");
6344
+ const source = readFileSync17(join24(appRoot, path), "utf8");
6286
6345
  let wrapper;
6287
6346
  if (entry && path === entry.file) {
6288
6347
  const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
@@ -6311,7 +6370,7 @@ Move what you want to keep, or pass --force to delete anyway.`);
6311
6370
  const table = entry ? tableExport(entry.resource) : void 0;
6312
6371
  if (table && existsSync24(join24(appRoot, "seeds"))) {
6313
6372
  const seeds = readdirSync9(join24(appRoot, "seeds")).filter(
6314
- (file) => new RegExp(`\\b${table}\\b`).test(readFileSync16(join24(appRoot, "seeds", file), "utf8"))
6373
+ (file) => new RegExp(`\\b${table}\\b`).test(readFileSync17(join24(appRoot, "seeds", file), "utf8"))
6315
6374
  );
6316
6375
  if (seeds.length) log2(pc19.yellow(`
6317
6376
  These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
@@ -6319,7 +6378,7 @@ These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`))
6319
6378
  let migrations = [];
6320
6379
  if (readStack(appRoot) === "next") {
6321
6380
  if (entry) log2(`
6322
- Next: ${pc19.bold("npx prisma migrate dev")} to drop the table.`);
6381
+ Next: ${pc19.bold("npx flare migrate")} to drop the table.`);
6323
6382
  return { removed, migrations };
6324
6383
  }
6325
6384
  if (!options2.skipMigration && entry) {
@@ -6334,17 +6393,17 @@ Next: ${pc19.bold("flare migrate")} to drop the table locally.`);
6334
6393
  }
6335
6394
 
6336
6395
  // src/commands/eject.ts
6337
- import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync17, readdirSync as readdirSync10, writeFileSync as writeFileSync18 } from "fs";
6396
+ import { existsSync as existsSync25, mkdirSync as mkdirSync12, readFileSync as readFileSync18, readdirSync as readdirSync10, writeFileSync as writeFileSync18 } from "fs";
6338
6397
  import { dirname as dirname12, join as join25, relative as relative2 } from "path";
6339
6398
  import pc20 from "picocolors";
6340
6399
  var TRACKED = ["app", "lib", "components"];
6341
- var PER_APP = /__APP_NAME__|__PM__|__THEME__|__COMPAT_DATE__/;
6400
+ var PER_APP = /__APP_NAME__|__APP_TITLE__|__PM__|__THEME__|__COMPAT_DATE__/;
6342
6401
  var WRITTEN_PER_APP = /* @__PURE__ */ new Set(["lib/auth-config.ts"]);
6343
6402
  var prunedOn = (path, stack) => stack === "next" && CLOUDFLARE_ONLY.some((only) => path === only || path.startsWith(`${only}/`));
6344
6403
  function upstreamFile(path, stack) {
6345
6404
  if (prunedOn(path, stack) || WRITTEN_PER_APP.has(path)) return void 0;
6346
6405
  const found = locate(path, stack);
6347
- if (found && PER_APP.test(readFileSync17(found, "utf8"))) return void 0;
6406
+ if (found && PER_APP.test(readFileSync18(found, "utf8"))) return void 0;
6348
6407
  return found;
6349
6408
  }
6350
6409
  function locate(path, stack) {
@@ -6371,10 +6430,10 @@ function trackedPaths(appRoot, stack) {
6371
6430
  function compareTracked(appRoot, stack, filter) {
6372
6431
  return trackedPaths(appRoot, stack).filter((path) => !filter || path.includes(filter)).map((path) => {
6373
6432
  const source = upstreamFile(path, stack);
6374
- const theirs = readFileSync17(source, "utf8");
6433
+ const theirs = readFileSync18(source, "utf8");
6375
6434
  const target = join25(appRoot, path);
6376
6435
  if (!existsSync25(target)) return { path, state: "missing", theirs };
6377
- const mine = readFileSync17(target, "utf8");
6436
+ const mine = readFileSync18(target, "utf8");
6378
6437
  if (mine === theirs) return { path, state: "same", mine, theirs };
6379
6438
  const merged = mergeGeneratedBlock(mine, theirs);
6380
6439
  if (merged !== void 0) {
@@ -6564,7 +6623,7 @@ function createInsertMany(sink, drizzle, onProgress) {
6564
6623
  }
6565
6624
 
6566
6625
  // src/seed/local-d1.ts
6567
- import { readFileSync as readFileSync18, rmSync as rmSync5, writeFileSync as writeFileSync19 } from "fs";
6626
+ import { readFileSync as readFileSync19, rmSync as rmSync5, writeFileSync as writeFileSync19 } from "fs";
6568
6627
  import { createRequire } from "module";
6569
6628
  import { join as join26 } from "path";
6570
6629
  import { pathToFileURL } from "url";
@@ -6597,7 +6656,7 @@ async function openLocalD1(appRoot, binding) {
6597
6656
  }
6598
6657
  function seedConfig(appRoot) {
6599
6658
  const source = join26(appRoot, "wrangler.jsonc");
6600
- const config = parseJsonc3(readFileSync18(source, "utf8")) ?? {};
6659
+ const config = parseJsonc3(readFileSync19(source, "utf8")) ?? {};
6601
6660
  delete config.durable_objects;
6602
6661
  delete config.migrations;
6603
6662
  const path = join26(appRoot, ".flare-seed.wrangler.json");
@@ -7146,7 +7205,7 @@ function* seedRows(resource, count, options2 = {}) {
7146
7205
 
7147
7206
  // src/commands/seed-resource-prisma.ts
7148
7207
  import { createWriteStream } from "fs";
7149
- import { mkdtempSync as mkdtempSync3, readFileSync as readFileSync19, rmSync as rmSync8, writeFileSync as writeFileSync22 } from "fs";
7208
+ import { mkdtempSync as mkdtempSync3, readFileSync as readFileSync20, rmSync as rmSync8, writeFileSync as writeFileSync22 } from "fs";
7150
7209
  import { tmpdir as tmpdir3 } from "os";
7151
7210
  import { join as join29 } from "path";
7152
7211
  import { once } from "events";
@@ -7270,7 +7329,7 @@ async function seedResourceWithPrisma(appRoot, resource, options2, log2) {
7270
7329
  const { code: code2, output } = await runNodeCapturing([tsx, runner, "probe", planFile], appRoot);
7271
7330
  if (code2 !== 0) throw new Error(`Could not read the database before seeding.
7272
7331
  ${output.trim()}`);
7273
- probe = JSON.parse(readFileSync19(outFile, "utf8"));
7332
+ probe = JSON.parse(readFileSync20(outFile, "utf8"));
7274
7333
  }
7275
7334
  const parentIds = new Map(Object.entries(probe.parents));
7276
7335
  for (const target of options2.parentTargets) {
@@ -7283,7 +7342,7 @@ ${output.trim()}`);
7283
7342
  writeFileSync22(planFile, JSON.stringify({ model, table: resource.table, out: outFile, rows: rowsFile, truncate: !!options2.truncate, total: options2.count }));
7284
7343
  const code = await runNode([tsx, runner, "insert", planFile], appRoot);
7285
7344
  if (code !== 0) throw new Error("Seeding stopped; the reason is above.");
7286
- const { written } = JSON.parse(readFileSync19(outFile, "utf8"));
7345
+ const { written } = JSON.parse(readFileSync20(outFile, "utf8"));
7287
7346
  return written;
7288
7347
  } finally {
7289
7348
  rmSync8(runner, { force: true });
@@ -7465,7 +7524,7 @@ ${result.output.trim()}`);
7465
7524
 
7466
7525
  // src/commands/billing-sync.ts
7467
7526
  import { createRequire as createRequire2 } from "module";
7468
- import { existsSync as existsSync27, mkdtempSync as mkdtempSync5, readFileSync as readFileSync20, rmSync as rmSync10, writeFileSync as writeFileSync24 } from "fs";
7527
+ import { existsSync as existsSync27, mkdtempSync as mkdtempSync5, readFileSync as readFileSync21, rmSync as rmSync10, writeFileSync as writeFileSync24 } from "fs";
7469
7528
  import { tmpdir as tmpdir5 } from "os";
7470
7529
  import { join as join31 } from "path";
7471
7530
  import { pathToFileURL as pathToFileURL2 } from "url";
@@ -7475,7 +7534,7 @@ function stripeKeys(appRoot) {
7475
7534
  const map = {};
7476
7535
  const path = join31(appRoot, ".dev.vars");
7477
7536
  if (existsSync27(path)) {
7478
- for (const line of readFileSync20(path, "utf8").split(/\r?\n/)) {
7537
+ for (const line of readFileSync21(path, "utf8").split(/\r?\n/)) {
7479
7538
  const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
7480
7539
  if (match) map[match[1]] = match[2].trim();
7481
7540
  }
@@ -7561,7 +7620,7 @@ async function syncPlans(options2 = {}) {
7561
7620
  const appRequire = createRequire2(join31(appRoot, "package.json"));
7562
7621
  const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
7563
7622
  const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
7564
- const appName = JSON.parse(readFileSync20(join31(appRoot, "package.json"), "utf8")).name ?? "app";
7623
+ const appName = JSON.parse(readFileSync21(join31(appRoot, "package.json"), "utf8")).name ?? "app";
7565
7624
  const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
7566
7625
  const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
7567
7626
  if (products.length === 0) {
@@ -7736,13 +7795,13 @@ async function setUserRoleOnPrisma(appRoot, email, role, log2) {
7736
7795
 
7737
7796
  // src/utils/install.ts
7738
7797
  import { spawn as spawn5 } from "child_process";
7739
- import { existsSync as existsSync29, readFileSync as readFileSync21, readdirSync as readdirSync12 } from "fs";
7798
+ import { existsSync as existsSync29, readFileSync as readFileSync22, readdirSync as readdirSync12 } from "fs";
7740
7799
  import { join as join34 } from "path";
7741
7800
  function expectedPackages(dir) {
7742
7801
  const npmLock = join34(dir, "package-lock.json");
7743
7802
  if (existsSync29(npmLock)) {
7744
7803
  try {
7745
- const lock = JSON.parse(readFileSync21(npmLock, "utf8"));
7804
+ const lock = JSON.parse(readFileSync22(npmLock, "utf8"));
7746
7805
  return Math.max(0, Object.keys(lock.packages ?? {}).length - 1);
7747
7806
  } catch {
7748
7807
  return 0;
@@ -7751,7 +7810,7 @@ function expectedPackages(dir) {
7751
7810
  const pnpmLock = join34(dir, "pnpm-lock.yaml");
7752
7811
  if (existsSync29(pnpmLock)) {
7753
7812
  try {
7754
- const text = readFileSync21(pnpmLock, "utf8");
7813
+ const text = readFileSync22(pnpmLock, "utf8");
7755
7814
  const packages = text.slice(text.indexOf("\npackages:"));
7756
7815
  return (packages.match(/\n {2}[^\s:]+:/g) ?? []).length;
7757
7816
  } catch {
@@ -7857,7 +7916,7 @@ function createCli() {
7857
7916
  printNextSteps(result, installed);
7858
7917
  }
7859
7918
  );
7860
- cli.command("gen <generator> [name] [second]", `Generate code. Generators: ${GENERATORS.join(", ")}`).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("--soft-delete", "resource: keep deleted rows and hide them, with a Trash view in the dashboard").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; on Next.js, also apply declared renames").example("flare gen migration add_phone --from-schema # Cloudflare: diff the Drizzle tables").example("flare gen migration add_phone # Next.js: prisma migrate dev, refusing a drop").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("--own <field>", "policy: confine each user to rows whose field holds their id, e.g. --own userId").option("--own-except <roles>", "policy: roles that see every row despite --own, e.g. admin").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing/apikeys: write files without adding the dependency").option("--skip-migration", "billing/security/apikeys: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen resource Invoice --fields 'number:string!' --soft-delete # a delete goes to the Trash").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 policy Invoice --roles staff --own userId --own-except admin # each user sees only their own").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").example("flare gen apikeys # API keys for cron jobs, scripts and mobile apps").example("flare gen apikeys # bearer keys for crons, scripts and mobile apps").action(
7919
+ cli.command("gen <generator> [name] [second]", `Generate code. Generators: ${GENERATORS.join(", ")}`).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("--add <fields>", "resource: add fields to one that exists, in the same grammar as --fields").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("--soft-delete", "resource: keep deleted rows and hide them, with a Trash view in the dashboard").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; on Next.js, also apply declared renames").example("flare gen migration add_phone --from-schema # Cloudflare: diff the Drizzle tables").example("flare gen migration add_phone # Next.js: prisma migrate dev, refusing a drop").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("--own <field>", "policy: confine each user to rows whose field holds their id, e.g. --own userId").option("--own-except <roles>", "policy: roles that see every row despite --own, e.g. admin").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing/apikeys: write files without adding the dependency").option("--skip-migration", "billing/security/apikeys: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen resource Contact --add 'phone:phone?, notes:text?' # add columns to one you have").example("flare gen resource Invoice --fields 'number:string!' --soft-delete # a delete goes to the Trash").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 policy Invoice --roles staff --own userId --own-except admin # each user sees only their own").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").example("flare gen apikeys # API keys for cron jobs, scripts and mobile apps").example("flare gen apikeys # bearer keys for crons, scripts and mobile apps").action(
7861
7920
  async (generator, name, second, options2) => {
7862
7921
  if (generator === "billing")
7863
7922
  return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
@@ -7870,6 +7929,7 @@ function createCli() {
7870
7929
  if (generator === "resource")
7871
7930
  return genResource(name, {
7872
7931
  fields: options2.fields,
7932
+ add: options2.add,
7873
7933
  force: options2.force,
7874
7934
  group: options2.group,
7875
7935
  icon: options2.icon,
@@ -7919,10 +7979,10 @@ function createCli() {
7919
7979
  "migrate",
7920
7980
  perStack(
7921
7981
  "Apply pending D1 migrations (local database unless --remote)",
7922
- "Apply pending Prisma migrations to the database in DATABASE_URL (prisma migrate deploy)",
7923
- "Apply pending migrations: D1 on the Cloudflare stack, Prisma on the Next.js one"
7982
+ "Make the database match the schema: prisma migrate dev + prisma generate (--deploy: apply committed migrations only)",
7983
+ "Make the database match the schema: D1 migrations on Cloudflare; prisma migrate dev + generate on Next.js"
7924
7984
  )
7925
- ).option("--remote", cloudflareOnly("Target the deployed database")).option("--env <name>", cloudflareOnly("Wrangler environment")).option("--database <binding>", cloudflareOnly("D1 binding, when the app has several")).action(async (options2) => {
7985
+ ).option("--remote", cloudflareOnly("Target the deployed database")).option("--env <name>", cloudflareOnly("Wrangler environment")).option("--database <binding>", cloudflareOnly("D1 binding, when the app has several")).option("--name <name>", "Next.js: name the migration, so Prisma doesn't have to ask").option("--deploy", "Next.js: apply committed migrations and invent nothing (CI, production)").example("flare migrate # local, writes a migration if the schema changed").example("flare migrate --deploy # production: apply what is committed, nothing else").action(async (options2) => {
7926
7986
  process.exitCode = await migrate(options2);
7927
7987
  });
7928
7988
  cli.command(
@@ -7981,6 +8041,15 @@ function createCli() {
7981
8041
  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) => {
7982
8042
  process.exitCode = await syncTypes(options2);
7983
8043
  });
8044
+ cli.command("db:seed", "Seed the development admin account (admin@example.com / Password, role admin)").option("--remote", "Against the deployed database rather than the local one (Cloudflare)").example("flare db:seed").action(async (options2) => {
8045
+ process.exitCode = await addUser(SEED_ADMIN.email, {
8046
+ password: SEED_ADMIN.password,
8047
+ name: SEED_ADMIN.name,
8048
+ role: "admin",
8049
+ skipExisting: true,
8050
+ remote: options2.remote
8051
+ });
8052
+ });
7984
8053
  cli.command("user:add <email>", "Create an account from the command line (the way in when signup is closed)").option("--role <role>", "Role to give them, e.g. admin").option("--password <password>", "Their password (generated and printed when omitted)").option("--name <name>", "Display name (default: the part before the @)").option("--remote", "Against the deployed database rather than the local one").option("--env <name>", "Wrangler environment").option("--skip-existing", "Succeed quietly if the account is already there (for seeds)").example("flare user:add ada@example.com --role admin").action(
7985
8054
  async (email, options2) => {
7986
8055
  process.exitCode = await addUser(email, options2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.12.2",
3
+ "version": "0.14.0",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -41,7 +41,7 @@
41
41
  "jiti": "^2.7.0",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picocolors": "^1.1.1",
44
- "@flaredev/core": "0.12.2"
44
+ "@flaredev/core": "0.14.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -1,4 +1,4 @@
1
- # __APP_NAME__
1
+ # __APP_TITLE__
2
2
 
3
3
  A [Flare](https://flare-docs.codetotech.com) app running on Cloudflare Workers via vinext.
4
4
 
@@ -4,33 +4,46 @@ import { useEffect } from "react";
4
4
  import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
7
+ import { hardReload, isStaleChunkError } from "@/lib/stale-chunk";
7
8
 
8
9
  /**
9
10
  * A dashboard page that threw.
10
11
  *
11
12
  * Inside the layout, so the sidebar stays and `reset` retries just this page — no
12
13
  * sign-out, no lost place in the app.
14
+ *
15
+ * Except when the tab is running stale JavaScript, where `reset` re-renders the same
16
+ * stale modules and can only fail again. That case gets a reload instead, which is the
17
+ * thing that actually works. See lib/stale-chunk.ts.
13
18
  */
14
19
  export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
15
20
  useEffect(() => {
16
21
  console.error(error);
17
22
  }, [error]);
18
23
 
24
+ const stale = isStaleChunkError(error);
25
+
19
26
  return (
20
27
  <Empty className="min-h-[60vh]">
21
28
  <EmptyHeader>
22
29
  <EmptyMedia variant="icon">
23
30
  <TriangleAlertIcon />
24
31
  </EmptyMedia>
25
- <EmptyTitle>This page didn&apos;t load</EmptyTitle>
32
+ <EmptyTitle>{stale ? "This tab is running old code" : "This page didn't load"}</EmptyTitle>
26
33
  <EmptyDescription>
27
- Something went wrong fetching it.
28
- {error.digest && <> Reference: <span className="font-mono select-all">{error.digest}</span>.</>}
34
+ {stale ? (
35
+ "Nothing is wrong with the app. This tab loaded one build and the server is serving another — usually after an update, or after starting a different app on this port. Reloading fixes it."
36
+ ) : (
37
+ <>
38
+ Something went wrong fetching it.
39
+ {error.digest && <> Reference: <span className="font-mono select-all">{error.digest}</span>.</>}
40
+ </>
41
+ )}
29
42
  </EmptyDescription>
30
43
  </EmptyHeader>
31
- <Button onClick={reset}>
44
+ <Button onClick={stale ? hardReload : reset}>
32
45
  <RefreshCwIcon data-icon="inline-start" />
33
- Try again
46
+ {stale ? "Reload the page" : "Try again"}
34
47
  </Button>
35
48
  </Empty>
36
49
  );
@@ -55,9 +55,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
55
55
 
56
56
  return (
57
57
  <SidebarProvider defaultOpen={!collapsed}>
58
- <DashboardSidebar appName="__APP_NAME__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
58
+ <DashboardSidebar appName="__APP_TITLE__" resources={resources} links={dashboardLinks} isAdmin={ADMIN_ROLES.includes(role)} user={account} />
59
59
  <SidebarInset>
60
- <DashboardHeader user={account} initialTheme={theme} notices={notices} />
60
+ <DashboardHeader user={account} initialTheme={theme} notices={notices} isAdmin={ADMIN_ROLES.includes(role)} />
61
61
  <main className="flex flex-1 flex-col gap-6 p-4 md:p-6 lg:p-8">{children}</main>
62
62
  </SidebarInset>
63
63
  <Toaster theme={theme} />