@flaredev/cli 0.13.0 → 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 (33) hide show
  1. package/dist/index.js +72 -37
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/error.tsx +18 -5
  4. package/templates/app/app/dashboard/roles/actions.ts +57 -0
  5. package/templates/app/app/dashboard/roles/page.tsx +56 -0
  6. package/templates/app/app/error.tsx +14 -5
  7. package/templates/app/components/account/sections.tsx +23 -3
  8. package/templates/app/components/auth/password-field.tsx +51 -32
  9. package/templates/app/components/auth/sign-in-flow.tsx +306 -306
  10. package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
  11. package/templates/app/components/dashboard/cell-value.ts +67 -0
  12. package/templates/app/components/dashboard/dashboard-sidebar.tsx +2 -1
  13. package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
  14. package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
  15. package/templates/app/components/dashboard/import-dialog.tsx +1 -58
  16. package/templates/app/components/dashboard/resource-table.tsx +2 -0
  17. package/templates/app/components/dashboard/role-matrix.tsx +268 -0
  18. package/templates/app/components/dashboard/user-actions.tsx +55 -9
  19. package/templates/app/components/dashboard/user-menu.tsx +1 -1
  20. package/templates/app/components/ui/combobox.tsx +133 -0
  21. package/templates/app/db/flare-schema.ts +27 -1
  22. package/templates/app/lib/api.ts +4 -1
  23. package/templates/app/lib/dashboard.ts +15 -4
  24. package/templates/app/lib/permissions.ts +105 -0
  25. package/templates/app/lib/stale-chunk.ts +50 -0
  26. package/templates/app/migrations/0000_init.sql +9 -0
  27. package/templates/app/migrations/meta/0000_snapshot.json +57 -1
  28. package/templates/app/migrations/meta/_journal.json +1 -1
  29. package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
  30. package/templates/locks/next/pnpm-lock.yaml +4 -4
  31. package/templates/next/lib/dashboard.ts +15 -4
  32. package/templates/next/lib/permissions.ts +100 -0
  33. package/templates/next/prisma/schema/base.prisma +31 -0
package/dist/index.js CHANGED
@@ -730,7 +730,7 @@ function printNextSteps(result, installed) {
730
730
  [
731
731
  `${pc.cyan(SEED_ADMIN.email)} ${pc.dim("\xB7")} ${pc.cyan(SEED_ADMIN.password)}`,
732
732
  "",
733
- 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.")
734
734
  ].join("\n"),
735
735
  "Sign in with"
736
736
  );
@@ -3116,7 +3116,7 @@ async function diagnose(options2 = {}) {
3116
3116
  }
3117
3117
  }
3118
3118
  if (stack === "next") {
3119
- 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." });
3120
3120
  } else if (options2.offline) {
3121
3121
  findings.push({ check: "Migrations", verdict: "skip", detail: "Skipped (--offline)." });
3122
3122
  } else {
@@ -3889,7 +3889,7 @@ async function genApiKeys(options2 = {}) {
3889
3889
  }
3890
3890
  const steps = [
3891
3891
  ...installed ? [`${pc10.bold("install")} (pnpm install) to fetch ${API_KEY_PACKAGE}.`] : [],
3892
- 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.`,
3893
3893
  `${pc10.bold("flare dev")}, then ${pc10.bold("/dashboard/api-keys")} to mint one.`,
3894
3894
  `Call the API with it: ${pc10.bold("-H 'Authorization: Bearer <key>'")} (or ${pc10.bold("x-api-key")}).`
3895
3895
  ];
@@ -4241,15 +4241,20 @@ function renderFieldsBlock(fields) {
4241
4241
  function renderDescriptor(name, fields, options2 = {}) {
4242
4242
  defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
4243
4243
  const block = renderFieldsBlock(fields);
4244
- const heading = [
4245
- options2.icon ? ` icon: ${literal(options2.icon)},
4246
- ` : "",
4247
- options2.group ? ` group: ${literal(options2.group)},
4248
- ` : "",
4249
- 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.
4250
4253
  softDelete: true,
4251
- ` : ""
4252
- ].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
+ `;
4253
4258
  return joinMarkers({
4254
4259
  before: `import { defineResource, field } from "@flaredev/core";
4255
4260
 
@@ -5761,7 +5766,7 @@ async function askCreateQuestions(given, appName) {
5761
5766
  }
5762
5767
 
5763
5768
  // src/commands/gen.ts
5764
- 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";
5765
5770
  import { dirname as dirname9, join as join20 } from "path";
5766
5771
  import { AUTH_USER as AUTH_USER2, camelCase as camelCase5, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
5767
5772
  import pc15 from "picocolors";
@@ -5785,11 +5790,37 @@ async function genResource(rawName, options2) {
5785
5790
  const relativePath = descriptorPath(name);
5786
5791
  const path = join20(appRoot, relativePath);
5787
5792
  const exists = existsSync20(path);
5788
- if (!exists && !options2.fields) {
5793
+ if (!exists && !options2.fields && !options2.add) {
5789
5794
  throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
5790
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
+ }
5791
5802
  const existing = await loadResources(appRoot);
5792
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
+ }
5793
5824
  if (options2.fields) {
5794
5825
  const fields = parseFields(options2.fields);
5795
5826
  validateFields(name, fields, others, log2);
@@ -5842,7 +5873,7 @@ Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for deta
5842
5873
  if (!options2.skipMigration) {
5843
5874
  if (readStack(appRoot) === "next") {
5844
5875
  log2(`
5845
- 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.`);
5846
5877
  } else {
5847
5878
  const renamed = await applyDeclaredRenames(appRoot, await loadResources(appRoot));
5848
5879
  for (const [index, file] of renamed.files.entries()) {
@@ -5937,7 +5968,7 @@ ${body}
5937
5968
  }
5938
5969
 
5939
5970
  // src/commands/gen-migration.ts
5940
- 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";
5941
5972
  import { basename as basename2, join as join22 } from "path";
5942
5973
  import { snakeCase } from "@flaredev/core";
5943
5974
  import pc17 from "picocolors";
@@ -6132,7 +6163,7 @@ ${applied.output.trim()}`);
6132
6163
  const relative3 = `prisma/migrations/${entry}/migration.sql`;
6133
6164
  written.push(relative3);
6134
6165
  log2(`${pc17.green("create".padEnd(9))} ${relative3}`);
6135
- const found = destructiveStatements(readFileSync15(file, "utf8"));
6166
+ const found = destructiveStatements(readFileSync16(file, "utf8"));
6136
6167
  if (found.length > 0) destructive.push({ file: relative3, found });
6137
6168
  }
6138
6169
  if (destructive.length > 0) {
@@ -6246,16 +6277,19 @@ ${pc18.dim("Roles come from `flare role:add`; the API and the admin both enforce
6246
6277
  }
6247
6278
 
6248
6279
  // src/commands/rm.ts
6249
- 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";
6250
6281
  import { dirname as dirname11, join as join24 } from "path";
6251
6282
  import { relationGraph as relationGraph3 } from "@flaredev/core";
6252
6283
  import pc19 from "picocolors";
6253
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
+ );
6254
6288
  function handWrittenReason(source, header2, options2 = {}) {
6255
6289
  const parts = splitMarkers(source);
6256
6290
  if (!parts) return "has no generated block (entirely hand-written)";
6257
6291
  if (options2.descriptorWrapper) {
6258
- 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)) {
6259
6293
  return "has hand-written changes outside the fields block";
6260
6294
  }
6261
6295
  return null;
@@ -6307,7 +6341,7 @@ async function rmResource(rawName, options2 = {}) {
6307
6341
  if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
6308
6342
  const findings = [];
6309
6343
  for (const path of owned) {
6310
- const source = readFileSync16(join24(appRoot, path), "utf8");
6344
+ const source = readFileSync17(join24(appRoot, path), "utf8");
6311
6345
  let wrapper;
6312
6346
  if (entry && path === entry.file) {
6313
6347
  const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
@@ -6336,7 +6370,7 @@ Move what you want to keep, or pass --force to delete anyway.`);
6336
6370
  const table = entry ? tableExport(entry.resource) : void 0;
6337
6371
  if (table && existsSync24(join24(appRoot, "seeds"))) {
6338
6372
  const seeds = readdirSync9(join24(appRoot, "seeds")).filter(
6339
- (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"))
6340
6374
  );
6341
6375
  if (seeds.length) log2(pc19.yellow(`
6342
6376
  These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
@@ -6344,7 +6378,7 @@ These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`))
6344
6378
  let migrations = [];
6345
6379
  if (readStack(appRoot) === "next") {
6346
6380
  if (entry) log2(`
6347
- Next: ${pc19.bold("npx prisma migrate dev")} to drop the table.`);
6381
+ Next: ${pc19.bold("npx flare migrate")} to drop the table.`);
6348
6382
  return { removed, migrations };
6349
6383
  }
6350
6384
  if (!options2.skipMigration && entry) {
@@ -6359,7 +6393,7 @@ Next: ${pc19.bold("flare migrate")} to drop the table locally.`);
6359
6393
  }
6360
6394
 
6361
6395
  // src/commands/eject.ts
6362
- 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";
6363
6397
  import { dirname as dirname12, join as join25, relative as relative2 } from "path";
6364
6398
  import pc20 from "picocolors";
6365
6399
  var TRACKED = ["app", "lib", "components"];
@@ -6369,7 +6403,7 @@ var prunedOn = (path, stack) => stack === "next" && CLOUDFLARE_ONLY.some((only)
6369
6403
  function upstreamFile(path, stack) {
6370
6404
  if (prunedOn(path, stack) || WRITTEN_PER_APP.has(path)) return void 0;
6371
6405
  const found = locate(path, stack);
6372
- if (found && PER_APP.test(readFileSync17(found, "utf8"))) return void 0;
6406
+ if (found && PER_APP.test(readFileSync18(found, "utf8"))) return void 0;
6373
6407
  return found;
6374
6408
  }
6375
6409
  function locate(path, stack) {
@@ -6396,10 +6430,10 @@ function trackedPaths(appRoot, stack) {
6396
6430
  function compareTracked(appRoot, stack, filter) {
6397
6431
  return trackedPaths(appRoot, stack).filter((path) => !filter || path.includes(filter)).map((path) => {
6398
6432
  const source = upstreamFile(path, stack);
6399
- const theirs = readFileSync17(source, "utf8");
6433
+ const theirs = readFileSync18(source, "utf8");
6400
6434
  const target = join25(appRoot, path);
6401
6435
  if (!existsSync25(target)) return { path, state: "missing", theirs };
6402
- const mine = readFileSync17(target, "utf8");
6436
+ const mine = readFileSync18(target, "utf8");
6403
6437
  if (mine === theirs) return { path, state: "same", mine, theirs };
6404
6438
  const merged = mergeGeneratedBlock(mine, theirs);
6405
6439
  if (merged !== void 0) {
@@ -6589,7 +6623,7 @@ function createInsertMany(sink, drizzle, onProgress) {
6589
6623
  }
6590
6624
 
6591
6625
  // src/seed/local-d1.ts
6592
- 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";
6593
6627
  import { createRequire } from "module";
6594
6628
  import { join as join26 } from "path";
6595
6629
  import { pathToFileURL } from "url";
@@ -6622,7 +6656,7 @@ async function openLocalD1(appRoot, binding) {
6622
6656
  }
6623
6657
  function seedConfig(appRoot) {
6624
6658
  const source = join26(appRoot, "wrangler.jsonc");
6625
- const config = parseJsonc3(readFileSync18(source, "utf8")) ?? {};
6659
+ const config = parseJsonc3(readFileSync19(source, "utf8")) ?? {};
6626
6660
  delete config.durable_objects;
6627
6661
  delete config.migrations;
6628
6662
  const path = join26(appRoot, ".flare-seed.wrangler.json");
@@ -7171,7 +7205,7 @@ function* seedRows(resource, count, options2 = {}) {
7171
7205
 
7172
7206
  // src/commands/seed-resource-prisma.ts
7173
7207
  import { createWriteStream } from "fs";
7174
- 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";
7175
7209
  import { tmpdir as tmpdir3 } from "os";
7176
7210
  import { join as join29 } from "path";
7177
7211
  import { once } from "events";
@@ -7295,7 +7329,7 @@ async function seedResourceWithPrisma(appRoot, resource, options2, log2) {
7295
7329
  const { code: code2, output } = await runNodeCapturing([tsx, runner, "probe", planFile], appRoot);
7296
7330
  if (code2 !== 0) throw new Error(`Could not read the database before seeding.
7297
7331
  ${output.trim()}`);
7298
- probe = JSON.parse(readFileSync19(outFile, "utf8"));
7332
+ probe = JSON.parse(readFileSync20(outFile, "utf8"));
7299
7333
  }
7300
7334
  const parentIds = new Map(Object.entries(probe.parents));
7301
7335
  for (const target of options2.parentTargets) {
@@ -7308,7 +7342,7 @@ ${output.trim()}`);
7308
7342
  writeFileSync22(planFile, JSON.stringify({ model, table: resource.table, out: outFile, rows: rowsFile, truncate: !!options2.truncate, total: options2.count }));
7309
7343
  const code = await runNode([tsx, runner, "insert", planFile], appRoot);
7310
7344
  if (code !== 0) throw new Error("Seeding stopped; the reason is above.");
7311
- const { written } = JSON.parse(readFileSync19(outFile, "utf8"));
7345
+ const { written } = JSON.parse(readFileSync20(outFile, "utf8"));
7312
7346
  return written;
7313
7347
  } finally {
7314
7348
  rmSync8(runner, { force: true });
@@ -7490,7 +7524,7 @@ ${result.output.trim()}`);
7490
7524
 
7491
7525
  // src/commands/billing-sync.ts
7492
7526
  import { createRequire as createRequire2 } from "module";
7493
- 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";
7494
7528
  import { tmpdir as tmpdir5 } from "os";
7495
7529
  import { join as join31 } from "path";
7496
7530
  import { pathToFileURL as pathToFileURL2 } from "url";
@@ -7500,7 +7534,7 @@ function stripeKeys(appRoot) {
7500
7534
  const map = {};
7501
7535
  const path = join31(appRoot, ".dev.vars");
7502
7536
  if (existsSync27(path)) {
7503
- for (const line of readFileSync20(path, "utf8").split(/\r?\n/)) {
7537
+ for (const line of readFileSync21(path, "utf8").split(/\r?\n/)) {
7504
7538
  const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
7505
7539
  if (match) map[match[1]] = match[2].trim();
7506
7540
  }
@@ -7586,7 +7620,7 @@ async function syncPlans(options2 = {}) {
7586
7620
  const appRequire = createRequire2(join31(appRoot, "package.json"));
7587
7621
  const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
7588
7622
  const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
7589
- 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";
7590
7624
  const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
7591
7625
  const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
7592
7626
  if (products.length === 0) {
@@ -7761,13 +7795,13 @@ async function setUserRoleOnPrisma(appRoot, email, role, log2) {
7761
7795
 
7762
7796
  // src/utils/install.ts
7763
7797
  import { spawn as spawn5 } from "child_process";
7764
- 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";
7765
7799
  import { join as join34 } from "path";
7766
7800
  function expectedPackages(dir) {
7767
7801
  const npmLock = join34(dir, "package-lock.json");
7768
7802
  if (existsSync29(npmLock)) {
7769
7803
  try {
7770
- const lock = JSON.parse(readFileSync21(npmLock, "utf8"));
7804
+ const lock = JSON.parse(readFileSync22(npmLock, "utf8"));
7771
7805
  return Math.max(0, Object.keys(lock.packages ?? {}).length - 1);
7772
7806
  } catch {
7773
7807
  return 0;
@@ -7776,7 +7810,7 @@ function expectedPackages(dir) {
7776
7810
  const pnpmLock = join34(dir, "pnpm-lock.yaml");
7777
7811
  if (existsSync29(pnpmLock)) {
7778
7812
  try {
7779
- const text = readFileSync21(pnpmLock, "utf8");
7813
+ const text = readFileSync22(pnpmLock, "utf8");
7780
7814
  const packages = text.slice(text.indexOf("\npackages:"));
7781
7815
  return (packages.match(/\n {2}[^\s:]+:/g) ?? []).length;
7782
7816
  } catch {
@@ -7882,7 +7916,7 @@ function createCli() {
7882
7916
  printNextSteps(result, installed);
7883
7917
  }
7884
7918
  );
7885
- 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(
7886
7920
  async (generator, name, second, options2) => {
7887
7921
  if (generator === "billing")
7888
7922
  return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
@@ -7895,6 +7929,7 @@ function createCli() {
7895
7929
  if (generator === "resource")
7896
7930
  return genResource(name, {
7897
7931
  fields: options2.fields,
7932
+ add: options2.add,
7898
7933
  force: options2.force,
7899
7934
  group: options2.group,
7900
7935
  icon: options2.icon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.13.0",
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.13.0"
44
+ "@flaredev/core": "0.14.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -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
  );
@@ -0,0 +1,57 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { createRole, deleteRole, setGrant } from "@/lib/permissions";
5
+ import { ADMIN_ROLES, dashboardSession } from "@/lib/dashboard";
6
+
7
+ /**
8
+ * Changing who may do what.
9
+ *
10
+ * Admin only, and deliberately not policy-driven: these actions decide what the
11
+ * policies themselves allow, so gating them on a policy would be circular. ADMIN_ROLES
12
+ * (lib/dashboard.ts) is the one list that governs the app's own settings.
13
+ */
14
+
15
+ export type RoleActionResult = { ok: true } | { ok: false; error: string };
16
+
17
+ const ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
18
+ const ACTIONS = new Set(["read", "create", "update", "delete"]);
19
+
20
+ async function requireAdmin(): Promise<RoleActionResult | undefined> {
21
+ const { session, role } = await dashboardSession();
22
+ if (!session) return { ok: false, error: "You're signed out." };
23
+ if (!role || !ADMIN_ROLES.includes(role)) return { ok: false, error: "Only an admin can change roles." };
24
+ }
25
+
26
+ export async function createRoleAction(input: { name: string; label: string }): Promise<RoleActionResult> {
27
+ const denied = await requireAdmin();
28
+ if (denied) return denied;
29
+ const name = input.name.trim().toLowerCase();
30
+ if (!ROLE_NAME.test(name)) {
31
+ return { ok: false, error: "A role is lowercase letters, digits, _ or -, starting with a letter — e.g. support-agent." };
32
+ }
33
+ await createRole(name, input.label.trim() || name);
34
+ revalidatePath("/dashboard/roles");
35
+ return { ok: true };
36
+ }
37
+
38
+ export async function deleteRoleAction(name: string): Promise<RoleActionResult> {
39
+ const denied = await requireAdmin();
40
+ if (denied) return denied;
41
+ // Not admin: the role this screen is gated on cannot be removed from this screen,
42
+ // which is the one mistake here that cannot be undone from the dashboard.
43
+ if (ADMIN_ROLES.includes(name)) return { ok: false, error: `"${name}" runs this screen, so it can't be deleted here.` };
44
+ await deleteRole(name);
45
+ revalidatePath("/dashboard/roles");
46
+ return { ok: true };
47
+ }
48
+
49
+ export async function setGrantAction(input: { role: string; resource: string; action: string; granted: boolean }): Promise<RoleActionResult> {
50
+ const denied = await requireAdmin();
51
+ if (denied) return denied;
52
+ if (!ROLE_NAME.test(input.role)) return { ok: false, error: `"${input.role}" is not a role name.` };
53
+ if (!ACTIONS.has(input.action)) return { ok: false, error: `"${input.action}" is not an action.` };
54
+ await setGrant(input.role, input.resource, input.action, input.granted);
55
+ revalidatePath("/dashboard/roles");
56
+ return { ok: true };
57
+ }
@@ -0,0 +1,56 @@
1
+ import { redirect } from "next/navigation";
2
+ import { grantIndex } from "@flaredev/core";
3
+ import { PageHeader } from "@/components/dashboard/page-header";
4
+ import { RoleMatrix, type MatrixResource } from "@/components/dashboard/role-matrix";
5
+ import { ADMIN_ROLES, allResources, dashboardSession } from "@/lib/dashboard";
6
+ import { allGrants, codePolicy, GRANTABLE, listRoles, rolesInUse } from "@/lib/permissions";
7
+
8
+ export const metadata = { title: "Roles & permissions" };
9
+
10
+ /**
11
+ * Who may do what, per role.
12
+ *
13
+ * Admin only, and not governed by a resource policy: this screen decides what the
14
+ * policies allow, so gating it on one would be circular. It reads the policy files and
15
+ * the grants together, and shows which ticks came from which — a tick from a file is
16
+ * locked, because the file is the floor and the dashboard only widens it.
17
+ */
18
+ export default async function RolesPage() {
19
+ const { session, role } = await dashboardSession();
20
+ if (!session) redirect("/sign-in?next=/dashboard/roles");
21
+ if (!role || !ADMIN_ROLES.includes(role)) redirect("/dashboard");
22
+
23
+ const [roles, grants, inUse] = await Promise.all([listRoles(), allGrants(), rolesInUse()]);
24
+ const index = grantIndex(grants);
25
+
26
+ // Every role an account could hold, not only the registered ones: the admin plugin's
27
+ // default is "user", and a role assigned with `flare user:role` before anybody opened
28
+ // this screen exists whether or not it was registered here. Showing it is how you
29
+ // discover you have one.
30
+ const known = new Map(roles.map((entry) => [entry.name, entry]));
31
+ for (const name of [...ADMIN_ROLES, "user", ...inUse]) {
32
+ if (!known.has(name)) known.set(name, { name, label: name });
33
+ }
34
+
35
+ const resources: MatrixResource[] = allResources().map((resource) => {
36
+ const policy = codePolicy(resource.name);
37
+ const fromCode: Record<string, string[]> = {};
38
+ const granted: Record<string, string[]> = {};
39
+ for (const action of GRANTABLE) {
40
+ fromCode[action] = policy ? policy[action] : [];
41
+ granted[action] = [...(index.get(`${resource.name}.${action}`) ?? [])];
42
+ }
43
+ return { name: resource.name, pluralLabel: resource.pluralLabel, restricted: Boolean(policy), fromCode, granted };
44
+ });
45
+
46
+ return (
47
+ <>
48
+ <PageHeader
49
+ title="Roles & permissions"
50
+ description="What each role may do, per resource. Policy files set the floor; what you tick here widens it."
51
+ crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Roles & permissions" }]}
52
+ />
53
+ <RoleMatrix roles={[...known.values()]} resources={resources} adminRoles={ADMIN_ROLES} />
54
+ </>
55
+ );
56
+ }
@@ -3,6 +3,7 @@
3
3
  import { useEffect } from "react";
4
4
  import Link from "next/link";
5
5
  import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
6
+ import { hardReload, isStaleChunkError } from "@/lib/stale-chunk";
6
7
 
7
8
  /**
8
9
  * The public error boundary.
@@ -11,6 +12,10 @@ import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
11
12
  * needs somewhere to hand `reset`, which re-renders the segment without a full page
12
13
  * load, and that is usually all a transient failure needs.
13
14
  *
15
+ * Usually, but not always: a tab holding stale JavaScript throws too, and `reset` can
16
+ * never fix that one — it re-renders the same stale modules. So that case is told
17
+ * apart and offered a reload, which does fix it. See lib/stale-chunk.ts.
18
+ *
14
19
  * `error.digest` is the only detail shown. The message itself is deliberately not:
15
20
  * in production it is replaced by React anyway, and in development the overlay is
16
21
  * more use than anything printed here.
@@ -21,17 +26,21 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
21
26
  console.error(error);
22
27
  }, [error]);
23
28
 
29
+ const stale = isStaleChunkError(error);
30
+
24
31
  return (
25
32
  <main className="mx-auto flex min-h-[70vh] max-w-2xl flex-col items-center justify-center gap-6 px-6 py-20 text-center">
26
33
  <div className="flex size-14 items-center justify-center rounded-2xl border border-border bg-surface">
27
34
  <TriangleAlertIcon className="size-6 text-danger" aria-hidden="true" />
28
35
  </div>
29
36
  <div className="flex flex-col gap-3">
30
- <h1 className="text-3xl text-balance md:text-4xl">Something went wrong</h1>
37
+ <h1 className="text-3xl text-balance md:text-4xl">{stale ? "This tab is running old code" : "Something went wrong"}</h1>
31
38
  <p className="text-foreground-muted text-pretty">
32
- This one is on us. Trying again often works; if it doesn&apos;t, the error has been logged.
39
+ {stale
40
+ ? "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."
41
+ : "This one is on us. Trying again often works; if it doesn't, the error has been logged."}
33
42
  </p>
34
- {error.digest && (
43
+ {!stale && error.digest && (
35
44
  <p className="font-mono text-xs text-foreground-muted">
36
45
  Reference: <span className="select-all">{error.digest}</span>
37
46
  </p>
@@ -40,11 +49,11 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
40
49
  <div className="flex flex-wrap justify-center gap-3">
41
50
  <button
42
51
  type="button"
43
- onClick={reset}
52
+ onClick={stale ? hardReload : reset}
44
53
  className="inline-flex h-11 items-center gap-2 rounded-[var(--button-radius)] bg-brand px-5 font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
45
54
  >
46
55
  <RefreshCwIcon className="size-4" aria-hidden="true" />
47
- Try again
56
+ {stale ? "Reload the page" : "Try again"}
48
57
  </button>
49
58
  <Link href="/" className="inline-flex h-11 items-center rounded-[var(--button-radius)] border border-border px-5 font-medium hover:bg-accent">
50
59
  Back home
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
5
5
  import { renderSVG } from "uqr";
6
6
  import { CheckIcon, CopyIcon, FingerprintIcon, LaptopIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react";
7
7
  import { AuthInput, AuthLabel, FormMessage, PrimaryButton, SecondaryButton, TextButton } from "@/components/auth/ui";
8
- import { PasswordField } from "@/components/auth/password-field";
8
+ import { PasswordField, PasswordInput } from "@/components/auth/password-field";
9
9
  import { authErrorMessage } from "@/components/auth/sign-in-flow";
10
10
  import { PROVIDER_LABELS, ProviderIcon } from "@/components/auth/provider-icons";
11
11
  import { authClient } from "@/lib/auth-client";
@@ -50,7 +50,7 @@ function PasswordConfirm({ value, onChange, id }: { value: string; onChange: (va
50
50
  return (
51
51
  <div className="flex flex-col gap-2">
52
52
  <AuthLabel htmlFor={id}>Your password</AuthLabel>
53
- <AuthInput id={id} type="password" autoComplete="current-password" value={value} onChange={(event) => onChange(event.target.value)} required />
53
+ <PasswordInput id={id} autoComplete="current-password" value={value} onChange={(event) => onChange(event.target.value)} required />
54
54
  </div>
55
55
  );
56
56
  }
@@ -459,6 +459,26 @@ function describeDevice(agent?: string | null): string {
459
459
  return system ? `${browser} on ${system}` : browser;
460
460
  }
461
461
 
462
+ /**
463
+ * Where a session came from, in words rather than a number nobody can read.
464
+ *
465
+ * Better Auth records whatever address the request arrived from, and on a machine
466
+ * talking to itself that is the loopback — which IPv6 writes in full as
467
+ * 0000:0000:0000:0000:0000:0000:0000:0001. Printing that in a list headed "Sign out of
468
+ * devices you don't recognise" is worse than printing nothing: it is unreadable, it is
469
+ * identical on every row, and it looks like a bug. Say "this computer" instead, which
470
+ * is both true and useful.
471
+ */
472
+ function describeOrigin(ip?: string | null): string | null {
473
+ if (!ip) return null;
474
+ const address = ip.trim().toLowerCase().replace(/^\[|\]$/g, "");
475
+ // Every spelling of "no address" and "here": IPv4 and IPv6 loopback, the unspecified
476
+ // address, and the IPv4-mapped forms a dual-stack listener reports.
477
+ if (/^(::1?|0+(:0+)*|127(\.\d+){3}|::ffff:127(\.\d+){3}|localhost)$/.test(address)) return "this computer";
478
+ if (/^(10|127)\.|^192\.168\.|^172\.(1[6-9]|2\d|3[01])\.|^fe80:/.test(address)) return `${ip} (local network)`;
479
+ return ip;
480
+ }
481
+
462
482
  export function Sessions() {
463
483
  const [list, setList] = useState<SessionRow[] | null>(null);
464
484
  const [current, setCurrent] = useState<string | null>(null);
@@ -488,7 +508,7 @@ export function Sessions() {
488
508
  <span className="flex-1">
489
509
  <span className="font-medium">{describeDevice(item.userAgent)}</span>
490
510
  <span className="block text-xs text-foreground-muted">
491
- {item.ipAddress || "Unknown location"}
511
+ {describeOrigin(item.ipAddress) ?? "Unknown location"}
492
512
  {item.updatedAt && `, last active ${new Date(item.updatedAt).toLocaleString()}`}
493
513
  </span>
494
514
  </span>