@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 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
- cloudflare: [{ name: "UPLOADTHING_TOKEN" }, { name: "UPLOADTHING_APP_ID", example: "a1b2c3d4e5" }],
208
- next: [{ name: "UPLOADTHING_TOKEN" }, { name: "UPLOADTHING_APP_ID", example: "a1b2c3d4e5" }]
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 (the app id is in the same place)",
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
- if (provider.install) steps.push(`Install its SDK: ${provider.install}`);
285
+ const readyLocally = options2.local !== false && vars.length > 0 && vars.every((variable) => variable.local);
284
286
  if (vars.length > 0 && provider.console) {
285
- steps.push(`Get ${vars.map((variable) => variable.name).join(", ")} from ${provider.where ?? provider.console}`);
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
- "@prisma/client": "7.10.0",
369
- "@prisma/adapter-pg": "7.10.0",
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: "7.10.0",
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 ALLOW_BUILDS = ["'@prisma/engines': false", "esbuild: true", "prisma: true", "sharp: false", "workerd: true"].map((line) => ` ${line}`).join("\n");
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", "--skip-generate", "--name", `${name}_rename`], appRoot);
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 (Cloudflare only)").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(
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(async (email, options2) => {
7874
- process.exitCode = await addUser(email, options2);
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.0",
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.0"
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 # needs: pnpm add 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.
@@ -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. Defaults to
26
- * `UPLOADTHING_APP_ID`.
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: string;
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
- const deliveryUrl = (key: string) => `https://${options.appId}.ufs.sh/f/${customIdFor(key)}`;
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":