@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.
- package/dist/index.js +385 -209
- package/package.json +2 -2
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -33
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +35 -0
- package/templates/app/app/dashboard/layout.tsx +28 -4
- package/templates/app/app/dashboard/observability/page.tsx +4 -3
- package/templates/app/app/dashboard/page.tsx +2 -2
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/fields/file-field.tsx +306 -302
- package/templates/app/components/dashboard/import-dialog.tsx +60 -7
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +200 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table.tsx +405 -331
- package/templates/app/components/dashboard/row-actions.tsx +156 -144
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +22 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/dashboard.ts +165 -138
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +11 -0
- package/templates/app/migrations/meta/0000_snapshot.json +63 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/locks/package-lock.json +3 -3
- package/templates/locks/pnpm-lock.yaml +4 -4
- package/templates/locks/yarn.lock +3548 -0
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
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 === "
|
|
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")
|
|
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-
|
|
3204
|
+
// src/commands/gen-endpoint.ts
|
|
3220
3205
|
import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
|
|
3221
|
-
import {
|
|
3222
|
-
import {
|
|
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(`${
|
|
3240
|
-
for (const repair of repairs) log2(
|
|
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 =
|
|
3243
|
-
|
|
3290
|
+
const downDir = join16(appRoot, "migrations", "down");
|
|
3291
|
+
mkdirSync9(downDir, { recursive: true });
|
|
3244
3292
|
for (const file of files) {
|
|
3245
|
-
const down =
|
|
3246
|
-
if (
|
|
3247
|
-
|
|
3248
|
-
log2(`${
|
|
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 ${
|
|
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: ${
|
|
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
|
|
3261
|
-
import { dirname as dirname9, join as
|
|
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
|
|
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 =
|
|
3280
|
-
const exists =
|
|
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
|
-
|
|
3292
|
-
|
|
3293
|
-
log2(`${
|
|
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" ?
|
|
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
|
-
${
|
|
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
|
|
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
|
|
3317
|
-
import
|
|
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 =
|
|
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 =
|
|
3444
|
-
if (
|
|
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 =
|
|
3453
|
-
if (!
|
|
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(`${
|
|
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} ${
|
|
3466
|
-
for (const line of step.sql.trim().split("\n")) log2(
|
|
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(
|
|
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(
|
|
3522
|
+
const dir = mkdtempSync(join18(tmpdir(), "flare-rollback-"));
|
|
3475
3523
|
try {
|
|
3476
|
-
const file =
|
|
3477
|
-
|
|
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(
|
|
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
|
|
3491
|
-
import { dirname as dirname10, join as
|
|
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
|
|
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(
|
|
3511
|
-
const roots = [
|
|
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() &&
|
|
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 (
|
|
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(
|
|
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(
|
|
3620
|
+
unlinkSync2(join19(appRoot, path));
|
|
3573
3621
|
removeEmptyDirs(appRoot, path);
|
|
3574
|
-
log2(`${
|
|
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 &&
|
|
3579
|
-
const seeds = readdirSync7(
|
|
3580
|
-
(file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(
|
|
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(
|
|
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(`${
|
|
3590
|
-
for (const repair of result.repairs) log2(
|
|
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: ${
|
|
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
|
|
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(
|
|
3645
|
-
log2(
|
|
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
|
|
3651
|
-
import { join as
|
|
3652
|
-
import { createFake, kebabCase as
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
3758
|
-
persist: { path:
|
|
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 =
|
|
3774
|
-
const all =
|
|
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 ${
|
|
3837
|
+
log2(`No seeds found. Create one with ${pc17.bold("flare seed:make <name>")}.`);
|
|
3790
3838
|
return;
|
|
3791
3839
|
}
|
|
3792
|
-
const jiti = createJiti4(
|
|
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(
|
|
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(
|
|
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(`${
|
|
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(
|
|
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 =
|
|
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 =
|
|
3892
|
-
if (
|
|
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) => [
|
|
3947
|
+
resource = resources.find((r) => [kebabCase4(r.name), r.slug].includes(name));
|
|
3900
3948
|
}
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
log2(`${
|
|
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 ${
|
|
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
|
|
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
|
|
3913
|
-
import
|
|
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,
|
|
4025
|
-
if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value,
|
|
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(` ${
|
|
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
|
-
`${
|
|
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
|
-
|
|
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
|
-
|
|
4129
|
-
|
|
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
|
-
|
|
4140
|
-
log2(`${
|
|
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
|
|
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
|
|
4270
|
+
import { join as join23 } from "path";
|
|
4174
4271
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
4175
|
-
import { kebabCase as
|
|
4176
|
-
import
|
|
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 =
|
|
4180
|
-
if (
|
|
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 ||
|
|
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(
|
|
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(
|
|
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(
|
|
4385
|
+
const dir = mkdtempSync3(join23(tmpdir3(), "flare-sync-"));
|
|
4289
4386
|
try {
|
|
4290
|
-
const file =
|
|
4291
|
-
|
|
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 ?
|
|
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(
|
|
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(`${
|
|
4308
|
-
log2(
|
|
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
|
|
4315
|
-
import { join as
|
|
4316
|
-
import
|
|
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 ||
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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 ${
|
|
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
|
-
${
|
|
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(
|
|
4456
|
+
if (stale === 0 && problems === 0) log2(pc20.green("Generated files are in sync with the resource descriptors."));
|
|
4360
4457
|
else log2(`
|
|
4361
|
-
${
|
|
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(
|
|
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
|
|
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(
|
|
4384
|
-
log2(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
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,
|
|
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(
|
|
4691
|
+
console.error(pc23.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
|
|
4516
4692
|
process.exitCode = 1;
|
|
4517
4693
|
}
|