@flaredev/cli 0.12.0 → 0.12.2
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 +132 -19
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +2 -3
- package/templates/app/_gitignore +7 -0
- package/templates/app/lib/storage/uploadthing.ts +42 -4
- package/templates/app/lib/storage.ts +1 -1
- package/templates/locks/cloudflare/package-lock.json +68 -68
- package/templates/locks/cloudflare/pnpm-lock.yaml +48 -48
- package/templates/locks/cloudflare/yarn.lock +89 -89
- package/templates/locks/next/package-lock.json +157 -155
- package/templates/locks/next/pnpm-lock.yaml +92 -101
- package/templates/locks/next/yarn.lock +172 -184
- package/templates/next/lib/storage.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -204,10 +204,12 @@ var PROVIDERS = {
|
|
|
204
204
|
hint: { both: "a CDN with an uploader" },
|
|
205
205
|
stacks: ["cloudflare", "next"],
|
|
206
206
|
vars: {
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
// One variable, not two. The token is base64 JSON carrying the app id, so the
|
|
208
|
+
// driver reads the id out of it rather than asking for it a second time.
|
|
209
|
+
cloudflare: [{ name: "UPLOADTHING_TOKEN" }],
|
|
210
|
+
next: [{ name: "UPLOADTHING_TOKEN" }]
|
|
209
211
|
},
|
|
210
|
-
where: "UploadThing dashboard \u2192 your app \u2192 API Keys
|
|
212
|
+
where: "UploadThing dashboard \u2192 your app \u2192 API Keys",
|
|
211
213
|
console: "https://uploadthing.com/dashboard",
|
|
212
214
|
docs: `${DOCS}/uploadthing/`,
|
|
213
215
|
install: "uploadthing",
|
|
@@ -276,13 +278,17 @@ function storageEnvExample(name, stack) {
|
|
|
276
278
|
lines.push("", `# Or switch driver: ${others.join(", ")} \u2014 see the guide above.`, "");
|
|
277
279
|
return lines.join("\n");
|
|
278
280
|
}
|
|
279
|
-
function storageSetupSteps(name, stack) {
|
|
281
|
+
function storageSetupSteps(name, stack, options2 = {}) {
|
|
280
282
|
const provider = STORAGE_PROVIDERS[name];
|
|
281
283
|
const vars = storageVars(name, stack);
|
|
282
284
|
const steps = [];
|
|
283
|
-
|
|
285
|
+
const readyLocally = options2.local !== false && vars.length > 0 && vars.every((variable) => variable.local);
|
|
284
286
|
if (vars.length > 0 && provider.console) {
|
|
285
|
-
|
|
287
|
+
if (readyLocally) {
|
|
288
|
+
steps.push(`Works locally as it is. When you deploy, replace ${vars.map((variable) => variable.name).join(", ")}:`);
|
|
289
|
+
} else {
|
|
290
|
+
steps.push(`Get ${vars.map((variable) => variable.name).join(", ")} from ${provider.where ?? provider.console}`);
|
|
291
|
+
}
|
|
286
292
|
steps.push(provider.console);
|
|
287
293
|
}
|
|
288
294
|
if (provider.caveat) steps.push(provider.caveat);
|
|
@@ -365,8 +371,13 @@ var NEXT_DEPENDENCIES = {
|
|
|
365
371
|
// protocol. Vercel refuses to build it. A pin is only worth having if it is moved when
|
|
366
372
|
// one of these lands, so treat a critical against this line as a release of its own.
|
|
367
373
|
next: "16.3.8",
|
|
368
|
-
|
|
369
|
-
"@prisma/
|
|
374
|
+
// Prisma 7, pinned exactly and deliberately. `prisma@latest` is 8.0.0-rc — a release
|
|
375
|
+
// candidate — and Prisma's own update notifier prints "npm i @prisma/client@latest"
|
|
376
|
+
// inside `prisma migrate` output, so an app that follows that advice lands on a
|
|
377
|
+
// prerelease. The three move together or not at all; a client newer than the CLI that
|
|
378
|
+
// generated it is the mismatch that shows up as a runtime error, not a build one.
|
|
379
|
+
"@prisma/client": "7.10.1",
|
|
380
|
+
"@prisma/adapter-pg": "7.10.1",
|
|
370
381
|
pg: "^8.16.3",
|
|
371
382
|
"@upstash/redis": "^1.36.0",
|
|
372
383
|
"@better-auth/prisma-adapter": "1.7.17",
|
|
@@ -375,7 +386,9 @@ var NEXT_DEPENDENCIES = {
|
|
|
375
386
|
dotenv: "^17.2.3"
|
|
376
387
|
};
|
|
377
388
|
var NEXT_DEV_DEPENDENCIES = {
|
|
378
|
-
prisma
|
|
389
|
+
// Matches @prisma/client and @prisma/adapter-pg above. See the note there before
|
|
390
|
+
// moving it: the newest 7.x, not `latest`, which is an 8.0 release candidate.
|
|
391
|
+
prisma: "7.10.1",
|
|
379
392
|
// Runs seeds: the generated Prisma client is TypeScript a bundler compiles.
|
|
380
393
|
tsx: "^4.23.13",
|
|
381
394
|
// `flare deploy` and `npm run deploy`. Local rather than global, so the deploy works
|
|
@@ -388,6 +401,9 @@ var NEXT_DEV_DEPENDENCIES = {
|
|
|
388
401
|
tailwindcss: "^4.3.3",
|
|
389
402
|
typescript: "~5.9.3"
|
|
390
403
|
};
|
|
404
|
+
var STORAGE_DEPENDENCIES = {
|
|
405
|
+
uploadthing: "^7.7.4"
|
|
406
|
+
};
|
|
391
407
|
var CLOUDFLARE_ONLY = [
|
|
392
408
|
"wrangler.jsonc",
|
|
393
409
|
"vite.config.ts",
|
|
@@ -502,7 +518,15 @@ function installArgs(pm) {
|
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
// src/commands/create.ts
|
|
505
|
-
var
|
|
521
|
+
var SEED_ADMIN = { email: "admin@example.com", password: "Password", name: "Admin" };
|
|
522
|
+
var ALLOW_BUILDS = [
|
|
523
|
+
"'@prisma/engines': false",
|
|
524
|
+
"esbuild: true",
|
|
525
|
+
"msgpackr-extract: false",
|
|
526
|
+
"prisma: true",
|
|
527
|
+
"sharp: false",
|
|
528
|
+
"workerd: true"
|
|
529
|
+
].map((line) => ` ${line}`).join("\n");
|
|
506
530
|
function toAppName(dir) {
|
|
507
531
|
const name = basename(resolve2(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
508
532
|
if (!name) throw new Error(`Cannot derive an app name from "${dir}". Use letters, digits, and dashes.`);
|
|
@@ -574,12 +598,22 @@ storage/
|
|
|
574
598
|
// Which stack this app targets. `flare gen` reads it; don't change it by hand.
|
|
575
599
|
flare: { stack },
|
|
576
600
|
scripts: stack === "next" ? {
|
|
601
|
+
// Prisma 7 generates its client into lib/generated/prisma, which is
|
|
602
|
+
// git-ignored and therefore absent from every fresh checkout — including
|
|
603
|
+
// the one `create` has just written. Without this the first `dev` fails on
|
|
604
|
+
// `Can't resolve './generated/prisma/client'` before serving a page, and
|
|
605
|
+
// so does `flare user:add`, which imports lib/db. postinstall rather than a
|
|
606
|
+
// step in `dev`, so a teammate cloning the repo is covered by the same line.
|
|
607
|
+
postinstall: "prisma generate",
|
|
577
608
|
dev: "next dev",
|
|
578
609
|
build: "prisma generate && next build",
|
|
579
610
|
start: "next start",
|
|
580
611
|
deploy: "vercel deploy --prod",
|
|
581
612
|
"db:generate": "prisma generate",
|
|
582
613
|
"db:migrate": "prisma migrate dev",
|
|
614
|
+
// An account to sign in with, so the app is reachable the moment the tables
|
|
615
|
+
// 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`,
|
|
583
617
|
"db:studio": "prisma studio"
|
|
584
618
|
} : {
|
|
585
619
|
dev: "flare dev",
|
|
@@ -592,7 +626,10 @@ storage/
|
|
|
592
626
|
},
|
|
593
627
|
dependencies: {
|
|
594
628
|
"@flaredev/core": flarePackageSpec("core", dir, packageManager),
|
|
595
|
-
...stack === "next" ? { ...uiOnly, ...shared, ...NEXT_DEPENDENCIES } : APP_DEPENDENCIES
|
|
629
|
+
...stack === "next" ? { ...uiOnly, ...shared, ...NEXT_DEPENDENCIES } : APP_DEPENDENCIES,
|
|
630
|
+
// The chosen provider's SDK, where it has one, so the first upload works without
|
|
631
|
+
// a second install.
|
|
632
|
+
...storage in STORAGE_DEPENDENCIES ? { [storage]: STORAGE_DEPENDENCIES[storage] } : {}
|
|
596
633
|
},
|
|
597
634
|
devDependencies: {
|
|
598
635
|
"@flaredev/cli": flarePackageSpec("cli", dir, packageManager),
|
|
@@ -651,9 +688,15 @@ function copyLockfile(packageManager, dir, stack) {
|
|
|
651
688
|
function printNextSteps(result, installed) {
|
|
652
689
|
const rel = relative(process.cwd(), result.dir) || ".";
|
|
653
690
|
const pm = result.packageManager;
|
|
691
|
+
const database = result.stack === "next" ? [
|
|
692
|
+
["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"]
|
|
695
|
+
] : [];
|
|
654
696
|
const commands = [
|
|
655
697
|
[`cd ${rel}`, "your new app"],
|
|
656
698
|
...installed ? [] : [[`${pm} install`, "dependencies first"]],
|
|
699
|
+
...database,
|
|
657
700
|
[`${pm} run dev`, "http://localhost:3000"],
|
|
658
701
|
["npx flare gen resource Contact", "a table, API and admin screens"],
|
|
659
702
|
["npx flare seed:resource Contact 1000", "fill it with sample rows"]
|
|
@@ -661,10 +704,20 @@ function printNextSteps(result, installed) {
|
|
|
661
704
|
const width = Math.max(...commands.map(([command]) => command.length)) + 2;
|
|
662
705
|
const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
|
|
663
706
|
prompts.note(steps.join("\n"), "Next");
|
|
664
|
-
const storage = storageSetupSteps(result.storage, result.stack);
|
|
707
|
+
const storage = storageSetupSteps(result.storage, result.stack, { local: true });
|
|
665
708
|
if (storage.length > 0) {
|
|
666
709
|
prompts.note(storage.map((line) => line.startsWith("http") ? pc.cyan(line) : line).join("\n"), `Uploads \xB7 ${result.storage}`);
|
|
667
710
|
}
|
|
711
|
+
if (result.stack === "next") {
|
|
712
|
+
prompts.note(
|
|
713
|
+
[
|
|
714
|
+
`${pc.cyan(SEED_ADMIN.email)} ${pc.dim("\xB7")} ${pc.cyan(SEED_ADMIN.password)}`,
|
|
715
|
+
"",
|
|
716
|
+
pc.dim(`Made by ${pm} run db:seed, as an admin. Change it before this app is anywhere but your machine.`)
|
|
717
|
+
].join("\n"),
|
|
718
|
+
"Sign in with"
|
|
719
|
+
);
|
|
720
|
+
}
|
|
668
721
|
prompts.outro(`${pc.bold(result.name)} is ready ${pc.dim("\xB7")} ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
|
|
669
722
|
}
|
|
670
723
|
|
|
@@ -2882,6 +2935,51 @@ function checkTemplateVersion(installed, cli) {
|
|
|
2882
2935
|
fix: "Update the app's dependencies, then `flare update` to take the template changes you want."
|
|
2883
2936
|
} : { check: "Flare version", verdict: "warn", detail: `app has @flaredev/core ${installed}, which is newer than this CLI (${cli}).`, fix: "Update the CLI." };
|
|
2884
2937
|
}
|
|
2938
|
+
function installedVersion(appRoot, name) {
|
|
2939
|
+
const path = join14(appRoot, "node_modules", ...name.split("/"), "package.json");
|
|
2940
|
+
if (!existsSync15(path)) return void 0;
|
|
2941
|
+
try {
|
|
2942
|
+
return JSON.parse(readFileSync10(path, "utf8")).version;
|
|
2943
|
+
} catch {
|
|
2944
|
+
return void 0;
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
var PRISMA_PACKAGES = ["prisma", "@prisma/client", "@prisma/adapter-pg"];
|
|
2948
|
+
function checkPrismaVersions(versions) {
|
|
2949
|
+
const found = Object.entries(versions).filter(([, version]) => version !== void 0);
|
|
2950
|
+
if (found.length === 0) {
|
|
2951
|
+
return { check: "Prisma", verdict: "skip", detail: "Prisma is not installed yet.", fix: "Install dependencies." };
|
|
2952
|
+
}
|
|
2953
|
+
const majors = new Set(found.map(([, version]) => version.split(".")[0]));
|
|
2954
|
+
if (majors.size > 1) {
|
|
2955
|
+
return {
|
|
2956
|
+
check: "Prisma",
|
|
2957
|
+
verdict: "fail",
|
|
2958
|
+
detail: `the Prisma packages disagree on a major: ${found.map(([name, version]) => `${name} ${version}`).join(", ")}.`,
|
|
2959
|
+
fix: "Set all three to the same version. A client the installed CLI did not generate fails on the first query, not at build."
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
const [major] = [...majors];
|
|
2963
|
+
if (major !== "7") {
|
|
2964
|
+
const prerelease = found.find(([, version]) => version.includes("-"));
|
|
2965
|
+
return {
|
|
2966
|
+
check: "Prisma",
|
|
2967
|
+
verdict: "fail",
|
|
2968
|
+
detail: `the app is on Prisma ${major}${prerelease ? ` (${prerelease[1]}, a prerelease)` : ""}; Flare generates for 7.`,
|
|
2969
|
+
fix: "Pin prisma, @prisma/client and @prisma/adapter-pg to the same 7.x. `prisma@latest` is an 8.0 release candidate, so ask for the version by number."
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
const distinct = new Set(found.map(([, version]) => version));
|
|
2973
|
+
if (distinct.size > 1) {
|
|
2974
|
+
return {
|
|
2975
|
+
check: "Prisma",
|
|
2976
|
+
verdict: "warn",
|
|
2977
|
+
detail: `Prisma 7, but at mixed versions: ${found.map(([name, version]) => `${name} ${version}`).join(", ")}.`,
|
|
2978
|
+
fix: "Match them. They are released together and are only tested together."
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
return { check: "Prisma", verdict: "ok", detail: `Prisma ${[...distinct][0]}, all three in step.` };
|
|
2982
|
+
}
|
|
2885
2983
|
function checkRateLimitBackend(declared, configured, stack) {
|
|
2886
2984
|
if (declared.length === 0) return { check: "Rate limits", verdict: "ok", detail: "none declared." };
|
|
2887
2985
|
const list2 = declared.map((entry) => `${entry.resource}.${entry.action} ${entry.spec}`).join(", ");
|
|
@@ -2918,6 +3016,9 @@ async function diagnose(options2 = {}) {
|
|
|
2918
3016
|
}
|
|
2919
3017
|
findings.push({ check: "Stack", verdict: "ok", detail: stack === "next" ? "Next.js (Prisma, Postgres)." : "Cloudflare (vinext, D1, Drizzle)." });
|
|
2920
3018
|
findings.push(checkTemplateVersion(installedCoreVersion(appRoot), FLARE_VERSION2));
|
|
3019
|
+
if (stack === "next") {
|
|
3020
|
+
findings.push(checkPrismaVersions(Object.fromEntries(PRISMA_PACKAGES.map((name) => [name, installedVersion(appRoot, name)]))));
|
|
3021
|
+
}
|
|
2921
3022
|
findings.push(checkEnv(appRoot, stack));
|
|
2922
3023
|
findings.push(checkBindings(appRoot, stack));
|
|
2923
3024
|
let resources = [];
|
|
@@ -3107,6 +3208,7 @@ ${result.output.trim()}`);
|
|
|
3107
3208
|
// src/commands/user-add.ts
|
|
3108
3209
|
var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
|
|
3109
3210
|
var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3211
|
+
var KEPT = -1;
|
|
3110
3212
|
function generatePassword(length = 24) {
|
|
3111
3213
|
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
3112
3214
|
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
@@ -3153,7 +3255,8 @@ async function addUser(email, options2 = {}) {
|
|
|
3153
3255
|
const password = options2.password ?? generatePassword();
|
|
3154
3256
|
const generated = options2.password === void 0;
|
|
3155
3257
|
const rows = await credentialUserRows({ email: address, password, name: options2.name, role: options2.role });
|
|
3156
|
-
const code = readStack(appRoot) === "next" ? await addOnPrisma(appRoot, rows, log2) : await addOnD1(appRoot, rows, options2, log2);
|
|
3258
|
+
const code = readStack(appRoot) === "next" ? await addOnPrisma(appRoot, rows, log2, options2.skipExisting) : await addOnD1(appRoot, rows, options2, log2);
|
|
3259
|
+
if (code === KEPT) return 0;
|
|
3157
3260
|
if (code !== 0) return code;
|
|
3158
3261
|
log2(pc9.green(`\u2714 ${address} can now sign in${options2.role ? ` as ${options2.role}` : ""}.`));
|
|
3159
3262
|
if (generated) {
|
|
@@ -3186,6 +3289,10 @@ async function addOnD1(appRoot, rows, options2, log2) {
|
|
|
3186
3289
|
${existing.output.trim()}`);
|
|
3187
3290
|
const [found] = extractJson(existing.output);
|
|
3188
3291
|
if ((found?.results ?? []).length > 0) {
|
|
3292
|
+
if (options2.skipExisting) {
|
|
3293
|
+
log2(pc9.dim(`${rows.user.email} already has an account \u2014 left as it is.`));
|
|
3294
|
+
return KEPT;
|
|
3295
|
+
}
|
|
3189
3296
|
log2(pc9.red(`${rows.user.email} already has an account.`));
|
|
3190
3297
|
log2(pc9.dim("To change their role: flare user:role <email> <role>"));
|
|
3191
3298
|
return 1;
|
|
@@ -3195,7 +3302,7 @@ ${existing.output.trim()}`);
|
|
|
3195
3302
|
${result.output.trim()}`);
|
|
3196
3303
|
return 0;
|
|
3197
3304
|
}
|
|
3198
|
-
async function addOnPrisma(appRoot, rows, log2) {
|
|
3305
|
+
async function addOnPrisma(appRoot, rows, log2, skipExisting = false) {
|
|
3199
3306
|
const runner = join15(appRoot, ".flare-user-add-runner.mts");
|
|
3200
3307
|
const script = [
|
|
3201
3308
|
`import "dotenv/config";`,
|
|
@@ -3222,6 +3329,10 @@ async function addOnPrisma(appRoot, rows, log2) {
|
|
|
3222
3329
|
try {
|
|
3223
3330
|
const code = await runNode([resolveBin(appRoot, "tsx", "tsx"), runner], appRoot);
|
|
3224
3331
|
if (code === 3) {
|
|
3332
|
+
if (skipExisting) {
|
|
3333
|
+
log2(pc9.dim(`${rows.user.email} already has an account \u2014 left as it is.`));
|
|
3334
|
+
return KEPT;
|
|
3335
|
+
}
|
|
3225
3336
|
log2(pc9.red(`${rows.user.email} already has an account.`));
|
|
3226
3337
|
log2(pc9.dim("To change their role: flare user:role <email> <role>"));
|
|
3227
3338
|
return 1;
|
|
@@ -5948,7 +6059,7 @@ ${problems.map((problem) => ` ${problem}`).join("\n")}`);
|
|
|
5948
6059
|
}
|
|
5949
6060
|
if (renamed.length) {
|
|
5950
6061
|
log2(`${pc17.cyan("prisma")} migrate dev --name ${name}_rename`);
|
|
5951
|
-
const applied = await runNodeCapturing([prisma, "migrate", "dev", "--
|
|
6062
|
+
const applied = await runNodeCapturing([prisma, "migrate", "dev", "--name", `${name}_rename`], appRoot);
|
|
5952
6063
|
if (applied.code !== 0) {
|
|
5953
6064
|
throw new Error(`The rename migration could not be applied:
|
|
5954
6065
|
${applied.output.trim()}`);
|
|
@@ -7746,7 +7857,7 @@ function createCli() {
|
|
|
7746
7857
|
printNextSteps(result, installed);
|
|
7747
7858
|
}
|
|
7748
7859
|
);
|
|
7749
|
-
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
|
|
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(
|
|
7750
7861
|
async (generator, name, second, options2) => {
|
|
7751
7862
|
if (generator === "billing")
|
|
7752
7863
|
return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
|
|
@@ -7870,9 +7981,11 @@ function createCli() {
|
|
|
7870
7981
|
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) => {
|
|
7871
7982
|
process.exitCode = await syncTypes(options2);
|
|
7872
7983
|
});
|
|
7873
|
-
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").example("flare user:add ada@example.com --role admin").action(
|
|
7874
|
-
|
|
7875
|
-
|
|
7984
|
+
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
|
+
async (email, options2) => {
|
|
7986
|
+
process.exitCode = await addUser(email, options2);
|
|
7987
|
+
}
|
|
7988
|
+
);
|
|
7876
7989
|
cli.command("doctor", "Check the things that are usually wrong: versions, env, bindings, migrations, drift, policies").option("--offline", "Skip the checks that ask the database").example("flare doctor").action(async (options2) => {
|
|
7877
7990
|
process.exitCode = await doctor(options2);
|
|
7878
7991
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
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.
|
|
44
|
+
"@flaredev/core": "0.12.2"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -19,9 +19,8 @@ MAIL_FROM=
|
|
|
19
19
|
# CLOUDINARY_API_KEY=
|
|
20
20
|
# CLOUDINARY_API_SECRET=
|
|
21
21
|
|
|
22
|
-
# STORAGE_DRIVER=uploadthing
|
|
23
|
-
# UPLOADTHING_TOKEN=
|
|
24
|
-
# UPLOADTHING_APP_ID=
|
|
22
|
+
# STORAGE_DRIVER=uploadthing
|
|
23
|
+
# UPLOADTHING_TOKEN= # the app id is inside it; there is no second variable
|
|
25
24
|
|
|
26
25
|
# Both serve files from their own CDN, so a read URL is public and does not expire.
|
|
27
26
|
# Set this to keep every read signed and proxied by the app instead.
|
package/templates/app/_gitignore
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
node_modules
|
|
2
|
+
|
|
3
|
+
# Prisma's generated client (Next.js stack). Generated output, not source: it is
|
|
4
|
+
# rewritten by `prisma generate` on every install (postinstall) and on every build, so
|
|
5
|
+
# committing it means hundreds of generated files in your diffs and a stale client the
|
|
6
|
+
# moment someone changes the schema without regenerating.
|
|
7
|
+
lib/generated/
|
|
8
|
+
|
|
2
9
|
.env*
|
|
3
10
|
!.env.example
|
|
4
11
|
.dev.vars*
|
|
@@ -22,10 +22,13 @@ export interface UploadThingOptions {
|
|
|
22
22
|
/** From the UploadThing dashboard. Defaults to `UPLOADTHING_TOKEN`. */
|
|
23
23
|
token?: string;
|
|
24
24
|
/**
|
|
25
|
-
* The app id, which is the subdomain files are served from.
|
|
26
|
-
*
|
|
25
|
+
* The app id, which is the subdomain files are served from.
|
|
26
|
+
*
|
|
27
|
+
* Almost never passed: it is already inside the token, and {@link appIdFromToken}
|
|
28
|
+
* reads it from there. Pass it only to serve from a different subdomain than the
|
|
29
|
+
* token names.
|
|
27
30
|
*/
|
|
28
|
-
appId
|
|
31
|
+
appId?: string;
|
|
29
32
|
/**
|
|
30
33
|
* Serve reads from UploadThing's CDN. Default true, which is the reason to use it.
|
|
31
34
|
* See {@link StorageBucket.publicUrl}: those files are readable by anyone with the
|
|
@@ -50,6 +53,38 @@ export function customIdFor(key: string): string {
|
|
|
50
53
|
return key.replace(/[^A-Za-z0-9.-]/g, (char) => `_${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The app id carried inside an UploadThing token.
|
|
58
|
+
*
|
|
59
|
+
* A v7 token is base64 JSON — `{ apiKey, appId, regions }` — so the id of the app it
|
|
60
|
+
* belongs to is already in your hands the moment you have the token. Asking for
|
|
61
|
+
* `UPLOADTHING_APP_ID` as well was asking for a value that cannot disagree with the
|
|
62
|
+
* token without being wrong, and the failure it produced was a quiet one: uploads
|
|
63
|
+
* succeeded and every read 404'd from a subdomain belonging to another app.
|
|
64
|
+
*
|
|
65
|
+
* Only the `appId` is read here. The apiKey stays in the token, which is what the SDK
|
|
66
|
+
* itself is given.
|
|
67
|
+
*/
|
|
68
|
+
export function appIdFromToken(token: string): string {
|
|
69
|
+
let decoded: string;
|
|
70
|
+
try {
|
|
71
|
+
decoded = typeof atob === "function" ? atob(token) : Buffer.from(token, "base64").toString("utf8");
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error("UPLOADTHING_TOKEN is not valid base64. Copy it again from https://uploadthing.com/dashboard.");
|
|
74
|
+
}
|
|
75
|
+
let parsed: unknown;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(decoded);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new Error("UPLOADTHING_TOKEN does not decode to JSON. Copy it again from https://uploadthing.com/dashboard.");
|
|
80
|
+
}
|
|
81
|
+
const appId = (parsed as { appId?: unknown } | null)?.appId;
|
|
82
|
+
if (typeof appId !== "string" || appId === "") {
|
|
83
|
+
throw new Error("UPLOADTHING_TOKEN has no appId in it. Copy it again from https://uploadthing.com/dashboard.");
|
|
84
|
+
}
|
|
85
|
+
return appId;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/**
|
|
54
89
|
* The part of UTApi this driver uses.
|
|
55
90
|
*
|
|
@@ -82,7 +117,10 @@ async function api(token: string | undefined): Promise<UTApiLike> {
|
|
|
82
117
|
|
|
83
118
|
export function uploadThingBucket(options: UploadThingOptions): StorageBucket {
|
|
84
119
|
const publicDelivery = options.publicDelivery ?? true;
|
|
85
|
-
|
|
120
|
+
// Read once rather than per request, so a malformed token is an error at startup
|
|
121
|
+
// instead of a 404 on somebody's first upload.
|
|
122
|
+
const appId = options.appId ?? appIdFromToken(options.token ?? process.env.UPLOADTHING_TOKEN ?? "");
|
|
123
|
+
const deliveryUrl = (key: string) => `https://${appId}.ufs.sh/f/${customIdFor(key)}`;
|
|
86
124
|
|
|
87
125
|
return {
|
|
88
126
|
async get(key: string): Promise<StorageObjectBody | null> {
|
|
@@ -74,8 +74,8 @@ function selectBucket(): StorageBucket {
|
|
|
74
74
|
});
|
|
75
75
|
case "uploadthing":
|
|
76
76
|
return uploadThingBucket({
|
|
77
|
+
// The app id lives inside the token; the driver reads it from there.
|
|
77
78
|
token: required("UPLOADTHING_TOKEN"),
|
|
78
|
-
appId: required("UPLOADTHING_APP_ID"),
|
|
79
79
|
publicDelivery,
|
|
80
80
|
});
|
|
81
81
|
case "r2":
|