@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.
- package/dist/index.js +126 -57
- package/package.json +2 -2
- package/templates/app/README.md +1 -1
- package/templates/app/app/dashboard/error.tsx +18 -5
- package/templates/app/app/dashboard/layout.tsx +2 -2
- package/templates/app/app/dashboard/page.tsx +101 -26
- package/templates/app/app/dashboard/roles/actions.ts +57 -0
- package/templates/app/app/dashboard/roles/page.tsx +56 -0
- package/templates/app/app/dashboard/users/actions.ts +86 -0
- package/templates/app/app/dashboard/users/page.tsx +54 -0
- package/templates/app/app/error.tsx +14 -5
- package/templates/app/components/account/sections.tsx +23 -3
- package/templates/app/components/auth/password-field.tsx +51 -32
- package/templates/app/components/auth/sign-in-flow.tsx +306 -306
- package/templates/app/components/dashboard/bulk-add-sheet.tsx +377 -0
- package/templates/app/components/dashboard/cell-value.ts +67 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +2 -2
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +14 -10
- package/templates/app/components/dashboard/fields/field-widget.tsx +20 -16
- package/templates/app/components/dashboard/fields/file-field.tsx +23 -1
- package/templates/app/components/dashboard/import-dialog.tsx +1 -58
- package/templates/app/components/dashboard/resource-table.tsx +2 -0
- package/templates/app/components/dashboard/role-matrix.tsx +268 -0
- package/templates/app/components/dashboard/user-actions.tsx +217 -0
- package/templates/app/components/dashboard/user-menu.tsx +17 -23
- package/templates/app/components/dashboard/users-table.tsx +109 -0
- package/templates/app/components/ui/combobox.tsx +133 -0
- package/templates/app/db/flare-schema.ts +27 -1
- package/templates/app/lib/api-docs.ts +1 -1
- package/templates/app/lib/api.ts +4 -1
- package/templates/app/lib/auth.ts +3 -3
- package/templates/app/lib/dashboard.ts +20 -5
- package/templates/app/lib/mail.ts +2 -2
- package/templates/app/lib/permissions.ts +105 -0
- package/templates/app/lib/site.ts +2 -2
- package/templates/app/lib/stale-chunk.ts +50 -0
- package/templates/app/migrations/0000_init.sql +9 -0
- package/templates/app/migrations/meta/0000_snapshot.json +57 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +3 -1
- package/templates/locks/cloudflare/pnpm-lock.yaml +8 -8
- package/templates/locks/next/pnpm-lock.yaml +4 -4
- package/templates/next/README.md +1 -1
- package/templates/next/app/dashboard/layout.tsx +2 -2
- package/templates/next/app/dashboard/observability/page.tsx +156 -0
- package/templates/next/app/dashboard/page.tsx +87 -23
- package/templates/next/app/dashboard/users/page.tsx +51 -0
- package/templates/next/lib/auth.ts +3 -3
- package/templates/next/lib/dashboard-nav.ts +29 -0
- package/templates/next/lib/dashboard.ts +20 -5
- package/templates/next/lib/mail.ts +2 -2
- package/templates/next/lib/permissions.ts +100 -0
- package/templates/next/prisma/schema/base.prisma +37 -0
- package/templates/next/tsconfig.json +2 -2
- /package/templates/app/app/{forgot-password → (auth)/forgot-password}/page.tsx +0 -0
- /package/templates/app/app/{invite → (auth)/invite}/[token]/route.ts +0 -0
- /package/templates/app/app/{reset-password → (auth)/reset-password}/page.tsx +0 -0
- /package/templates/app/app/{sign-in → (auth)/sign-in}/page.tsx +0 -0
- /package/templates/app/app/{sign-up → (auth)/sign-up}/page.tsx +0 -0
- /package/templates/app/app/{two-factor → (auth)/two-factor}/page.tsx +0 -0
- /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
|
-
|
|
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":
|
|
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
|
-
[
|
|
694
|
-
[
|
|
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
|
-
[
|
|
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
|
|
706
|
-
prompts.note(
|
|
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((
|
|
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(
|
|
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,
|
|
2729
|
+
log2(pc6.dim("On Next.js, the database is whichever one DATABASE_URL points at."));
|
|
2708
2730
|
}
|
|
2709
|
-
const
|
|
2710
|
-
log2(`${pc6.cyan("prisma")}
|
|
2711
|
-
|
|
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
|
|
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
|
|
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
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 (
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
"
|
|
7923
|
-
"
|
|
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.
|
|
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.
|
|
44
|
+
"@flaredev/core": "0.14.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
package/templates/app/README.md
CHANGED
|
@@ -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
|
|
32
|
+
<EmptyTitle>{stale ? "This tab is running old code" : "This page didn't load"}</EmptyTitle>
|
|
26
33
|
<EmptyDescription>
|
|
27
|
-
|
|
28
|
-
|
|
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="
|
|
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} />
|