@flaredev/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.js +385 -209
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/account/layout.tsx +30 -0
  4. package/templates/app/app/dashboard/account/page.tsx +11 -33
  5. package/templates/app/app/dashboard/account/password/page.tsx +22 -0
  6. package/templates/app/app/dashboard/account/security/page.tsx +34 -0
  7. package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
  8. package/templates/app/app/dashboard/actions.ts +35 -0
  9. package/templates/app/app/dashboard/layout.tsx +28 -4
  10. package/templates/app/app/dashboard/observability/page.tsx +4 -3
  11. package/templates/app/app/dashboard/page.tsx +2 -2
  12. package/templates/app/components/account/account-nav.tsx +39 -0
  13. package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
  14. package/templates/app/components/dashboard/column-menu.tsx +66 -0
  15. package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
  16. package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
  17. package/templates/app/components/dashboard/dropzone.tsx +92 -0
  18. package/templates/app/components/dashboard/editable.ts +12 -0
  19. package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
  20. package/templates/app/components/dashboard/import-dialog.tsx +60 -7
  21. package/templates/app/components/dashboard/inline-cell.tsx +165 -0
  22. package/templates/app/components/dashboard/local-time.tsx +77 -0
  23. package/templates/app/components/dashboard/query.ts +2 -0
  24. package/templates/app/components/dashboard/record-actions.tsx +113 -0
  25. package/templates/app/components/dashboard/record-detail.tsx +200 -0
  26. package/templates/app/components/dashboard/resource-chart.tsx +91 -0
  27. package/templates/app/components/dashboard/resource-stats.tsx +6 -11
  28. package/templates/app/components/dashboard/resource-table.tsx +405 -331
  29. package/templates/app/components/dashboard/row-actions.tsx +156 -144
  30. package/templates/app/components/dashboard/save-view-button.tsx +86 -0
  31. package/templates/app/components/dashboard/user-menu.tsx +91 -0
  32. package/templates/app/components/ui/sonner.tsx +33 -13
  33. package/templates/app/db/flare-schema.ts +22 -0
  34. package/templates/app/lib/api.ts +12 -0
  35. package/templates/app/lib/dashboard.ts +165 -138
  36. package/templates/app/lib/views.ts +29 -0
  37. package/templates/app/migrations/0000_init.sql +11 -0
  38. package/templates/app/migrations/meta/0000_snapshot.json +63 -1
  39. package/templates/app/vite.config.ts +41 -31
  40. package/templates/locks/package-lock.json +3 -3
  41. package/templates/locks/pnpm-lock.yaml +4 -4
  42. package/templates/locks/yarn.lock +3548 -0
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/index.ts
2
- import pc21 from "picocolors";
2
+ import pc23 from "picocolors";
3
3
 
4
4
  // src/cli.ts
5
5
  import { cac } from "cac";
6
+ import pc22 from "picocolors";
6
7
  import * as prompts3 from "@clack/prompts";
7
8
  import { FLARE_VERSION as FLARE_VERSION3 } from "@flaredev/core";
8
9
 
@@ -185,43 +186,6 @@ var APP_DEV_DEPENDENCIES = {
185
186
  wrangler: "^4.132.0"
186
187
  };
187
188
 
188
- // src/terminal.ts
189
- var isTTY = () => Boolean(process.stdout.isTTY);
190
- function formatDuration(ms) {
191
- const seconds = Math.round(ms / 1e3);
192
- if (seconds < 60) return `${seconds}s`;
193
- if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
194
- return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, "0")}m`;
195
- }
196
- var formatCount = (value) => value.toLocaleString();
197
- function formatRate(count, ms, unit = "rows") {
198
- if (ms <= 0) return `${formatCount(count)} ${unit}/s`;
199
- return `${formatCount(Math.round(count / ms * 1e3))} ${unit}/s`;
200
- }
201
- function progressLine(everyMs = 120) {
202
- if (!isTTY()) return { update: () => {
203
- }, done: () => {
204
- } };
205
- let last = 0;
206
- let width = 0;
207
- const write = (message) => {
208
- process.stdout.write(`\r${message.padEnd(width)}`);
209
- width = Math.max(width, message.length);
210
- };
211
- return {
212
- update(message) {
213
- const now = Date.now();
214
- if (now - last < everyMs) return;
215
- last = now;
216
- write(message);
217
- },
218
- done() {
219
- if (width > 0) process.stdout.write(`\r${" ".repeat(width)}\r`);
220
- width = 0;
221
- }
222
- };
223
- }
224
-
225
189
  // src/utils/fs.ts
226
190
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
227
191
  import { dirname, join, resolve } from "path";
@@ -281,11 +245,6 @@ function childEnv(env = process.env) {
281
245
  }
282
246
  return next;
283
247
  }
284
- function runQuiet(command, args, cwd) {
285
- const env = childEnv();
286
- const result = process.platform === "win32" ? spawnSync([command, ...args].join(" "), { cwd, env, shell: true, encoding: "utf8" }) : spawnSync(command, args, { cwd, env, encoding: "utf8" });
287
- return { code: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
288
- }
289
248
  function installArgs(pm) {
290
249
  if (pm === "npm") return ["install", "--no-audit", "--no-fund", "--loglevel=error"];
291
250
  return ["install"];
@@ -361,16 +320,10 @@ ${devVarsEntries(authProviders)}`
361
320
  if (pinned) copyLockfile(packageManager, dir);
362
321
  const log2 = options2.log ?? ((message) => console.log(message));
363
322
  log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
364
- if (options2.install !== false) {
365
- const started = Date.now();
366
- const code = (options2.installer ?? run)(packageManager, installArgs(packageManager), dir);
367
- if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
368
- if (!options2.installer) log2(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
369
- }
370
323
  return { dir, name, packageManager, inWorkspace };
371
324
  }
372
325
  function copyLockfile(packageManager, dir) {
373
- const file = packageManager === "npm" ? "package-lock.json" : packageManager === "pnpm" ? "pnpm-lock.yaml" : void 0;
326
+ const file = packageManager === "pnpm" ? "pnpm-lock.yaml" : packageManager === "yarn" ? "yarn.lock" : "package-lock.json";
374
327
  if (!file) return false;
375
328
  const source = join2(templatesDir, "locks", file);
376
329
  if (!existsSync2(source)) return false;
@@ -685,15 +638,21 @@ function renderFieldsBlock(fields) {
685
638
  return fields.map((f) => `${renderField(f)}
686
639
  `).join("");
687
640
  }
688
- function renderDescriptor(name, fields) {
641
+ function renderDescriptor(name, fields, options2 = {}) {
689
642
  defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
690
643
  const block = renderFieldsBlock(fields);
644
+ const heading = [
645
+ options2.icon ? ` icon: ${literal(options2.icon)},
646
+ ` : "",
647
+ options2.group ? ` group: ${literal(options2.group)},
648
+ ` : ""
649
+ ].join("");
691
650
  return joinMarkers({
692
651
  before: `import { defineResource, field } from "@flaredev/core";
693
652
 
694
653
  export default defineResource({
695
654
  name: ${literal(name)},
696
- fields: {
655
+ ${heading} fields: {
697
656
  `,
698
657
  block,
699
658
  after: ` },
@@ -2007,13 +1966,24 @@ function renderTableModule(entry, all) {
2007
1966
  const name = tableExport(resource);
2008
1967
  const columns = fields.map(([key, def]) => ` ${key}: ${columnExpression(key, def, resources, resource.name)},`);
2009
1968
  const extras = [];
1969
+ const indexed = /* @__PURE__ */ new Set();
1970
+ const addIndex = (key, column) => {
1971
+ if (indexed.has(key)) return;
1972
+ indexed.add(key);
1973
+ extras.push(` index(${q(`${resource.table}_${columnName(column)}_idx`)}).on(table.${key}),`);
1974
+ };
2010
1975
  for (const [key, def] of fields) {
2011
1976
  if (def.kind === "enum") {
2012
1977
  const options2 = def.options.map(sqlString).join(", ");
2013
1978
  extras.push(` check(${q(`${resource.table}_${columnName(key)}_check`)}, sql\`\${table.${key}} in (${options2})\`),`);
1979
+ if (def.filterable !== false) addIndex(key, key);
2014
1980
  }
2015
- if (def.kind === "belongsTo") extras.push(` index(${q(`${resource.table}_${columnName(key)}_idx`)}).on(table.${key}),`);
1981
+ if (def.kind === "belongsTo") addIndex(key, key);
2016
1982
  }
1983
+ const sortKey = resource.defaultSort.field;
1984
+ if (sortKey === "createdAt" || sortKey === "updatedAt") addIndex(sortKey, sortKey === "createdAt" ? "created_at" : "updated_at");
1985
+ else if (fields.some(([key]) => key === sortKey)) addIndex(sortKey, sortKey);
1986
+ addIndex("createdAt", "created_at");
2017
1987
  const targets = [...new Set(fields.flatMap(([, def]) => def.kind === "belongsTo" ? [resources.get(def.target)] : []))].filter((target) => target.name !== resource.name).sort((a, b) => a.table.localeCompare(b.table));
2018
1988
  const coreImports = ["integer", "sqliteTable", "text"];
2019
1989
  if (fields.some(([, def]) => def.kind === "float")) coreImports.push("real");
@@ -2089,7 +2059,7 @@ var handlerImports = (entry) => [
2089
2059
  `import { createResourceHandlers } from "@flaredev/core/server";`,
2090
2060
  `import { getDb } from "@/db";`,
2091
2061
  `import { ${tableExport(entry.resource)} } from "@/db/schema";`,
2092
- `import { authorize } from "@/lib/api";`,
2062
+ `import { authorize, currentUser } from "@/lib/api";`,
2093
2063
  `import { revalidateResource } from "@/lib/cache";`,
2094
2064
  `import ${resourceLocal(entry.stem)} from "@/resources/${entry.stem}.resource";`,
2095
2065
  "",
@@ -2098,6 +2068,7 @@ var handlerImports = (entry) => [
2098
2068
  ` table: ${tableExport(entry.resource)},`,
2099
2069
  ` getDb,`,
2100
2070
  ` authorize,`,
2071
+ ` currentUser,`,
2101
2072
  ` onChange: revalidateResource,`,
2102
2073
  `});`,
2103
2074
  ""
@@ -2124,6 +2095,7 @@ function renderAdminListPage(entry) {
2124
2095
  const local = resourceLocal(entry.stem);
2125
2096
  return [
2126
2097
  `import { PageHeader } from "@/components/dashboard/page-header";`,
2098
+ `import { ResourceChart } from "@/components/dashboard/resource-chart";`,
2127
2099
  `import { ResourceStats } from "@/components/dashboard/resource-stats";`,
2128
2100
  `import { ResourceTable } from "@/components/dashboard/resource-table";`,
2129
2101
  `import type { SearchParams } from "@/components/dashboard/query";`,
@@ -2139,6 +2111,7 @@ function renderAdminListPage(entry) {
2139
2111
  ` crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: ${local}.pluralLabel }]}`,
2140
2112
  " />",
2141
2113
  ` <ResourceStats resource={${local}} />`,
2114
+ ` <ResourceChart resource={${local}} />`,
2142
2115
  ` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
2143
2116
  " </>",
2144
2117
  " );",
@@ -2146,6 +2119,17 @@ function renderAdminListPage(entry) {
2146
2119
  ""
2147
2120
  ].join("\n");
2148
2121
  }
2122
+ function renderAdminDetailPage(entry) {
2123
+ return [
2124
+ `import { RecordDetail } from "@/components/dashboard/record-detail";`,
2125
+ ...adminImports(entry),
2126
+ "",
2127
+ `export default async function ${entry.resource.name}DetailPage({ params }: { params: Promise<{ id: string }> }) {`,
2128
+ ` return <RecordDetail resource={${resourceLocal(entry.stem)}} id={(await params).id} />;`,
2129
+ "}",
2130
+ ""
2131
+ ].join("\n");
2132
+ }
2149
2133
  function renderAdminNewPage(entry) {
2150
2134
  return [
2151
2135
  `import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
@@ -2228,6 +2212,7 @@ function resourceFiles(entry, all) {
2228
2212
  { path: `resources/${stem}.client.ts`, content: renderClient(entry) },
2229
2213
  { path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
2230
2214
  { path: `app/dashboard/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
2215
+ { path: `app/dashboard/${resource.slug}/[id]/page.tsx`, content: renderAdminDetailPage(entry) },
2231
2216
  { path: `app/dashboard/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
2232
2217
  { path: `app/dashboard/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
2233
2218
  ];
@@ -2290,7 +2275,7 @@ function findOrphans(appRoot, all) {
2290
2275
  for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync11(join10(appRoot, file))) candidates.push(file);
2291
2276
  }
2292
2277
  for (const dir of list2("app/dashboard", () => true)) {
2293
- for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
2278
+ for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
2294
2279
  if (existsSync11(join10(appRoot, file))) candidates.push(file);
2295
2280
  }
2296
2281
  }
@@ -3168,7 +3153,7 @@ async function genResource(rawName, options2) {
3168
3153
  validateFields(name, fields, others, log2);
3169
3154
  if (!exists) {
3170
3155
  mkdirSync7(dirname8(path), { recursive: true });
3171
- writeFileSync9(path, renderDescriptor(name, fields));
3156
+ writeFileSync9(path, renderDescriptor(name, fields, { group: options2.group, icon: options2.icon }));
3172
3157
  log2(`${pc10.green("create".padEnd(9))} ${relativePath}`);
3173
3158
  } else {
3174
3159
  try {
@@ -3216,11 +3201,74 @@ Next: ${pc10.bold("flare migrate")} to apply it locally.`);
3216
3201
  return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
3217
3202
  }
3218
3203
 
3219
- // src/commands/gen-migration.ts
3204
+ // src/commands/gen-endpoint.ts
3220
3205
  import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
3221
- import { basename as basename2, join as join15 } from "path";
3222
- import { snakeCase } from "@flaredev/core";
3206
+ import { join as join15 } from "path";
3207
+ import { kebabCase as kebabCase3, pascalCase as pascalCase2 } from "@flaredev/core";
3223
3208
  import pc11 from "picocolors";
3209
+ var METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE"];
3210
+ var ACTIONS = ["read", "create", "update", "delete"];
3211
+ async function genEndpoint(resourceName2, rawName, options2 = {}) {
3212
+ const log2 = options2.log ?? ((message) => console.log(message));
3213
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3214
+ const method = (options2.method ?? "GET").toUpperCase();
3215
+ if (!METHODS.includes(method)) throw new Error(`Unknown method "${method}". Use one of ${METHODS.join(", ")}.`);
3216
+ const name = kebabCase3(rawName);
3217
+ if (!name) throw new Error(`Invalid endpoint name "${rawName}".`);
3218
+ const resources = await loadResources(appRoot);
3219
+ const wanted = pascalCase2(resourceName2);
3220
+ const entry = resources.find(({ resource: resource2 }) => resource2.name === wanted);
3221
+ if (!entry) {
3222
+ throw new Error(`No resource "${resourceName2}". Available: ${resources.map(({ resource: resource2 }) => resource2.name).join(", ") || "none"}.`);
3223
+ }
3224
+ const action = options2.action ?? (method === "GET" ? "read" : "update");
3225
+ if (!ACTIONS.includes(action)) throw new Error(`Unknown action "${action}". Use one of ${ACTIONS.join(", ")}.`);
3226
+ const { resource, stem } = entry;
3227
+ const relative2 = options2.record ? `app/api/${resource.slug}/[id]/${name}/route.ts` : `app/api/${resource.slug}/${name}/route.ts`;
3228
+ const path = join15(appRoot, relative2);
3229
+ if (existsSync16(path)) throw new Error(`${relative2} already exists.`);
3230
+ mkdirSync8(join15(path, ".."), { recursive: true });
3231
+ const route = options2.record ? `/api/${resource.slug}/[id]/${name}` : `/api/${resource.slug}/${name}`;
3232
+ writeFileSync10(path, renderEndpoint({ resource: resource.name, stem, method, action, record: Boolean(options2.record), name, route }));
3233
+ log2(`${pc11.green("create".padEnd(9))} ${relative2}`);
3234
+ log2(`
3235
+ It's an ordinary route handler \u2014 edit it freely; regeneration leaves it alone.`);
3236
+ return relative2;
3237
+ }
3238
+ function renderEndpoint({ resource, stem, method, action, record, route }) {
3239
+ const body = method === "GET" || method === "DELETE" ? "" : " const body = (await request.json()) as Record<string, unknown>;\n";
3240
+ const params = record ? ", { params }: { params: Promise<{ id: string }> }" : "";
3241
+ const id = record ? " const { id } = await params;\n" : "";
3242
+ return `import { authorize, currentUser } from "@/lib/api";
3243
+ import { dashboardStore } from "@/lib/dashboard";
3244
+ import ${stem.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())}Resource from "@/resources/${stem}.resource";
3245
+
3246
+ /**
3247
+ * ${method} ${route}
3248
+ *
3249
+ * Yours to change. The session and the policy are checked the same way the generated
3250
+ * CRUD routes check them, and \`store\` is the same store they use \u2014 so hooks, validation
3251
+ * and cache invalidation all still apply.
3252
+ */
3253
+ export async function ${method}(request: Request${params}) {
3254
+ ${id} const denied = await authorize({ request, resource: ${stem.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase())}Resource, action: "${action}" });
3255
+ if (denied) return denied;
3256
+
3257
+ const user = await currentUser();
3258
+ const store = dashboardStore("${resource}");
3259
+ ${body}
3260
+ // Your logic here. \`store.list\`, \`store.get\`, \`store.create\`, \`store.update\` and
3261
+ // \`store.delete\` are available, or reach for Drizzle directly with \`getDb()\`.
3262
+ return Response.json({ ok: true, ${record ? "id, " : ""}${method === "GET" || method === "DELETE" ? "" : "received: body, "}user: user?.email ?? null });
3263
+ }
3264
+ `;
3265
+ }
3266
+
3267
+ // src/commands/gen-migration.ts
3268
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
3269
+ import { basename as basename2, join as join16 } from "path";
3270
+ import { snakeCase } from "@flaredev/core";
3271
+ import pc12 from "picocolors";
3224
3272
  var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
3225
3273
  -- Write SQL that undoes the up migration, e.g.:
3226
3274
  -- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
@@ -3236,31 +3284,31 @@ async function genMigration(rawName, options2 = {}) {
3236
3284
  log2(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
3237
3285
  return [];
3238
3286
  }
3239
- for (const file of files) log2(`${pc11.green("create".padEnd(9))} ${file}`);
3240
- for (const repair of repairs) log2(pc11.dim(`repaired ${repair}`));
3287
+ for (const file of files) log2(`${pc12.green("create".padEnd(9))} ${file}`);
3288
+ for (const repair of repairs) log2(pc12.dim(`repaired ${repair}`));
3241
3289
  if (!options2.fromSchema) {
3242
- const downDir = join15(appRoot, "migrations", "down");
3243
- mkdirSync8(downDir, { recursive: true });
3290
+ const downDir = join16(appRoot, "migrations", "down");
3291
+ mkdirSync9(downDir, { recursive: true });
3244
3292
  for (const file of files) {
3245
- const down = join15(downDir, basename2(file));
3246
- if (existsSync16(down)) continue;
3247
- writeFileSync10(down, DOWN_TEMPLATE(basename2(file)));
3248
- log2(`${pc11.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
3293
+ const down = join16(downDir, basename2(file));
3294
+ if (existsSync17(down)) continue;
3295
+ writeFileSync11(down, DOWN_TEMPLATE(basename2(file)));
3296
+ log2(`${pc12.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
3249
3297
  }
3250
3298
  log2(`
3251
- Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc11.bold("flare migrate")}.`);
3299
+ Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc12.bold("flare migrate")}.`);
3252
3300
  } else {
3253
3301
  log2(`
3254
- Next: ${pc11.bold("flare migrate")}.`);
3302
+ Next: ${pc12.bold("flare migrate")}.`);
3255
3303
  }
3256
3304
  return files;
3257
3305
  }
3258
3306
 
3259
3307
  // src/commands/gen-policy.ts
3260
- import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
3261
- import { dirname as dirname9, join as join16 } from "path";
3308
+ import { existsSync as existsSync18, mkdirSync as mkdirSync10, writeFileSync as writeFileSync12 } from "fs";
3309
+ import { dirname as dirname9, join as join17 } from "path";
3262
3310
  import { definePolicy } from "@flaredev/core";
3263
- import pc12 from "picocolors";
3311
+ import pc13 from "picocolors";
3264
3312
  function parseRoles(input, what = "--roles") {
3265
3313
  const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
3266
3314
  if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
@@ -3276,8 +3324,8 @@ async function genPolicy(rawName, options2) {
3276
3324
  throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
3277
3325
  }
3278
3326
  const relative2 = policyPath(name);
3279
- const path = join16(appRoot, relative2);
3280
- const exists = existsSync17(path);
3327
+ const path = join17(appRoot, relative2);
3328
+ const exists = existsSync18(path);
3281
3329
  let roles;
3282
3330
  let deleteRoles;
3283
3331
  if (options2.roles || !exists) {
@@ -3288,13 +3336,13 @@ async function genPolicy(rawName, options2) {
3288
3336
  if (roles && deleteRoles) {
3289
3337
  const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
3290
3338
  if (!exists) {
3291
- mkdirSync9(dirname9(path), { recursive: true });
3292
- writeFileSync11(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
3293
- log2(`${pc12.green("create".padEnd(9))} ${relative2}`);
3339
+ mkdirSync10(dirname9(path), { recursive: true });
3340
+ writeFileSync12(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
3341
+ log2(`${pc13.green("create".padEnd(9))} ${relative2}`);
3294
3342
  } else {
3295
3343
  try {
3296
3344
  const outcome = writeGenerated(path, block, { force: options2.force });
3297
- log2(`${(outcome === "identical" ? pc12.dim : pc12.yellow)(outcome.padEnd(9))} ${relative2}`);
3345
+ log2(`${(outcome === "identical" ? pc13.dim : pc13.yellow)(outcome.padEnd(9))} ${relative2}`);
3298
3346
  } catch (error) {
3299
3347
  if (error instanceof DriftError) {
3300
3348
  throw new Error(`The roles in ${relative2} were edited by hand, so --roles won't overwrite them. Edit the file directly, or pass --force.`);
@@ -3306,15 +3354,15 @@ async function genPolicy(rawName, options2) {
3306
3354
  const policies = await loadPolicies(appRoot);
3307
3355
  logResults(applyPlan(appRoot, planFiles(resources, policies)), log2);
3308
3356
  log2(`
3309
- ${pc12.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
3357
+ ${pc13.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
3310
3358
  return { name, path: relative2 };
3311
3359
  }
3312
3360
 
3313
3361
  // src/commands/migrate.ts
3314
- import { existsSync as existsSync18, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
3362
+ import { existsSync as existsSync19, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync13 } from "fs";
3315
3363
  import { tmpdir } from "os";
3316
- import { join as join17 } from "path";
3317
- import pc13 from "picocolors";
3364
+ import { join as join18 } from "path";
3365
+ import pc14 from "picocolors";
3318
3366
 
3319
3367
  // src/generator/down.ts
3320
3368
  var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
@@ -3431,7 +3479,7 @@ async function rollback(options2 = {}) {
3431
3479
  const steps = options2.steps ?? 1;
3432
3480
  if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
3433
3481
  const { appRoot, db, target, where, wrangler, log: log2 } = context(options2);
3434
- const migrationsDir = join17(appRoot, db.migrations_dir ?? "migrations");
3482
+ const migrationsDir = join18(appRoot, db.migrations_dir ?? "migrations");
3435
3483
  const applied = await appliedMigrations(options2, steps);
3436
3484
  if (applied.length === 0) {
3437
3485
  log2("Nothing to roll back.");
@@ -3440,8 +3488,8 @@ async function rollback(options2 = {}) {
3440
3488
  const plan = [];
3441
3489
  for (const { name } of applied) {
3442
3490
  if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
3443
- const handWritten = join17(migrationsDir, "down", name);
3444
- if (existsSync18(handWritten)) {
3491
+ const handWritten = join18(migrationsDir, "down", name);
3492
+ if (existsSync19(handWritten)) {
3445
3493
  const sql2 = readFileSync11(handWritten, "utf8");
3446
3494
  if (splitStatements(sql2).length === 0) {
3447
3495
  throw new Error(`Can't roll back ${name}: migrations/down/${name} has no SQL statements yet. Write the rollback SQL, then run the rollback again. Nothing was changed.`);
@@ -3449,8 +3497,8 @@ async function rollback(options2 = {}) {
3449
3497
  plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
3450
3498
  continue;
3451
3499
  }
3452
- const upPath = join17(migrationsDir, name);
3453
- if (!existsSync18(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
3500
+ const upPath = join18(migrationsDir, name);
3501
+ if (!existsSync19(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
3454
3502
  const down = deriveDown(readFileSync11(upPath, "utf8"));
3455
3503
  if (!down.reversible) {
3456
3504
  throw new Error(
@@ -3460,37 +3508,37 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
3460
3508
  }
3461
3509
  plan.push({ name, sql: down.sql, source: "derived" });
3462
3510
  }
3463
- log2(`${pc13.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3511
+ log2(`${pc14.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3464
3512
  for (const step of plan) {
3465
- log2(` ${step.name} ${pc13.dim(`(${step.source})`)}`);
3466
- for (const line of step.sql.trim().split("\n")) log2(pc13.dim(` ${line}`));
3513
+ log2(` ${step.name} ${pc14.dim(`(${step.source})`)}`);
3514
+ for (const line of step.sql.trim().split("\n")) log2(pc14.dim(` ${line}`));
3467
3515
  }
3468
3516
  if (options2.remote && !options2.yes) {
3469
- log2(pc13.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3517
+ log2(pc14.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3470
3518
  return 1;
3471
3519
  }
3472
3520
  const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
3473
3521
  DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
3474
- const dir = mkdtempSync(join17(tmpdir(), "flare-rollback-"));
3522
+ const dir = mkdtempSync(join18(tmpdir(), "flare-rollback-"));
3475
3523
  try {
3476
- const file = join17(dir, "rollback.sql");
3477
- writeFileSync12(file, script);
3524
+ const file = join18(dir, "rollback.sql");
3525
+ writeFileSync13(file, script);
3478
3526
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
3479
3527
  if (result.code !== 0) throw new Error(`Rollback failed:
3480
3528
  ${result.output.trim()}`);
3481
3529
  } finally {
3482
3530
  rmSync2(dir, { recursive: true, force: true });
3483
3531
  }
3484
- log2(pc13.green(`
3532
+ log2(pc14.green(`
3485
3533
  Rolled back ${plan.map((step) => step.name).join(", ")}.`));
3486
3534
  return 0;
3487
3535
  }
3488
3536
 
3489
3537
  // src/commands/rm.ts
3490
- import { existsSync as existsSync19, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
3491
- import { dirname as dirname10, join as join18 } from "path";
3538
+ import { existsSync as existsSync20, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
3539
+ import { dirname as dirname10, join as join19 } from "path";
3492
3540
  import { relationGraph as relationGraph3 } from "@flaredev/core";
3493
- import pc14 from "picocolors";
3541
+ import pc15 from "picocolors";
3494
3542
  var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
3495
3543
  function handWrittenReason(source, header, options2 = {}) {
3496
3544
  const parts = splitMarkers(source);
@@ -3507,10 +3555,10 @@ function handWrittenReason(source, header, options2 = {}) {
3507
3555
  return null;
3508
3556
  }
3509
3557
  function removeEmptyDirs(appRoot, start) {
3510
- let dir = dirname10(join18(appRoot, start));
3511
- const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "dashboard")];
3558
+ let dir = dirname10(join19(appRoot, start));
3559
+ const roots = [join19(appRoot, "app", "api"), join19(appRoot, "app", "dashboard")];
3512
3560
  const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
3513
- while (inside() && existsSync19(dir) && readdirSync7(dir).length === 0) {
3561
+ while (inside() && existsSync20(dir) && readdirSync7(dir).length === 0) {
3514
3562
  rmdirSync(dir);
3515
3563
  dir = dirname10(dir);
3516
3564
  }
@@ -3539,7 +3587,7 @@ async function rmResource(rawName, options2 = {}) {
3539
3587
  if (entry) {
3540
3588
  owned.add(entry.file);
3541
3589
  for (const file of planFiles(all)) {
3542
- if (existsSync19(join18(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3590
+ if (existsSync20(join19(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3543
3591
  }
3544
3592
  }
3545
3593
  for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
@@ -3548,7 +3596,7 @@ async function rmResource(rawName, options2 = {}) {
3548
3596
  if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
3549
3597
  const findings = [];
3550
3598
  for (const path of owned) {
3551
- const source = readFileSync12(join18(appRoot, path), "utf8");
3599
+ const source = readFileSync12(join19(appRoot, path), "utf8");
3552
3600
  let wrapper;
3553
3601
  if (entry && path === entry.file) {
3554
3602
  const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
@@ -3569,34 +3617,34 @@ Move what you want to keep, or pass --force to delete anyway.`);
3569
3617
  }
3570
3618
  const removed = [...owned].sort();
3571
3619
  for (const path of removed) {
3572
- unlinkSync2(join18(appRoot, path));
3620
+ unlinkSync2(join19(appRoot, path));
3573
3621
  removeEmptyDirs(appRoot, path);
3574
- log2(`${pc14.red("remove".padEnd(9))} ${path}`);
3622
+ log2(`${pc15.red("remove".padEnd(9))} ${path}`);
3575
3623
  }
3576
3624
  logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log2);
3577
3625
  const table = entry ? tableExport(entry.resource) : void 0;
3578
- if (table && existsSync19(join18(appRoot, "seeds"))) {
3579
- const seeds = readdirSync7(join18(appRoot, "seeds")).filter(
3580
- (file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join18(appRoot, "seeds", file), "utf8"))
3626
+ if (table && existsSync20(join19(appRoot, "seeds"))) {
3627
+ const seeds = readdirSync7(join19(appRoot, "seeds")).filter(
3628
+ (file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join19(appRoot, "seeds", file), "utf8"))
3581
3629
  );
3582
- if (seeds.length) log2(pc14.yellow(`
3630
+ if (seeds.length) log2(pc15.yellow(`
3583
3631
  These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
3584
3632
  }
3585
3633
  let migrations = [];
3586
3634
  if (!options2.skipMigration && entry) {
3587
3635
  const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
3588
3636
  migrations = result.files;
3589
- for (const file of migrations) log2(`${pc14.green("create".padEnd(9))} ${file}`);
3590
- for (const repair of result.repairs) log2(pc14.dim(`repaired ${repair}`));
3637
+ for (const file of migrations) log2(`${pc15.green("create".padEnd(9))} ${file}`);
3638
+ for (const repair of result.repairs) log2(pc15.dim(`repaired ${repair}`));
3591
3639
  if (migrations.length) log2(`
3592
- Next: ${pc14.bold("flare migrate")} to drop the table locally.`);
3640
+ Next: ${pc15.bold("flare migrate")} to drop the table locally.`);
3593
3641
  }
3594
3642
  return { removed, migrations };
3595
3643
  }
3596
3644
 
3597
3645
  // src/commands/role.ts
3598
3646
  import { humanize } from "@flaredev/core";
3599
- import pc15 from "picocolors";
3647
+ import pc16 from "picocolors";
3600
3648
  var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
3601
3649
  function roleInsertSql(name, label) {
3602
3650
  if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
@@ -3641,17 +3689,17 @@ ${result.output.trim()}`);
3641
3689
  }
3642
3690
  const [first] = extractJson(result.output);
3643
3691
  const added = first?.results?.[0];
3644
- log2(pc15.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
3645
- log2(pc15.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
3692
+ log2(pc16.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
3693
+ log2(pc16.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
3646
3694
  return 0;
3647
3695
  }
3648
3696
 
3649
3697
  // src/commands/seed.ts
3650
- import { existsSync as existsSync20, mkdirSync as mkdirSync10, readdirSync as readdirSync8, writeFileSync as writeFileSync13 } from "fs";
3651
- import { join as join20 } from "path";
3652
- import { createFake, kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
3698
+ import { existsSync as existsSync21, mkdirSync as mkdirSync11, readdirSync as readdirSync8, writeFileSync as writeFileSync14 } from "fs";
3699
+ import { join as join21 } from "path";
3700
+ import { createFake, kebabCase as kebabCase4, storedFields as storedFields2 } from "@flaredev/core";
3653
3701
  import { createJiti as createJiti4 } from "jiti";
3654
- import pc16 from "picocolors";
3702
+ import pc17 from "picocolors";
3655
3703
 
3656
3704
  // src/seed/bulk.ts
3657
3705
  var MAX_STATEMENT_BYTES = 8e4;
@@ -3746,16 +3794,16 @@ function createInsertMany(sink, drizzle, onProgress) {
3746
3794
 
3747
3795
  // src/seed/local-d1.ts
3748
3796
  import { createRequire } from "module";
3749
- import { join as join19 } from "path";
3797
+ import { join as join20 } from "path";
3750
3798
  import { pathToFileURL } from "url";
3751
3799
  var LOCAL_STATE = ".wrangler/state/v3";
3752
3800
  async function openLocalD1(appRoot, binding) {
3753
3801
  process.env.WRANGLER_LOG ??= "error";
3754
- const appRequire = createRequire(join19(appRoot, "package.json"));
3802
+ const appRequire = createRequire(join20(appRoot, "package.json"));
3755
3803
  const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
3756
3804
  const { env, dispose } = await wrangler.getPlatformProxy({
3757
- configPath: join19(appRoot, "wrangler.jsonc"),
3758
- persist: { path: join19(appRoot, LOCAL_STATE) }
3805
+ configPath: join20(appRoot, "wrangler.jsonc"),
3806
+ persist: { path: join20(appRoot, LOCAL_STATE) }
3759
3807
  });
3760
3808
  const bindings = env;
3761
3809
  const name = binding ?? "DB";
@@ -3770,8 +3818,8 @@ async function openLocalD1(appRoot, binding) {
3770
3818
  // src/commands/seed.ts
3771
3819
  var SEEDS_DIR = "seeds";
3772
3820
  function seedFiles(appRoot, names) {
3773
- const dir = join20(appRoot, SEEDS_DIR);
3774
- const all = existsSync20(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3821
+ const dir = join21(appRoot, SEEDS_DIR);
3822
+ const all = existsSync21(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3775
3823
  if (!names?.length) return all;
3776
3824
  return names.map((name) => {
3777
3825
  const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
@@ -3786,24 +3834,24 @@ async function runSeeds(options2 = {}) {
3786
3834
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3787
3835
  const files = seedFiles(appRoot, options2.names);
3788
3836
  if (files.length === 0) {
3789
- log2(`No seeds found. Create one with ${pc16.bold("flare seed:make <name>")}.`);
3837
+ log2(`No seeds found. Create one with ${pc17.bold("flare seed:make <name>")}.`);
3790
3838
  return;
3791
3839
  }
3792
- const jiti = createJiti4(join20(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3840
+ const jiti = createJiti4(join21(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3793
3841
  const { drizzle } = await jiti.import("drizzle-orm/d1");
3794
3842
  const drizzleHelpers = await jiti.import("drizzle-orm");
3795
- const schema = await jiti.import(join20(appRoot, "db/schema.ts"));
3843
+ const schema = await jiti.import(join21(appRoot, "db/schema.ts"));
3796
3844
  const local = await openLocalD1(appRoot);
3797
3845
  try {
3798
3846
  const db = drizzle(local.db, { schema });
3799
3847
  const insertMany = createInsertMany(local.db, drizzleHelpers);
3800
3848
  for (const file of files) {
3801
- const mod = await jiti.import(join20(appRoot, SEEDS_DIR, file));
3849
+ const mod = await jiti.import(join21(appRoot, SEEDS_DIR, file));
3802
3850
  if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
3803
- log2(`${pc16.cyan("seed")} ${file}`);
3851
+ log2(`${pc17.cyan("seed")} ${file}`);
3804
3852
  await mod.default({ db, env: local.env, insertMany, fake: createFake(), log: (message) => log2(` ${message}`) });
3805
3853
  }
3806
- log2(pc16.green(`
3854
+ log2(pc17.green(`
3807
3855
  Ran ${files.length} seed(s) against the local database.`));
3808
3856
  } finally {
3809
3857
  await local.dispose();
@@ -3885,32 +3933,32 @@ ${[...lines, ...todos].join("\n")}
3885
3933
  async function makeSeed(rawName, options2 = {}) {
3886
3934
  const log2 = options2.log ?? ((message) => console.log(message));
3887
3935
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3888
- const name = kebabCase3(rawName);
3936
+ const name = kebabCase4(rawName);
3889
3937
  if (!name) throw new Error(`Invalid seed name "${rawName}".`);
3890
3938
  const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
3891
- const path = join20(appRoot, relative2);
3892
- if (existsSync20(path)) throw new Error(`${relative2} already exists.`);
3939
+ const path = join21(appRoot, relative2);
3940
+ if (existsSync21(path)) throw new Error(`${relative2} already exists.`);
3893
3941
  const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
3894
3942
  let resource;
3895
3943
  if (options2.resource) {
3896
3944
  resource = resources.find((r) => r.name.toLowerCase() === options2.resource.toLowerCase());
3897
3945
  if (!resource) throw new Error(`No resource "${options2.resource}". Available: ${resources.map((r) => r.name).join(", ") || "none"}.`);
3898
3946
  } else {
3899
- resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
3947
+ resource = resources.find((r) => [kebabCase4(r.name), r.slug].includes(name));
3900
3948
  }
3901
- mkdirSync10(join20(appRoot, SEEDS_DIR), { recursive: true });
3902
- writeFileSync13(path, renderSeed(resource));
3903
- log2(`${pc16.green("create".padEnd(9))} ${relative2}`);
3949
+ mkdirSync11(join21(appRoot, SEEDS_DIR), { recursive: true });
3950
+ writeFileSync14(path, renderSeed(resource));
3951
+ log2(`${pc17.green("create".padEnd(9))} ${relative2}`);
3904
3952
  log2(`
3905
- Run it with ${pc16.bold(`flare seed ${name}`)}.`);
3953
+ Run it with ${pc17.bold(`flare seed ${name}`)}.`);
3906
3954
  return relative2;
3907
3955
  }
3908
3956
 
3909
3957
  // src/commands/seed-resource.ts
3910
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync14 } from "fs";
3958
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync15 } from "fs";
3911
3959
  import { tmpdir as tmpdir2 } from "os";
3912
- import { join as join21 } from "path";
3913
- import pc17 from "picocolors";
3960
+ import { join as join22 } from "path";
3961
+ import pc18 from "picocolors";
3914
3962
 
3915
3963
  // src/seed/rows.ts
3916
3964
  import { columnName as columnName2, createFake as createFake2, storedFields as storedFields3 } from "@flaredev/core";
@@ -4018,11 +4066,13 @@ function* seedRows(resource, count, options2 = {}) {
4018
4066
  const parentIds = options2.parentIds ?? /* @__PURE__ */ new Map();
4019
4067
  const fields = storedFields3(resource);
4020
4068
  const now = options2.now ?? Date.now();
4069
+ const start = options2.startIndex ?? 0;
4021
4070
  for (let i = 0; i < count; i++) {
4071
+ const number = start + i;
4022
4072
  const row = { id: fake.id() };
4023
4073
  for (const [key, def] of fields) {
4024
- let value = fieldValue(key, def, fake, resource, parentIds, i);
4025
- if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value, i + 1);
4074
+ let value = fieldValue(key, def, fake, resource, parentIds, number);
4075
+ if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value, number + 1);
4026
4076
  row[columnName2(key)] = value;
4027
4077
  }
4028
4078
  const created = now - fake.int(0, 365) * 864e5 - fake.int(0, 86399999);
@@ -4032,7 +4082,48 @@ function* seedRows(resource, count, options2 = {}) {
4032
4082
  }
4033
4083
  }
4034
4084
 
4085
+ // src/terminal.ts
4086
+ var isTTY = () => Boolean(process.stdout.isTTY);
4087
+ function formatDuration(ms) {
4088
+ const seconds = Math.round(ms / 1e3);
4089
+ if (seconds < 60) return `${seconds}s`;
4090
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
4091
+ return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, "0")}m`;
4092
+ }
4093
+ var formatCount = (value) => value.toLocaleString();
4094
+ function formatRate(count, ms, unit = "rows") {
4095
+ if (ms <= 0) return `${formatCount(count)} ${unit}/s`;
4096
+ return `${formatCount(Math.round(count / ms * 1e3))} ${unit}/s`;
4097
+ }
4098
+ function progressLine(everyMs = 120) {
4099
+ if (!isTTY()) return { update: () => {
4100
+ }, done: () => {
4101
+ } };
4102
+ let last = 0;
4103
+ let width = 0;
4104
+ const write = (message) => {
4105
+ process.stdout.write(`\r${message.padEnd(width)}`);
4106
+ width = Math.max(width, message.length);
4107
+ };
4108
+ return {
4109
+ update(message) {
4110
+ const now = Date.now();
4111
+ if (now - last < everyMs) return;
4112
+ last = now;
4113
+ write(message);
4114
+ },
4115
+ done() {
4116
+ if (width > 0) process.stdout.write(`\r${" ".repeat(width)}\r`);
4117
+ width = 0;
4118
+ }
4119
+ };
4120
+ }
4121
+
4035
4122
  // src/commands/seed-resource.ts
4123
+ async function countRows(db, table) {
4124
+ const row = await db.prepare(`SELECT count(*) AS total FROM "${table}"`).first();
4125
+ return row?.total ?? 0;
4126
+ }
4036
4127
  var PARENT_SAMPLE = 5e3;
4037
4128
  function parseCount(value, fallback = 25) {
4038
4129
  if (value === void 0 || value === "") return fallback;
@@ -4081,13 +4172,13 @@ async function seedResourceCommand(name, options2 = {}) {
4081
4172
  ...options2,
4082
4173
  onProgress: (written, total) => {
4083
4174
  const elapsed = Date.now() - started;
4084
- progress.update(` ${pc17.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc17.dim(formatRate(written, elapsed))}`);
4175
+ progress.update(` ${pc18.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc18.dim(formatRate(written, elapsed))}`);
4085
4176
  }
4086
4177
  });
4087
4178
  progress.done();
4088
4179
  const { rows, ms, where } = result;
4089
4180
  console.log(
4090
- `${pc17.green("\u2714")} Seeded ${pc17.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc17.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
4181
+ `${pc18.green("\u2714")} Seeded ${pc18.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc18.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
4091
4182
  );
4092
4183
  return result;
4093
4184
  } catch (error) {
@@ -4108,7 +4199,8 @@ async function seedResource(name, options2 = {}) {
4108
4199
  const columns = seedColumns(resource);
4109
4200
  const started = Date.now();
4110
4201
  let parents = /* @__PURE__ */ new Map();
4111
- const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents });
4202
+ let startIndex = 0;
4203
+ const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents, startIndex });
4112
4204
  if (options2.remote) {
4113
4205
  const db = pickDatabase(readD1Databases(appRoot), options2.database);
4114
4206
  const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
@@ -4125,8 +4217,12 @@ ${result.output.trim()}`);
4125
4217
  const found = await query(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`);
4126
4218
  return found.map((row) => row.id);
4127
4219
  });
4128
- const dir = mkdtempSync2(join21(tmpdir2(), "flare-seed-"));
4129
- const file = join21(dir, `${resource.table}.sql`);
4220
+ if (!options2.truncate) {
4221
+ const [counted] = await query(`SELECT count(*) AS total FROM "${resource.table}"`);
4222
+ startIndex = counted?.total ?? 0;
4223
+ }
4224
+ const dir = mkdtempSync2(join22(tmpdir2(), "flare-seed-"));
4225
+ const file = join22(dir, `${resource.table}.sql`);
4130
4226
  try {
4131
4227
  const statements = [];
4132
4228
  if (options2.truncate) statements.push(`DELETE FROM "${resource.table}";`);
@@ -4136,8 +4232,8 @@ ${result.output.trim()}`);
4136
4232
  written += statement.rows;
4137
4233
  options2.onProgress?.(written, total);
4138
4234
  }
4139
- writeFileSync14(file, statements.join("\n"));
4140
- log2(`${pc17.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc17.bold(db.database_name)}`);
4235
+ writeFileSync15(file, statements.join("\n"));
4236
+ log2(`${pc18.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc18.bold(db.database_name)}`);
4141
4237
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
4142
4238
  if (result.code !== 0) throw new Error(`Seeding the remote database failed:
4143
4239
  ${result.output.trim()}`);
@@ -4153,6 +4249,7 @@ ${result.output.trim()}`);
4153
4249
  return (found.results ?? []).map((row) => row.id);
4154
4250
  });
4155
4251
  if (options2.truncate) await local.db.exec(`DELETE FROM "${resource.table}";`);
4252
+ else startIndex = await countRows(local.db, resource.table);
4156
4253
  const sink = { exec: (sql2) => local.db.exec(sql2) };
4157
4254
  const written = await bulkInsert(sink, {
4158
4255
  table: resource.table,
@@ -4168,16 +4265,16 @@ ${result.output.trim()}`);
4168
4265
 
4169
4266
  // src/commands/billing-sync.ts
4170
4267
  import { createRequire as createRequire2 } from "module";
4171
- import { existsSync as existsSync21, mkdtempSync as mkdtempSync3, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync15 } from "fs";
4268
+ import { existsSync as existsSync22, mkdtempSync as mkdtempSync3, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync16 } from "fs";
4172
4269
  import { tmpdir as tmpdir3 } from "os";
4173
- import { join as join22 } from "path";
4270
+ import { join as join23 } from "path";
4174
4271
  import { pathToFileURL as pathToFileURL2 } from "url";
4175
- import { kebabCase as kebabCase4 } from "@flaredev/core";
4176
- import pc18 from "picocolors";
4272
+ import { kebabCase as kebabCase5 } from "@flaredev/core";
4273
+ import pc19 from "picocolors";
4177
4274
  function stripeKeys(appRoot) {
4178
4275
  const map = {};
4179
- const path = join22(appRoot, ".dev.vars");
4180
- if (existsSync21(path)) {
4276
+ const path = join23(appRoot, ".dev.vars");
4277
+ if (existsSync22(path)) {
4181
4278
  for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
4182
4279
  const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
4183
4280
  if (match) map[match[1]] = match[2].trim();
@@ -4198,7 +4295,7 @@ function planRows(catalog) {
4198
4295
  return ok;
4199
4296
  });
4200
4297
  const currencies = new Set(usable.map((price) => price.currency));
4201
- const base = product.metadata?.slug || kebabCase4(product.name);
4298
+ const base = product.metadata?.slug || kebabCase5(product.name);
4202
4299
  for (const price of usable) {
4203
4300
  const interval = price.recurring?.interval ?? null;
4204
4301
  const parts = [base];
@@ -4261,10 +4358,10 @@ async function syncPlans(options2 = {}) {
4261
4358
  if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
4262
4359
  const db = readD1Databases(appRoot)[0];
4263
4360
  if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
4264
- const appRequire = createRequire2(join22(appRoot, "package.json"));
4361
+ const appRequire = createRequire2(join23(appRoot, "package.json"));
4265
4362
  const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
4266
4363
  const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
4267
- const appName = JSON.parse(readFileSync13(join22(appRoot, "package.json"), "utf8")).name ?? "app";
4364
+ const appName = JSON.parse(readFileSync13(join23(appRoot, "package.json"), "utf8")).name ?? "app";
4268
4365
  const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
4269
4366
  const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
4270
4367
  if (products.length === 0) {
@@ -4285,10 +4382,10 @@ async function syncPlans(options2 = {}) {
4285
4382
  ${existingResult.output.trim()}`);
4286
4383
  }
4287
4384
  const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
4288
- const dir = mkdtempSync3(join22(tmpdir3(), "flare-sync-"));
4385
+ const dir = mkdtempSync3(join23(tmpdir3(), "flare-sync-"));
4289
4386
  try {
4290
- const file = join22(dir, "sync-plans.sql");
4291
- writeFileSync15(file, renderSyncSql(rows));
4387
+ const file = join23(dir, "sync-plans.sql");
4388
+ writeFileSync16(file, renderSyncSql(rows));
4292
4389
  const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
4293
4390
  if (result.code !== 0) throw new Error(`Could not write plans:
4294
4391
  ${result.output.trim()}`);
@@ -4300,26 +4397,26 @@ ${result.output.trim()}`);
4300
4397
  const isNew = !known.has(row.stripePriceId);
4301
4398
  if (isNew) created++;
4302
4399
  const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
4303
- log2(`${(isNew ? pc18.green("create") : pc18.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc18.dim(price)}`);
4400
+ log2(`${(isNew ? pc19.green("create") : pc19.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc19.dim(price)}`);
4304
4401
  }
4305
- for (const name of skipped) log2(pc18.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
4402
+ for (const name of skipped) log2(pc19.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
4306
4403
  const configuration = await syncPortalConfiguration(stripe, rows, appName);
4307
- if (configuration) log2(`${pc18.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
4308
- log2(pc18.green(`
4404
+ if (configuration) log2(`${pc19.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
4405
+ log2(pc19.green(`
4309
4406
  ${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
4310
4407
  return { created, updated: rows.length - created, skipped };
4311
4408
  }
4312
4409
 
4313
4410
  // src/commands/sync.ts
4314
- import { existsSync as existsSync22 } from "fs";
4315
- import { join as join23 } from "path";
4316
- import pc19 from "picocolors";
4411
+ import { existsSync as existsSync23 } from "fs";
4412
+ import { join as join24 } from "path";
4413
+ import pc20 from "picocolors";
4317
4414
  async function syncTypes(options2 = {}) {
4318
4415
  const log2 = options2.log ?? ((message) => console.log(message));
4319
4416
  const appRoot = findAppRoot(options2.cwd ?? process.cwd());
4320
4417
  const all = await loadResources(appRoot);
4321
4418
  const policies = await loadPolicies(appRoot);
4322
- const tracksPolicies = policies.length > 0 || existsSync22(join23(appRoot, "policies/index.ts"));
4419
+ const tracksPolicies = policies.length > 0 || existsSync23(join24(appRoot, "policies/index.ts"));
4323
4420
  const plan = planFiles(all, tracksPolicies ? policies : void 0);
4324
4421
  if (!options2.check) ensureSupportFiles(appRoot, log2);
4325
4422
  const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
@@ -4331,42 +4428,42 @@ async function syncTypes(options2 = {}) {
4331
4428
  if (drift.length) {
4332
4429
  log2(
4333
4430
  `
4334
- ${pc19.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
4431
+ ${pc20.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
4335
4432
  Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
4336
4433
  );
4337
4434
  }
4338
4435
  if (unmarked.length) {
4339
4436
  log2(`
4340
- ${pc19.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
4437
+ ${pc20.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
4341
4438
  }
4342
4439
  if (orphans.length) {
4343
4440
  const resources = [...new Set(orphans.map((o) => o.resource))];
4344
4441
  log2(`
4345
- ${pc19.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
4442
+ ${pc20.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
4346
4443
  for (const orphan of orphans) log2(` ${orphan.path}`);
4347
4444
  log2(
4348
- `If the resource was deleted, remove them with ${pc19.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
4445
+ `If the resource was deleted, remove them with ${pc20.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
4349
4446
  );
4350
4447
  }
4351
4448
  const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
4352
4449
  if (tablesChanged && !options2.check) {
4353
4450
  log2(`
4354
- ${pc19.cyan("Tables changed.")} Create a migration with ${pc19.bold("flare gen migration <name> --from-schema")}, then ${pc19.bold("flare migrate")}.`);
4451
+ ${pc20.cyan("Tables changed.")} Create a migration with ${pc20.bold("flare gen migration <name> --from-schema")}, then ${pc20.bold("flare migrate")}.`);
4355
4452
  }
4356
4453
  const problems = drift.length + unmarked.length + orphans.length;
4357
4454
  if (options2.check) {
4358
4455
  const stale = changed.length;
4359
- if (stale === 0 && problems === 0) log2(pc19.green("Generated files are in sync with the resource descriptors."));
4456
+ if (stale === 0 && problems === 0) log2(pc20.green("Generated files are in sync with the resource descriptors."));
4360
4457
  else log2(`
4361
- ${pc19.red("Out of sync:")} ${stale} file(s) would change. Run ${pc19.bold("flare sync-types")}.`);
4458
+ ${pc20.red("Out of sync:")} ${stale} file(s) would change. Run ${pc20.bold("flare sync-types")}.`);
4362
4459
  return stale + problems > 0 ? 1 : 0;
4363
4460
  }
4364
- if (changed.length === 0 && problems === 0) log2(pc19.green("Everything is in sync."));
4461
+ if (changed.length === 0 && problems === 0) log2(pc20.green("Everything is in sync."));
4365
4462
  return problems > 0 ? 1 : 0;
4366
4463
  }
4367
4464
 
4368
4465
  // src/commands/user-role.ts
4369
- import pc20 from "picocolors";
4466
+ import pc21 from "picocolors";
4370
4467
  var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
4371
4468
  var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
4372
4469
  function userRoleSql(email, role) {
@@ -4380,8 +4477,8 @@ async function setUserRole(email, role, options2 = {}) {
4380
4477
  const sql2 = userRoleSql(email, role);
4381
4478
  const roles = await listRoles(options2);
4382
4479
  if (roles && !roles.includes(role)) {
4383
- log2(pc20.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
4384
- log2(pc20.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
4480
+ log2(pc21.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
4481
+ log2(pc21.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
4385
4482
  return 1;
4386
4483
  }
4387
4484
  const db = readD1Databases(appRoot)[0];
@@ -4395,51 +4492,130 @@ ${result.output.trim()}`);
4395
4492
  const [first] = extractJson(result.output);
4396
4493
  const updated = first?.results ?? [];
4397
4494
  if (updated.length === 0) {
4398
- log2(pc20.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
4495
+ log2(pc21.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
4399
4496
  return 1;
4400
4497
  }
4401
- log2(pc20.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
4498
+ log2(pc21.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
4499
+ return 0;
4500
+ }
4501
+
4502
+ // src/utils/install.ts
4503
+ import { spawn as spawn5 } from "child_process";
4504
+ import { existsSync as existsSync24, readFileSync as readFileSync14, readdirSync as readdirSync9 } from "fs";
4505
+ import { join as join25 } from "path";
4506
+ function expectedPackages(dir) {
4507
+ const npmLock = join25(dir, "package-lock.json");
4508
+ if (existsSync24(npmLock)) {
4509
+ try {
4510
+ const lock = JSON.parse(readFileSync14(npmLock, "utf8"));
4511
+ return Math.max(0, Object.keys(lock.packages ?? {}).length - 1);
4512
+ } catch {
4513
+ return 0;
4514
+ }
4515
+ }
4516
+ const pnpmLock = join25(dir, "pnpm-lock.yaml");
4517
+ if (existsSync24(pnpmLock)) {
4518
+ try {
4519
+ const text = readFileSync14(pnpmLock, "utf8");
4520
+ const packages = text.slice(text.indexOf("\npackages:"));
4521
+ return (packages.match(/\n {2}[^\s:]+:/g) ?? []).length;
4522
+ } catch {
4523
+ return 0;
4524
+ }
4525
+ }
4402
4526
  return 0;
4403
4527
  }
4528
+ function installedPackages(dir) {
4529
+ const root = join25(dir, "node_modules");
4530
+ if (!existsSync24(root)) return 0;
4531
+ let total = 0;
4532
+ try {
4533
+ for (const entry of readdirSync9(root, { withFileTypes: true })) {
4534
+ if (!entry.isDirectory() || entry.name === ".bin") continue;
4535
+ if (entry.name.startsWith("@")) {
4536
+ try {
4537
+ total += readdirSync9(join25(root, entry.name)).length;
4538
+ } catch {
4539
+ }
4540
+ } else if (entry.name !== ".pnpm" && entry.name !== ".vite") {
4541
+ total += 1;
4542
+ }
4543
+ }
4544
+ } catch {
4545
+ return total;
4546
+ }
4547
+ return total;
4548
+ }
4549
+ function install(packageManager, dir, onProgress) {
4550
+ const args = installArgs(packageManager);
4551
+ const started = Date.now();
4552
+ const expected = expectedPackages(dir);
4553
+ return new Promise((resolve6, reject) => {
4554
+ const child = process.platform === "win32" ? spawn5([packageManager, ...args].join(" "), { cwd: dir, shell: true, env: childEnv(), stdio: ["ignore", "pipe", "pipe"] }) : spawn5(packageManager, args, { cwd: dir, env: childEnv(), stdio: ["ignore", "pipe", "pipe"] });
4555
+ let output = "";
4556
+ child.stdout?.on("data", (chunk) => output += chunk);
4557
+ child.stderr?.on("data", (chunk) => output += chunk);
4558
+ const timer = onProgress ? setInterval(() => {
4559
+ const installed = installedPackages(dir);
4560
+ onProgress({
4561
+ installed,
4562
+ expected,
4563
+ percent: expected > 0 ? Math.min(99, Math.round(installed / expected * 100)) : void 0,
4564
+ elapsedMs: Date.now() - started
4565
+ });
4566
+ }, 500) : void 0;
4567
+ child.on("error", (error) => {
4568
+ clearInterval(timer);
4569
+ reject(error);
4570
+ });
4571
+ child.on("close", (code) => {
4572
+ clearInterval(timer);
4573
+ resolve6({ code: code ?? 1, output, ms: Date.now() - started });
4574
+ });
4575
+ });
4576
+ }
4404
4577
 
4405
4578
  // src/cli.ts
4406
4579
  function createCli() {
4407
4580
  const cli = cac("flare");
4408
4581
  cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--theme <name>", "Look of the app: default, coral, amber, sky, mono or emerald").option("--auth <list>", "Sign-in methods: magic-link, email-otp, passkeys, 2fa-app, 2fa-email, all or none (default: all)").option("--auth-providers <list>", "Social sign-in, comma-separated: google, github, apple, microsoft").option("-y, --yes", "Don't ask: use the flags given and the defaults for the rest").option("--skip-install", "Write files without installing dependencies").example("flare create shop").example("flare create shop --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes").action(async (dir, options2) => {
4409
- const install = !options2.skipInstall;
4582
+ const wanted = !options2.skipInstall;
4410
4583
  let answers = { theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
4411
4584
  if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers, toAppName(dir));
4412
- const result = createApp(dir, {
4413
- pm: options2.pm,
4414
- install,
4415
- ...answers,
4416
- log: (message) => prompts3.log.message(message),
4417
- // The install is the long part of `flare create`, so it gets a timer rather
4418
- // than a wall of package manager output. Output is kept for when it fails.
4419
- installer: (packageManager, args, cwd) => {
4420
- const spin = prompts3.spinner({ indicator: "timer" });
4421
- spin.start(`Installing dependencies with ${packageManager}`);
4422
- const { code, output } = runQuiet(packageManager, args, cwd);
4423
- if (code === 0) {
4424
- spin.stop(`Installed dependencies with ${packageManager}`);
4425
- } else {
4426
- spin.error(`${packageManager} install failed`);
4427
- process.stderr.write(output);
4428
- }
4429
- return code;
4585
+ const result = createApp(dir, { pm: options2.pm, install: false, ...answers, log: (message) => prompts3.log.message(message) });
4586
+ let installed = false;
4587
+ if (wanted) {
4588
+ const spin = prompts3.spinner({ indicator: "timer" });
4589
+ spin.start(`Installing dependencies with ${result.packageManager}`);
4590
+ const outcome = await install(result.packageManager, result.dir, ({ installed: done, expected, percent }) => {
4591
+ spin.message(
4592
+ percent === void 0 ? `Installing dependencies with ${result.packageManager} \xB7 ${formatCount(done)} packages` : `Installing dependencies with ${result.packageManager} \xB7 ${percent}% (${formatCount(done)} of ${formatCount(expected)})`
4593
+ );
4594
+ });
4595
+ installed = outcome.code === 0;
4596
+ if (installed) {
4597
+ spin.stop(`Installed ${formatCount(expectedPackages(result.dir) || 0)} packages with ${result.packageManager} ${pc22.dim(`(${formatDuration(outcome.ms)})`)}`);
4598
+ } else {
4599
+ spin.error(`${result.packageManager} install failed`);
4600
+ process.stderr.write(outcome.output);
4601
+ process.exitCode = 1;
4430
4602
  }
4431
- });
4432
- printNextSteps(result, install);
4603
+ }
4604
+ printNextSteps(result, installed);
4433
4605
  });
4434
- cli.command("gen <generator> [name]", "Generate code. Generators: resource, migration, policy, billing, security").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("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration").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("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing: write files without installing the stripe dependency").option("--skip-migration", "billing/security: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").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 billing --provider stripe --mode subscriptions").example("flare gen security").action(
4435
- async (generator, name, options2) => {
4606
+ cli.command("gen <generator> [name] [second]", "Generate code. Generators: resource, endpoint, migration, policy, billing, security").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("--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").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("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing: write files without installing the stripe dependency").option("--skip-migration", "billing/security: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").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 billing --provider stripe --mode subscriptions").example("flare gen security").action(
4607
+ async (generator, name, second, options2) => {
4436
4608
  if (generator === "billing")
4437
4609
  return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
4438
4610
  if (generator === "security") return genSecurity({ force: options2.force, skipMigration: options2.skipMigration });
4439
4611
  if (["resource", "migration", "policy"].includes(generator) && !name) {
4440
4612
  throw new Error(`flare gen ${generator} needs a name, e.g. flare gen ${generator} ${generator === "migration" ? "add_phone_to_contacts" : "Contact"}`);
4441
4613
  }
4442
- if (generator === "resource") return genResource(name, { fields: options2.fields, force: options2.force });
4614
+ if (generator === "resource") return genResource(name, { fields: options2.fields, force: options2.force, group: options2.group, icon: options2.icon });
4615
+ if (generator === "endpoint") {
4616
+ if (!name || !second) throw new Error("flare gen endpoint needs a resource and a name, e.g. flare gen endpoint Order publish");
4617
+ return genEndpoint(name, second, { method: options2.method, action: options2.action, record: options2.record });
4618
+ }
4443
4619
  if (generator === "migration") return genMigration(name, { fromSchema: options2.fromSchema });
4444
4620
  if (generator === "policy") return genPolicy(name, { roles: options2.roles, deleteRoles: options2.deleteRoles, force: options2.force });
4445
4621
  throw new Error(`Unknown generator "${generator}". Available: resource, migration, policy, billing, security.`);
@@ -4512,6 +4688,6 @@ try {
4512
4688
  }
4513
4689
  }
4514
4690
  } catch (error) {
4515
- console.error(pc21.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
4691
+ console.error(pc23.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
4516
4692
  process.exitCode = 1;
4517
4693
  }