@flaredev/cli 0.2.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/bin/flare.js +9 -1
- package/dist/index.js +991 -344
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -31
- 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 +246 -0
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +66 -39
- package/templates/app/app/dashboard/observability/page.tsx +175 -0
- package/templates/app/app/dashboard/page.tsx +108 -27
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -525
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -79
- package/templates/app/components/auth/sign-in-flow.tsx +306 -315
- package/templates/app/components/auth/sign-up-form.tsx +93 -101
- package/templates/app/components/auth/two-factor-form.tsx +139 -124
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -0
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +228 -0
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +22 -18
- package/templates/app/components/dashboard/import-dialog.tsx +540 -0
- 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/page-header.tsx +57 -0
- package/templates/app/components/{admin → 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-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +48 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +152 -28
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +56 -11
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -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 +55 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-config.ts +38 -33
- package/templates/app/lib/auth.ts +34 -0
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/dashboard.ts +165 -0
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +25 -2
- package/templates/app/migrations/meta/0000_snapshot.json +159 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/locks/yarn.lock +3548 -0
- package/templates/app/app/admin/actions.ts +0 -113
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/admin/admin-header.tsx +0 -78
- package/templates/app/components/admin/resource-nav.tsx +0 -120
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- package/templates/app/lib/admin.ts +0 -103
- /package/templates/app/components/{admin → dashboard}/fields/country-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/multi-select-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/phone-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
package/dist/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
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
|
|
6
|
+
import pc22 from "picocolors";
|
|
7
|
+
import * as prompts3 from "@clack/prompts";
|
|
8
|
+
import { FLARE_VERSION as FLARE_VERSION3 } from "@flaredev/core";
|
|
7
9
|
|
|
8
10
|
// src/commands/create.ts
|
|
9
11
|
import { randomBytes } from "crypto";
|
|
10
|
-
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
12
|
+
import { appendFileSync, copyFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11
13
|
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
|
|
14
|
+
import * as prompts from "@clack/prompts";
|
|
12
15
|
import pc from "picocolors";
|
|
13
16
|
import { FLARE_VERSION } from "@flaredev/core";
|
|
14
17
|
|
|
@@ -86,6 +89,11 @@ export const authConfig = {
|
|
|
86
89
|
social: ${JSON.stringify(providers).replace(/","/g, '", "')} as SocialProvider[],
|
|
87
90
|
/** Refuse password sign-in until the email address is verified. */
|
|
88
91
|
requireEmailVerification: false,
|
|
92
|
+
/**
|
|
93
|
+
* Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
94
|
+
* characters of the password's hash are sent, and an outage lets the password through.
|
|
95
|
+
*/
|
|
96
|
+
checkBreachedPasswords: true,
|
|
89
97
|
};
|
|
90
98
|
|
|
91
99
|
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
@@ -185,16 +193,19 @@ import { fileURLToPath } from "url";
|
|
|
185
193
|
var RENAMES = { _gitignore: ".gitignore" };
|
|
186
194
|
function copyTemplate(from, to, tokens) {
|
|
187
195
|
mkdirSync(to, { recursive: true });
|
|
196
|
+
let files = 0;
|
|
188
197
|
for (const entry of readdirSync(from)) {
|
|
189
198
|
const src = join(from, entry);
|
|
190
199
|
const dest = join(to, RENAMES[entry] ?? entry);
|
|
191
200
|
if (statSync(src).isDirectory()) {
|
|
192
|
-
copyTemplate(src, dest, tokens);
|
|
201
|
+
files += copyTemplate(src, dest, tokens);
|
|
193
202
|
continue;
|
|
194
203
|
}
|
|
195
204
|
const content = readFileSync(src, "utf8").replace(/__([A-Z_]+)__/g, (match, key) => tokens[key] ?? match);
|
|
196
205
|
writeFileSync(dest, content);
|
|
206
|
+
files++;
|
|
197
207
|
}
|
|
208
|
+
return files;
|
|
198
209
|
}
|
|
199
210
|
function writeJson(path, value) {
|
|
200
211
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -234,11 +245,6 @@ function childEnv(env = process.env) {
|
|
|
234
245
|
}
|
|
235
246
|
return next;
|
|
236
247
|
}
|
|
237
|
-
function runQuiet(command, args, cwd) {
|
|
238
|
-
const env = childEnv();
|
|
239
|
-
const result = process.platform === "win32" ? spawnSync([command, ...args].join(" "), { cwd, env, shell: true, encoding: "utf8" }) : spawnSync(command, args, { cwd, env, encoding: "utf8" });
|
|
240
|
-
return { code: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
|
|
241
|
-
}
|
|
242
248
|
function installArgs(pm) {
|
|
243
249
|
if (pm === "npm") return ["install", "--no-audit", "--no-fund", "--loglevel=error"];
|
|
244
250
|
return ["install"];
|
|
@@ -275,7 +281,7 @@ function createApp(target, options2 = {}) {
|
|
|
275
281
|
const theme = parseTheme(options2.theme);
|
|
276
282
|
const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
|
|
277
283
|
const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
278
|
-
copyTemplate(join2(templatesDir, "app"), dir, {
|
|
284
|
+
const files = copyTemplate(join2(templatesDir, "app"), dir, {
|
|
279
285
|
APP_NAME: name,
|
|
280
286
|
COMPAT_DATE: compatibilityDate,
|
|
281
287
|
PM: packageManager,
|
|
@@ -310,42 +316,34 @@ ${devVarsEntries(authProviders)}`
|
|
|
310
316
|
if (packageManager === "pnpm" && !inWorkspace) {
|
|
311
317
|
writeFileSync2(join2(dir, "pnpm-workspace.yaml"), "allowBuilds:\n esbuild: true\n workerd: true\n sharp: false\n");
|
|
312
318
|
}
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
`);
|
|
318
|
-
const started = Date.now();
|
|
319
|
-
const code = run(packageManager, installArgs(packageManager), dir);
|
|
320
|
-
if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
|
|
321
|
-
log(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
|
|
322
|
-
const typegen = runQuiet(packageManager, ["run", "cf-typegen"], dir);
|
|
323
|
-
if (typegen.code !== 0) {
|
|
324
|
-
process.stderr.write(typegen.output);
|
|
325
|
-
throw new Error(`wrangler types failed (exit code ${typegen.code}).`);
|
|
326
|
-
}
|
|
327
|
-
log(`${pc.green("\u2714")} Generated types for the Cloudflare bindings`);
|
|
328
|
-
}
|
|
319
|
+
const pinned = flarePackageSpec("core", dir, packageManager).startsWith("^");
|
|
320
|
+
if (pinned) copyLockfile(packageManager, dir);
|
|
321
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
322
|
+
log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
|
|
329
323
|
return { dir, name, packageManager, inWorkspace };
|
|
330
324
|
}
|
|
331
|
-
function
|
|
332
|
-
const
|
|
333
|
-
|
|
325
|
+
function copyLockfile(packageManager, dir) {
|
|
326
|
+
const file = packageManager === "pnpm" ? "pnpm-lock.yaml" : packageManager === "yarn" ? "yarn.lock" : "package-lock.json";
|
|
327
|
+
if (!file) return false;
|
|
328
|
+
const source = join2(templatesDir, "locks", file);
|
|
329
|
+
if (!existsSync2(source)) return false;
|
|
330
|
+
copyFileSync(source, join2(dir, file));
|
|
331
|
+
return true;
|
|
334
332
|
}
|
|
335
333
|
function printNextSteps(result, installed) {
|
|
336
334
|
const rel = relative(process.cwd(), result.dir) || ".";
|
|
337
335
|
const pm = result.packageManager;
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
336
|
+
const commands = [
|
|
337
|
+
[`cd ${rel}`, "your new app"],
|
|
338
|
+
...installed ? [] : [[`${pm} install`, "dependencies first"]],
|
|
339
|
+
[`${pm} run dev`, "http://localhost:3000"],
|
|
340
|
+
["npx flare gen resource Contact", "a table, API and admin screens"],
|
|
341
|
+
["npx flare seed:resource Contact 1000", "fill it with sample rows"]
|
|
342
|
+
];
|
|
343
|
+
const width = Math.max(...commands.map(([command]) => command.length)) + 2;
|
|
344
|
+
const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
|
|
345
|
+
prompts.note(steps.join("\n"), "Next");
|
|
346
|
+
prompts.outro(`${pc.bold(result.name)} is ready ${pc.dim("\xB7")} ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
|
|
349
347
|
}
|
|
350
348
|
|
|
351
349
|
// src/commands/gen-billing.ts
|
|
@@ -640,15 +638,21 @@ function renderFieldsBlock(fields) {
|
|
|
640
638
|
return fields.map((f) => `${renderField(f)}
|
|
641
639
|
`).join("");
|
|
642
640
|
}
|
|
643
|
-
function renderDescriptor(name, fields) {
|
|
641
|
+
function renderDescriptor(name, fields, options2 = {}) {
|
|
644
642
|
defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
|
|
645
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("");
|
|
646
650
|
return joinMarkers({
|
|
647
651
|
before: `import { defineResource, field } from "@flaredev/core";
|
|
648
652
|
|
|
649
653
|
export default defineResource({
|
|
650
654
|
name: ${literal(name)},
|
|
651
|
-
fields: {
|
|
655
|
+
${heading} fields: {
|
|
652
656
|
`,
|
|
653
657
|
block,
|
|
654
658
|
after: ` },
|
|
@@ -1394,12 +1398,12 @@ function onPath() {
|
|
|
1394
1398
|
const probe = spawnSync2(process.platform === "win32" ? "where" : "which", ["cloudflared"], { encoding: "utf8" });
|
|
1395
1399
|
return probe.status === 0 ? probe.stdout.split(/\r?\n/)[0]?.trim() ?? null : null;
|
|
1396
1400
|
}
|
|
1397
|
-
async function download(
|
|
1401
|
+
async function download(log2) {
|
|
1398
1402
|
const asset = releaseAsset();
|
|
1399
1403
|
const dir = cacheDir();
|
|
1400
1404
|
mkdirSync3(dir, { recursive: true });
|
|
1401
1405
|
const target = join4(dir, binName);
|
|
1402
|
-
|
|
1406
|
+
log2(pc2.dim(`Downloading cloudflared (one time) into ${dir}\u2026`));
|
|
1403
1407
|
const response = await fetch(`https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`);
|
|
1404
1408
|
if (!response.ok) throw new Error(`Downloading cloudflared failed: HTTP ${response.status}.`);
|
|
1405
1409
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -1416,12 +1420,12 @@ async function download(log) {
|
|
|
1416
1420
|
if (process.platform !== "win32") chmodSync(target, 493);
|
|
1417
1421
|
return target;
|
|
1418
1422
|
}
|
|
1419
|
-
async function ensureCloudflared(
|
|
1423
|
+
async function ensureCloudflared(log2 = console.log) {
|
|
1420
1424
|
const found = onPath();
|
|
1421
1425
|
if (found) return found;
|
|
1422
1426
|
const cached = join4(cacheDir(), binName);
|
|
1423
1427
|
if (existsSync5(cached)) return cached;
|
|
1424
|
-
return download(
|
|
1428
|
+
return download(log2);
|
|
1425
1429
|
}
|
|
1426
1430
|
async function openTunnel(localUrl, options2 = {}) {
|
|
1427
1431
|
const bin = await ensureCloudflared(options2.log);
|
|
@@ -1575,9 +1579,9 @@ async function runDeploy(args, context3) {
|
|
|
1575
1579
|
if (plan.passthroughOnly) return context3.deploy(plan.forwarded);
|
|
1576
1580
|
const { appRoot, wranglerBin } = context3;
|
|
1577
1581
|
const shellEnv = context3.env ?? process.env;
|
|
1578
|
-
const
|
|
1582
|
+
const log2 = context3.log ?? ((message) => console.log(message));
|
|
1579
1583
|
const envArgs = plan.env ? ["--env", plan.env] : [];
|
|
1580
|
-
const step = (message) =>
|
|
1584
|
+
const step = (message) => log2(`
|
|
1581
1585
|
${pc3.bold(pc3.cyan("flare"))} ${message}`);
|
|
1582
1586
|
const databases = plan.skipMigrations ? [] : readD1Databases(appRoot).filter((db) => existsSync7(join6(appRoot, db.migrations_dir ?? "migrations")));
|
|
1583
1587
|
const migrateAfterDeploy = [];
|
|
@@ -1609,7 +1613,7 @@ ${info.output.trim()}`);
|
|
|
1609
1613
|
const apiToken = shellEnv.FLARE_SECURITY_API_TOKEN;
|
|
1610
1614
|
if (security && zoneId && apiToken) {
|
|
1611
1615
|
step("Provisioning zone security rules");
|
|
1612
|
-
await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log, fetch: context3.fetch });
|
|
1616
|
+
await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log: log2, fetch: context3.fetch });
|
|
1613
1617
|
} else if (security) {
|
|
1614
1618
|
step("Zone security rules skipped (Worker-layer protection is on). To add them, deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set.");
|
|
1615
1619
|
}
|
|
@@ -1642,7 +1646,7 @@ ${put.output.trim()}`);
|
|
|
1642
1646
|
const optional = existsSync7(examplePath) ? declaredSecretNames(readFileSync4(examplePath, "utf8")).filter((name) => !existing.has(name)) : [];
|
|
1643
1647
|
if (optional.length > 0) {
|
|
1644
1648
|
step("Optional secrets not set in production (features using them stay off):");
|
|
1645
|
-
for (const name of optional)
|
|
1649
|
+
for (const name of optional) log2(` wrangler secret put ${name}${plan.env ? ` --env ${plan.env}` : ""}`);
|
|
1646
1650
|
}
|
|
1647
1651
|
}
|
|
1648
1652
|
return 0;
|
|
@@ -1849,7 +1853,7 @@ function previousSnapshot(dir, migrationFile) {
|
|
|
1849
1853
|
}
|
|
1850
1854
|
|
|
1851
1855
|
// src/generator/plan.ts
|
|
1852
|
-
import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
|
|
1856
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
|
|
1853
1857
|
import { dirname as dirname5, join as join10 } from "path";
|
|
1854
1858
|
import pc5 from "picocolors";
|
|
1855
1859
|
|
|
@@ -1962,13 +1966,24 @@ function renderTableModule(entry, all) {
|
|
|
1962
1966
|
const name = tableExport(resource);
|
|
1963
1967
|
const columns = fields.map(([key, def]) => ` ${key}: ${columnExpression(key, def, resources, resource.name)},`);
|
|
1964
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
|
+
};
|
|
1965
1975
|
for (const [key, def] of fields) {
|
|
1966
1976
|
if (def.kind === "enum") {
|
|
1967
1977
|
const options2 = def.options.map(sqlString).join(", ");
|
|
1968
1978
|
extras.push(` check(${q(`${resource.table}_${columnName(key)}_check`)}, sql\`\${table.${key}} in (${options2})\`),`);
|
|
1979
|
+
if (def.filterable !== false) addIndex(key, key);
|
|
1969
1980
|
}
|
|
1970
|
-
if (def.kind === "belongsTo")
|
|
1981
|
+
if (def.kind === "belongsTo") addIndex(key, key);
|
|
1971
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");
|
|
1972
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));
|
|
1973
1988
|
const coreImports = ["integer", "sqliteTable", "text"];
|
|
1974
1989
|
if (fields.some(([, def]) => def.kind === "float")) coreImports.push("real");
|
|
@@ -2044,7 +2059,7 @@ var handlerImports = (entry) => [
|
|
|
2044
2059
|
`import { createResourceHandlers } from "@flaredev/core/server";`,
|
|
2045
2060
|
`import { getDb } from "@/db";`,
|
|
2046
2061
|
`import { ${tableExport(entry.resource)} } from "@/db/schema";`,
|
|
2047
|
-
`import { authorize } from "@/lib/api";`,
|
|
2062
|
+
`import { authorize, currentUser } from "@/lib/api";`,
|
|
2048
2063
|
`import { revalidateResource } from "@/lib/cache";`,
|
|
2049
2064
|
`import ${resourceLocal(entry.stem)} from "@/resources/${entry.stem}.resource";`,
|
|
2050
2065
|
"",
|
|
@@ -2053,6 +2068,7 @@ var handlerImports = (entry) => [
|
|
|
2053
2068
|
` table: ${tableExport(entry.resource)},`,
|
|
2054
2069
|
` getDb,`,
|
|
2055
2070
|
` authorize,`,
|
|
2071
|
+
` currentUser,`,
|
|
2056
2072
|
` onChange: revalidateResource,`,
|
|
2057
2073
|
`});`,
|
|
2058
2074
|
""
|
|
@@ -2078,24 +2094,45 @@ var adminImports = (entry) => [
|
|
|
2078
2094
|
function renderAdminListPage(entry) {
|
|
2079
2095
|
const local = resourceLocal(entry.stem);
|
|
2080
2096
|
return [
|
|
2081
|
-
`import {
|
|
2082
|
-
`import
|
|
2097
|
+
`import { PageHeader } from "@/components/dashboard/page-header";`,
|
|
2098
|
+
`import { ResourceChart } from "@/components/dashboard/resource-chart";`,
|
|
2099
|
+
`import { ResourceStats } from "@/components/dashboard/resource-stats";`,
|
|
2100
|
+
`import { ResourceTable } from "@/components/dashboard/resource-table";`,
|
|
2101
|
+
`import type { SearchParams } from "@/components/dashboard/query";`,
|
|
2083
2102
|
...adminImports(entry),
|
|
2084
2103
|
"",
|
|
2104
|
+
`export const metadata = { title: ${local}.pluralLabel };`,
|
|
2105
|
+
"",
|
|
2085
2106
|
`export default async function ${entry.resource.name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {`,
|
|
2086
2107
|
" return (",
|
|
2087
|
-
|
|
2088
|
-
|
|
2108
|
+
" <>",
|
|
2109
|
+
" <PageHeader",
|
|
2110
|
+
` title={${local}.pluralLabel}`,
|
|
2111
|
+
` crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: ${local}.pluralLabel }]}`,
|
|
2112
|
+
" />",
|
|
2113
|
+
` <ResourceStats resource={${local}} />`,
|
|
2114
|
+
` <ResourceChart resource={${local}} />`,
|
|
2089
2115
|
` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
|
|
2090
|
-
"
|
|
2116
|
+
" </>",
|
|
2091
2117
|
" );",
|
|
2092
2118
|
"}",
|
|
2093
2119
|
""
|
|
2094
2120
|
].join("\n");
|
|
2095
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
|
+
}
|
|
2096
2133
|
function renderAdminNewPage(entry) {
|
|
2097
2134
|
return [
|
|
2098
|
-
`import { ResourceFormPage } from "@/components/
|
|
2135
|
+
`import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
|
|
2099
2136
|
...adminImports(entry),
|
|
2100
2137
|
"",
|
|
2101
2138
|
`export default function New${entry.resource.name}Page() {`,
|
|
@@ -2106,7 +2143,7 @@ function renderAdminNewPage(entry) {
|
|
|
2106
2143
|
}
|
|
2107
2144
|
function renderAdminEditPage(entry) {
|
|
2108
2145
|
return [
|
|
2109
|
-
`import { ResourceFormPage } from "@/components/
|
|
2146
|
+
`import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
|
|
2110
2147
|
...adminImports(entry),
|
|
2111
2148
|
"",
|
|
2112
2149
|
`export default async function Edit${entry.resource.name}Page({ params }: { params: Promise<{ id: string }> }) {`,
|
|
@@ -2174,9 +2211,10 @@ function resourceFiles(entry, all) {
|
|
|
2174
2211
|
{ path: `app/api/${resource.slug}/[id]/route.ts`, content: renderItemRoute(entry) },
|
|
2175
2212
|
{ path: `resources/${stem}.client.ts`, content: renderClient(entry) },
|
|
2176
2213
|
{ path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
|
|
2177
|
-
{ path: `app/
|
|
2178
|
-
{ path: `app/
|
|
2179
|
-
{ path: `app/
|
|
2214
|
+
{ path: `app/dashboard/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
|
|
2215
|
+
{ path: `app/dashboard/${resource.slug}/[id]/page.tsx`, content: renderAdminDetailPage(entry) },
|
|
2216
|
+
{ path: `app/dashboard/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
|
|
2217
|
+
{ path: `app/dashboard/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
|
|
2180
2218
|
];
|
|
2181
2219
|
}
|
|
2182
2220
|
|
|
@@ -2236,8 +2274,8 @@ function findOrphans(appRoot, all) {
|
|
|
2236
2274
|
for (const dir of list2("app/api", () => true)) {
|
|
2237
2275
|
for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync11(join10(appRoot, file))) candidates.push(file);
|
|
2238
2276
|
}
|
|
2239
|
-
for (const dir of list2("app/
|
|
2240
|
-
for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
|
|
2277
|
+
for (const dir of list2("app/dashboard", () => true)) {
|
|
2278
|
+
for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
|
|
2241
2279
|
if (existsSync11(join10(appRoot, file))) candidates.push(file);
|
|
2242
2280
|
}
|
|
2243
2281
|
}
|
|
@@ -2248,20 +2286,20 @@ function findOrphans(appRoot, all) {
|
|
|
2248
2286
|
}
|
|
2249
2287
|
return orphans;
|
|
2250
2288
|
}
|
|
2251
|
-
function ensureSupportFiles(appRoot,
|
|
2289
|
+
function ensureSupportFiles(appRoot, log2) {
|
|
2252
2290
|
for (const path of ["lib/api.ts", "lib/cache.ts"]) {
|
|
2253
2291
|
const target = join10(appRoot, path);
|
|
2254
2292
|
if (existsSync11(target)) continue;
|
|
2255
2293
|
mkdirSync4(dirname5(target), { recursive: true });
|
|
2256
|
-
|
|
2257
|
-
|
|
2294
|
+
copyFileSync2(join10(templatesDir, "app", path), target);
|
|
2295
|
+
log2(`${pc5.green("create".padEnd(9))} ${path}`);
|
|
2258
2296
|
}
|
|
2259
2297
|
}
|
|
2260
|
-
function logResults(results,
|
|
2298
|
+
function logResults(results, log2, options2 = {}) {
|
|
2261
2299
|
for (const { path, outcome } of results) {
|
|
2262
2300
|
if (outcome === "identical" && !options2.showIdentical) continue;
|
|
2263
2301
|
const label = outcome === "create" ? pc5.green("create") : outcome === "update" ? pc5.yellow("update") : outcome === "identical" ? pc5.dim("identical") : pc5.red(outcome === "skipped-drift" ? "drift" : "hand-written");
|
|
2264
|
-
|
|
2302
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2265
2303
|
}
|
|
2266
2304
|
}
|
|
2267
2305
|
|
|
@@ -2296,20 +2334,20 @@ function addStripeDependency(appRoot) {
|
|
|
2296
2334
|
writeJson(path, pkg);
|
|
2297
2335
|
return true;
|
|
2298
2336
|
}
|
|
2299
|
-
function writeBillingResource(appRoot, name, force,
|
|
2337
|
+
function writeBillingResource(appRoot, name, force, log2) {
|
|
2300
2338
|
const relative2 = descriptorPath(name);
|
|
2301
2339
|
const path = join11(appRoot, relative2);
|
|
2302
2340
|
if (!existsSync12(path)) {
|
|
2303
2341
|
mkdirSync5(dirname6(path), { recursive: true });
|
|
2304
2342
|
writeFileSync6(path, renderBillingDescriptor(name));
|
|
2305
|
-
|
|
2343
|
+
log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
|
|
2306
2344
|
return;
|
|
2307
2345
|
}
|
|
2308
2346
|
const parts = splitMarkers(readFileSync8(path, "utf8"));
|
|
2309
2347
|
if (!parts) throw new Error(`${relative2} has no generated fields block, so the billing fields can't be merged. Add them by hand: ${BILLING_RESOURCES[name]}`);
|
|
2310
2348
|
const merged = mergeBillingFields(name, parts.block);
|
|
2311
2349
|
if (!merged) {
|
|
2312
|
-
|
|
2350
|
+
log2(`${pc6.dim("identical".padEnd(9))} ${relative2} (has every billing field)`);
|
|
2313
2351
|
return;
|
|
2314
2352
|
}
|
|
2315
2353
|
try {
|
|
@@ -2320,22 +2358,22 @@ function writeBillingResource(appRoot, name, force, log) {
|
|
|
2320
2358
|
}
|
|
2321
2359
|
throw error;
|
|
2322
2360
|
}
|
|
2323
|
-
|
|
2361
|
+
log2(`${pc6.yellow("update".padEnd(9))} ${relative2} (added billing fields, kept yours)`);
|
|
2324
2362
|
}
|
|
2325
2363
|
async function genBilling(options2 = {}) {
|
|
2326
|
-
const
|
|
2364
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2327
2365
|
validateBillingOptions(options2.provider, options2.mode);
|
|
2328
2366
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2329
|
-
for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force,
|
|
2367
|
+
for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force, log2);
|
|
2330
2368
|
for (const [name, roles] of Object.entries(BILLING_POLICIES)) {
|
|
2331
2369
|
const relative2 = policyPath(name);
|
|
2332
2370
|
if (existsSync12(join11(appRoot, relative2))) {
|
|
2333
|
-
|
|
2371
|
+
log2(`${pc6.dim("kept".padEnd(9))} ${relative2} (already exists)`);
|
|
2334
2372
|
continue;
|
|
2335
2373
|
}
|
|
2336
2374
|
mkdirSync5(join11(appRoot, "policies"), { recursive: true });
|
|
2337
2375
|
writeFileSync6(join11(appRoot, relative2), renderPolicy(name, roles));
|
|
2338
|
-
|
|
2376
|
+
log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
|
|
2339
2377
|
}
|
|
2340
2378
|
const files = [];
|
|
2341
2379
|
const appName = JSON.parse(readFileSync8(join11(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
@@ -2343,7 +2381,7 @@ async function genBilling(options2 = {}) {
|
|
|
2343
2381
|
const outcome = writeGenerated(join11(appRoot, path), content, { header: billingHeader(), force: options2.force });
|
|
2344
2382
|
files.push({ path, status: "missing", outcome });
|
|
2345
2383
|
const label = outcome === "create" ? pc6.green("create") : outcome === "update" ? pc6.yellow("update") : pc6.dim("identical");
|
|
2346
|
-
|
|
2384
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2347
2385
|
}
|
|
2348
2386
|
for (const path of RETIRED_FILES) {
|
|
2349
2387
|
const absolute = join11(appRoot, path);
|
|
@@ -2352,15 +2390,15 @@ async function genBilling(options2 = {}) {
|
|
|
2352
2390
|
const untouched = parts && parts.before.includes("flare gen billing") && !parts.after.trim() && parts.hash !== void 0 && hashBlock(parts.block) === parts.hash;
|
|
2353
2391
|
if (untouched) {
|
|
2354
2392
|
unlinkSync(absolute);
|
|
2355
|
-
|
|
2393
|
+
log2(`${pc6.red("remove".padEnd(9))} ${path} (no longer generated)`);
|
|
2356
2394
|
} else {
|
|
2357
|
-
|
|
2395
|
+
log2(pc6.yellow(`${path} is no longer generated but has your changes; remove it when you're done with it.`));
|
|
2358
2396
|
}
|
|
2359
2397
|
}
|
|
2360
|
-
if (appendVars(appRoot, ".dev.vars.example"))
|
|
2361
|
-
if (appendVars(appRoot, ".dev.vars"))
|
|
2398
|
+
if (appendVars(appRoot, ".dev.vars.example")) log2(`${pc6.green("append".padEnd(9))} .dev.vars.example`);
|
|
2399
|
+
if (appendVars(appRoot, ".dev.vars")) log2(`${pc6.green("append".padEnd(9))} .dev.vars`);
|
|
2362
2400
|
const addedStripe = addStripeDependency(appRoot);
|
|
2363
|
-
if (addedStripe)
|
|
2401
|
+
if (addedStripe) log2(`${pc6.yellow("update".padEnd(9))} package.json (stripe@${STRIPE_DEP})`);
|
|
2364
2402
|
if (!options2.skipInstall) {
|
|
2365
2403
|
const pm = detectPackageManager();
|
|
2366
2404
|
const code = run(pm, ["install"], appRoot);
|
|
@@ -2368,16 +2406,16 @@ async function genBilling(options2 = {}) {
|
|
|
2368
2406
|
run(pm, ["run", "cf-typegen"], appRoot);
|
|
2369
2407
|
}
|
|
2370
2408
|
const all = await loadResources(appRoot);
|
|
2371
|
-
ensureSupportFiles(appRoot,
|
|
2372
|
-
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }),
|
|
2409
|
+
ensureSupportFiles(appRoot, log2);
|
|
2410
|
+
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
|
|
2373
2411
|
let migrations = [];
|
|
2374
2412
|
if (!options2.skipMigration) {
|
|
2375
2413
|
const result = await generateSchemaMigration(appRoot, "billing");
|
|
2376
2414
|
migrations = result.files;
|
|
2377
|
-
for (const file of migrations)
|
|
2378
|
-
for (const repair of result.repairs)
|
|
2415
|
+
for (const file of migrations) log2(`${pc6.green("create".padEnd(9))} ${file}`);
|
|
2416
|
+
for (const repair of result.repairs) log2(pc6.dim(`repaired ${repair}`));
|
|
2379
2417
|
}
|
|
2380
|
-
|
|
2418
|
+
log2(
|
|
2381
2419
|
[
|
|
2382
2420
|
"",
|
|
2383
2421
|
`Next:`,
|
|
@@ -2519,7 +2557,7 @@ var renderSecurityActions = () => `"use server";
|
|
|
2519
2557
|
import { revalidatePath } from "next/cache";
|
|
2520
2558
|
import { can } from "@flaredev/core";
|
|
2521
2559
|
import { isIp } from "@flaredev/core/security";
|
|
2522
|
-
import {
|
|
2560
|
+
import { dashboardSession, policyFor } from "@/lib/dashboard";
|
|
2523
2561
|
import { logSecurityEvent, security } from "@/lib/security";
|
|
2524
2562
|
|
|
2525
2563
|
/** On failure the submitted values come back, so the form (which React resets) keeps them. */
|
|
@@ -2528,7 +2566,7 @@ export type BanResult = { ok: true } | { ok: false; error: string; ip?: string;
|
|
|
2528
2566
|
const DURATIONS: Record<string, number | null> = { "1h": 3600, "24h": 86_400, "7d": 604_800, permanent: null };
|
|
2529
2567
|
|
|
2530
2568
|
async function adminOnly(): Promise<{ email: string } | { error: string }> {
|
|
2531
|
-
const { session, role } = await
|
|
2569
|
+
const { session, role } = await dashboardSession();
|
|
2532
2570
|
if (!session) return { error: "Sign in required." };
|
|
2533
2571
|
if (!can(policyFor("SecurityEvent"), role, "create")) return { error: "Only admins can ban or unban." };
|
|
2534
2572
|
return { email: session.user.email };
|
|
@@ -2549,8 +2587,8 @@ export async function banAction(_previous: BanResult | null, form: FormData): Pr
|
|
|
2549
2587
|
} catch (error) {
|
|
2550
2588
|
return { ok: false, error: \`The ban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\`, ip, reason };
|
|
2551
2589
|
}
|
|
2552
|
-
await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/
|
|
2553
|
-
revalidatePath("/
|
|
2590
|
+
await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: true, detail: \`\${reason} (by \${admin.email})\` });
|
|
2591
|
+
revalidatePath("/dashboard/security");
|
|
2554
2592
|
return { ok: true };
|
|
2555
2593
|
}
|
|
2556
2594
|
|
|
@@ -2563,8 +2601,8 @@ export async function unbanAction(ip: string): Promise<BanResult> {
|
|
|
2563
2601
|
} catch (error) {
|
|
2564
2602
|
return { ok: false, error: \`The unban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\` };
|
|
2565
2603
|
}
|
|
2566
|
-
await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/
|
|
2567
|
-
revalidatePath("/
|
|
2604
|
+
await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: false, detail: \`by \${admin.email}\` });
|
|
2605
|
+
revalidatePath("/dashboard/security");
|
|
2568
2606
|
return { ok: true };
|
|
2569
2607
|
}
|
|
2570
2608
|
`;
|
|
@@ -2657,7 +2695,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|
|
2657
2695
|
import { Separator } from "@/components/ui/separator";
|
|
2658
2696
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
2659
2697
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
2660
|
-
import {
|
|
2698
|
+
import { dashboardStore, policyFor, requireDashboard } from "@/lib/dashboard";
|
|
2661
2699
|
import { security, zoneClient } from "@/lib/security";
|
|
2662
2700
|
import { BanForm, UnbanButton } from "./ban-controls";
|
|
2663
2701
|
|
|
@@ -2666,7 +2704,7 @@ const when = (value: string | number | Date | null) =>
|
|
|
2666
2704
|
value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Until unbanned";
|
|
2667
2705
|
|
|
2668
2706
|
export default async function SecurityPage() {
|
|
2669
|
-
const { role } = await
|
|
2707
|
+
const { role } = await requireDashboard("/dashboard/security");
|
|
2670
2708
|
if (!can(policyFor("SecurityEvent"), role, "read")) {
|
|
2671
2709
|
return (
|
|
2672
2710
|
<Alert>
|
|
@@ -2679,7 +2717,7 @@ export default async function SecurityPage() {
|
|
|
2679
2717
|
|
|
2680
2718
|
const [bans, events, edge] = await Promise.all([
|
|
2681
2719
|
security().bans(),
|
|
2682
|
-
|
|
2720
|
+
dashboardStore("SecurityEvent").list(new URLSearchParams({ sort: "-createdAt", perPage: "20" })),
|
|
2683
2721
|
zone
|
|
2684
2722
|
? Promise.all([zone.bans(), zone.firewallEvents(new Date(Date.now() - 24 * 3600 * 1000))]).then(
|
|
2685
2723
|
([zoneBans, firewall]) => ({ ok: true as const, zoneBans, firewall }),
|
|
@@ -2790,7 +2828,7 @@ export default async function SecurityPage() {
|
|
|
2790
2828
|
<CardHeader>
|
|
2791
2829
|
<CardTitle>Recent events</CardTitle>
|
|
2792
2830
|
<CardDescription>
|
|
2793
|
-
Detectors that tripped, and manual bans. <Link href="/
|
|
2831
|
+
Detectors that tripped, and manual bans. <Link href="/dashboard/security-events" className="underline underline-offset-4">All events</Link>
|
|
2794
2832
|
</CardDescription>
|
|
2795
2833
|
</CardHeader>
|
|
2796
2834
|
<CardContent>
|
|
@@ -2900,43 +2938,43 @@ function addSecurityBindings(source) {
|
|
|
2900
2938
|
}
|
|
2901
2939
|
return { text, added };
|
|
2902
2940
|
}
|
|
2903
|
-
var renderAdminNavBlock = () => `export interface
|
|
2941
|
+
var renderAdminNavBlock = () => `export interface DashboardLink {
|
|
2904
2942
|
label: string;
|
|
2905
2943
|
href: string;
|
|
2906
|
-
/** A resource icon name, e.g. "shield" (see components/
|
|
2944
|
+
/** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
|
|
2907
2945
|
icon: string;
|
|
2908
2946
|
}
|
|
2909
2947
|
|
|
2910
|
-
export const
|
|
2948
|
+
export const generatedDashboardLinks: DashboardLink[] = [{ label: "Security", href: "/dashboard/security", icon: "shield" }];
|
|
2911
2949
|
`;
|
|
2912
2950
|
|
|
2913
2951
|
// src/commands/gen-security.ts
|
|
2914
2952
|
function securityFiles(appName) {
|
|
2915
2953
|
return [
|
|
2916
2954
|
{ path: "lib/security.ts", content: renderLibSecurity(appName) },
|
|
2917
|
-
{ path: "app/
|
|
2918
|
-
{ path: "app/
|
|
2919
|
-
{ path: "app/
|
|
2955
|
+
{ path: "app/dashboard/security/page.tsx", content: renderSecurityPage() },
|
|
2956
|
+
{ path: "app/dashboard/security/actions.ts", content: renderSecurityActions() },
|
|
2957
|
+
{ path: "app/dashboard/security/ban-controls.tsx", content: renderBanControls() }
|
|
2920
2958
|
];
|
|
2921
2959
|
}
|
|
2922
2960
|
var PASS_THROUGH_HEADER = /^\/\/ Request protection for worker\/index\.ts\.[\s\S]*?\n(?=\/\/ generated:start)/;
|
|
2923
|
-
function writeOnce(appRoot, relative2, content,
|
|
2961
|
+
function writeOnce(appRoot, relative2, content, log2) {
|
|
2924
2962
|
const path = join12(appRoot, relative2);
|
|
2925
2963
|
if (existsSync13(path)) {
|
|
2926
|
-
|
|
2964
|
+
log2(`${pc7.dim("kept".padEnd(9))} ${relative2} (already exists)`);
|
|
2927
2965
|
return;
|
|
2928
2966
|
}
|
|
2929
2967
|
mkdirSync6(dirname7(path), { recursive: true });
|
|
2930
2968
|
writeFileSync7(path, content);
|
|
2931
|
-
|
|
2969
|
+
log2(`${pc7.green("create".padEnd(9))} ${relative2}`);
|
|
2932
2970
|
}
|
|
2933
2971
|
async function genSecurity(options2 = {}) {
|
|
2934
|
-
const
|
|
2972
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2935
2973
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2936
2974
|
const appName = JSON.parse(readFileSync9(join12(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
2937
|
-
writeOnce(appRoot, "security.config.ts", renderSecurityConfig(),
|
|
2938
|
-
writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(),
|
|
2939
|
-
writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY),
|
|
2975
|
+
writeOnce(appRoot, "security.config.ts", renderSecurityConfig(), log2);
|
|
2976
|
+
writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(), log2);
|
|
2977
|
+
writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY), log2);
|
|
2940
2978
|
const files = [];
|
|
2941
2979
|
const write = (path, content, header) => {
|
|
2942
2980
|
const absolute = join12(appRoot, path);
|
|
@@ -2947,12 +2985,12 @@ async function genSecurity(options2 = {}) {
|
|
|
2947
2985
|
const outcome = writeGenerated(absolute, content, { header, force: options2.force });
|
|
2948
2986
|
files.push({ path, status: "missing", outcome });
|
|
2949
2987
|
const label = outcome === "create" ? pc7.green("create") : outcome === "update" ? pc7.yellow("update") : pc7.dim("identical");
|
|
2950
|
-
|
|
2988
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2951
2989
|
};
|
|
2952
2990
|
for (const { path, content } of securityFiles(appName)) write(path, content, securityHeader());
|
|
2953
|
-
const navPath = join12(appRoot, "lib/
|
|
2954
|
-
if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/
|
|
2955
|
-
else
|
|
2991
|
+
const navPath = join12(appRoot, "lib/dashboard-nav.ts");
|
|
2992
|
+
if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/dashboard-nav.ts", renderAdminNavBlock());
|
|
2993
|
+
else log2(pc7.yellow(`lib/dashboard-nav.ts not found: link to /dashboard/security from your dashboard sidebar yourself.`));
|
|
2956
2994
|
const wranglerPath = join12(appRoot, "wrangler.jsonc");
|
|
2957
2995
|
let bindings = [];
|
|
2958
2996
|
if (existsSync13(wranglerPath)) {
|
|
@@ -2960,27 +2998,27 @@ async function genSecurity(options2 = {}) {
|
|
|
2960
2998
|
bindings = added;
|
|
2961
2999
|
if (added.length) {
|
|
2962
3000
|
writeFileSync7(wranglerPath, text);
|
|
2963
|
-
for (const line of added)
|
|
3001
|
+
for (const line of added) log2(`${pc7.yellow("update".padEnd(9))} wrangler.jsonc (${line})`);
|
|
2964
3002
|
}
|
|
2965
3003
|
} else {
|
|
2966
|
-
|
|
3004
|
+
log2(pc7.yellow("wrangler.jsonc not found: add the FLARE_SECURITY, FLARE_SECURITY_MONITOR and FLARE_RATE_LIMIT bindings yourself."));
|
|
2967
3005
|
}
|
|
2968
3006
|
const all = await loadResources(appRoot);
|
|
2969
|
-
ensureSupportFiles(appRoot,
|
|
2970
|
-
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }),
|
|
3007
|
+
ensureSupportFiles(appRoot, log2);
|
|
3008
|
+
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
|
|
2971
3009
|
let migrations = [];
|
|
2972
3010
|
if (!options2.skipMigration) {
|
|
2973
3011
|
const result = await generateSchemaMigration(appRoot, "security");
|
|
2974
3012
|
migrations = result.files;
|
|
2975
|
-
for (const file of migrations)
|
|
2976
|
-
for (const repair of result.repairs)
|
|
3013
|
+
for (const file of migrations) log2(`${pc7.green("create".padEnd(9))} ${file}`);
|
|
3014
|
+
for (const repair of result.repairs) log2(pc7.dim(`repaired ${repair}`));
|
|
2977
3015
|
}
|
|
2978
|
-
|
|
3016
|
+
log2(
|
|
2979
3017
|
[
|
|
2980
3018
|
"",
|
|
2981
3019
|
`Next:`,
|
|
2982
3020
|
` 1. ${pc7.bold("flare migrate")} to create the security_events table.`,
|
|
2983
|
-
` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/
|
|
3021
|
+
` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/dashboard/security")}. Tune detectors in security.config.ts.`,
|
|
2984
3022
|
` 3. Optional zone layer: deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set (a token scoped to that`,
|
|
2985
3023
|
` one zone with Zone WAF Edit, Firewall Services Edit, Analytics Read, Zone Read). ${pc7.bold("flare deploy")} pushes the`,
|
|
2986
3024
|
` zone rules and uploads both as secrets.`
|
|
@@ -2994,7 +3032,7 @@ import { existsSync as existsSync14, readFileSync as readFileSync10, writeFileSy
|
|
|
2994
3032
|
import { join as join13 } from "path";
|
|
2995
3033
|
import pc8 from "picocolors";
|
|
2996
3034
|
var THEME_LINE = /theme: "([a-z]+)" as ThemeName,/;
|
|
2997
|
-
function setTheme(name, cwd = process.cwd(),
|
|
3035
|
+
function setTheme(name, cwd = process.cwd(), log2 = console.log) {
|
|
2998
3036
|
const appRoot = findAppRoot(cwd);
|
|
2999
3037
|
const path = join13(appRoot, "lib/site.ts");
|
|
3000
3038
|
if (!existsSync14(path)) throw new Error("lib/site.ts not found: this app was created before themes. Add it from the Flare app template.");
|
|
@@ -3002,34 +3040,50 @@ function setTheme(name, cwd = process.cwd(), log = console.log) {
|
|
|
3002
3040
|
const current = THEME_LINE.exec(source)?.[1];
|
|
3003
3041
|
if (!name) {
|
|
3004
3042
|
for (const [theme2, description] of Object.entries(THEMES)) {
|
|
3005
|
-
|
|
3043
|
+
log2(`${theme2 === current ? pc8.green("\u25CF") : " "} ${theme2.padEnd(8)} ${pc8.dim(description)}`);
|
|
3006
3044
|
}
|
|
3007
|
-
|
|
3045
|
+
log2(pc8.dim("\nSwitch with `flare theme <name>`. FLARE_THEME in .env overrides it for a build."));
|
|
3008
3046
|
return;
|
|
3009
3047
|
}
|
|
3010
3048
|
const theme = parseTheme(name);
|
|
3011
3049
|
if (!current) throw new Error('lib/site.ts has no `theme: "\u2026" as ThemeName,` line to change.');
|
|
3012
3050
|
writeFileSync8(path, source.replace(THEME_LINE, `theme: "${theme}" as ThemeName,`));
|
|
3013
|
-
|
|
3051
|
+
log2(`${pc8.green("\u2714")} Theme is now ${pc8.bold(theme)}. Restart \`flare dev\` (or rebuild) to see it.`);
|
|
3014
3052
|
}
|
|
3015
3053
|
|
|
3016
3054
|
// src/commands/create-prompts.ts
|
|
3017
|
-
import * as
|
|
3055
|
+
import * as prompts2 from "@clack/prompts";
|
|
3056
|
+
import pc9 from "picocolors";
|
|
3057
|
+
import { FLARE_VERSION as FLARE_VERSION2 } from "@flaredev/core";
|
|
3018
3058
|
var canPrompt = (yes) => !yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
3059
|
+
var AUTH_HINTS = {
|
|
3060
|
+
"magic-link": "a sign-in link by email",
|
|
3061
|
+
"email-otp": "a 6-digit sign-in code by email",
|
|
3062
|
+
passkeys: "Face ID, Touch ID, Windows Hello",
|
|
3063
|
+
"2fa-app": "authenticator app, with backup codes",
|
|
3064
|
+
"2fa-email": "a code by email as the second step"
|
|
3065
|
+
};
|
|
3066
|
+
var AUTH_LABELS = {
|
|
3067
|
+
"magic-link": "Magic links",
|
|
3068
|
+
"email-otp": "Email codes",
|
|
3069
|
+
passkeys: "Passkeys",
|
|
3070
|
+
"2fa-app": "Two-factor: authenticator app",
|
|
3071
|
+
"2fa-email": "Two-factor: email code"
|
|
3072
|
+
};
|
|
3019
3073
|
function stopIfCancelled(value) {
|
|
3020
|
-
if (
|
|
3021
|
-
|
|
3074
|
+
if (prompts2.isCancel(value)) {
|
|
3075
|
+
prompts2.cancel("Cancelled: nothing was created.");
|
|
3022
3076
|
process.exit(1);
|
|
3023
3077
|
}
|
|
3024
3078
|
return value;
|
|
3025
3079
|
}
|
|
3026
|
-
async function askCreateQuestions(given) {
|
|
3027
|
-
|
|
3080
|
+
async function askCreateQuestions(given, appName) {
|
|
3081
|
+
prompts2.intro(`${pc9.bgRed(pc9.white(" Flare "))} ${pc9.dim(`v${FLARE_VERSION2}`)}${appName ? ` ${pc9.dim("\xB7")} ${pc9.bold(appName)}` : ""}`);
|
|
3028
3082
|
const answers = { ...given };
|
|
3029
3083
|
if (given.theme === void 0) {
|
|
3030
3084
|
answers.theme = stopIfCancelled(
|
|
3031
|
-
await
|
|
3032
|
-
message: "Pick a look
|
|
3085
|
+
await prompts2.select({
|
|
3086
|
+
message: "Pick a look",
|
|
3033
3087
|
initialValue: "default",
|
|
3034
3088
|
options: Object.entries(THEMES).map(([value, hint]) => ({ value, label: value, hint }))
|
|
3035
3089
|
})
|
|
@@ -3037,19 +3091,23 @@ async function askCreateQuestions(given) {
|
|
|
3037
3091
|
}
|
|
3038
3092
|
if (given.auth === void 0) {
|
|
3039
3093
|
const methods = stopIfCancelled(
|
|
3040
|
-
await
|
|
3041
|
-
message:
|
|
3094
|
+
await prompts2.multiselect({
|
|
3095
|
+
message: `How can people sign in? ${pc9.dim("Email and password is always on")}`,
|
|
3042
3096
|
initialValues: DEFAULT_AUTH_METHODS,
|
|
3043
3097
|
required: false,
|
|
3044
|
-
options: Object.
|
|
3098
|
+
options: Object.keys(AUTH_METHODS).map((value) => ({
|
|
3099
|
+
value,
|
|
3100
|
+
label: AUTH_LABELS[value],
|
|
3101
|
+
hint: AUTH_HINTS[value]
|
|
3102
|
+
}))
|
|
3045
3103
|
})
|
|
3046
3104
|
);
|
|
3047
3105
|
answers.auth = methods.length ? methods.join(",") : "none";
|
|
3048
3106
|
}
|
|
3049
3107
|
if (given.authProviders === void 0) {
|
|
3050
3108
|
const providers = stopIfCancelled(
|
|
3051
|
-
await
|
|
3052
|
-
message:
|
|
3109
|
+
await prompts2.multiselect({
|
|
3110
|
+
message: `Social sign-in? ${pc9.dim("each needs its own credentials later")}`,
|
|
3053
3111
|
initialValues: [],
|
|
3054
3112
|
required: false,
|
|
3055
3113
|
options: Object.entries(AUTH_PROVIDERS).map(([value, { label }]) => ({ value, label }))
|
|
@@ -3057,7 +3115,6 @@ async function askCreateQuestions(given) {
|
|
|
3057
3115
|
);
|
|
3058
3116
|
answers.authProviders = providers.join(",");
|
|
3059
3117
|
}
|
|
3060
|
-
prompts.outro("Creating your app");
|
|
3061
3118
|
return answers;
|
|
3062
3119
|
}
|
|
3063
3120
|
|
|
@@ -3065,8 +3122,8 @@ async function askCreateQuestions(given) {
|
|
|
3065
3122
|
import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
|
|
3066
3123
|
import { dirname as dirname8, join as join14 } from "path";
|
|
3067
3124
|
import { camelCase as camelCase4, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
|
|
3068
|
-
import
|
|
3069
|
-
function validateFields(name, fields, others,
|
|
3125
|
+
import pc10 from "picocolors";
|
|
3126
|
+
function validateFields(name, fields, others, log2) {
|
|
3070
3127
|
const known = /* @__PURE__ */ new Set([name, ...others.map((resource) => resource.name)]);
|
|
3071
3128
|
for (const field of fields) {
|
|
3072
3129
|
if (field.kind === "belongsTo" && !known.has(field.target)) {
|
|
@@ -3076,11 +3133,11 @@ function validateFields(name, fields, others, log) {
|
|
|
3076
3133
|
const candidate = defineResource2({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
|
|
3077
3134
|
const graph = relationGraph2([...others, candidate]);
|
|
3078
3135
|
for (const pending of graph.pending.filter((p) => p.resource === name)) {
|
|
3079
|
-
|
|
3136
|
+
log2(pc10.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
|
|
3080
3137
|
}
|
|
3081
3138
|
}
|
|
3082
3139
|
async function genResource(rawName, options2) {
|
|
3083
|
-
const
|
|
3140
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3084
3141
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3085
3142
|
const name = resourceName(rawName);
|
|
3086
3143
|
const relativePath = descriptorPath(name);
|
|
@@ -3093,15 +3150,15 @@ async function genResource(rawName, options2) {
|
|
|
3093
3150
|
const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
|
|
3094
3151
|
if (options2.fields) {
|
|
3095
3152
|
const fields = parseFields(options2.fields);
|
|
3096
|
-
validateFields(name, fields, others,
|
|
3153
|
+
validateFields(name, fields, others, log2);
|
|
3097
3154
|
if (!exists) {
|
|
3098
3155
|
mkdirSync7(dirname8(path), { recursive: true });
|
|
3099
|
-
writeFileSync9(path, renderDescriptor(name, fields));
|
|
3100
|
-
|
|
3156
|
+
writeFileSync9(path, renderDescriptor(name, fields, { group: options2.group, icon: options2.icon }));
|
|
3157
|
+
log2(`${pc10.green("create".padEnd(9))} ${relativePath}`);
|
|
3101
3158
|
} else {
|
|
3102
3159
|
try {
|
|
3103
3160
|
const outcome = writeGenerated(path, renderFieldsBlock(fields), { force: options2.force });
|
|
3104
|
-
|
|
3161
|
+
log2(`${(outcome === "identical" ? pc10.dim : pc10.yellow)(outcome.padEnd(9))} ${relativePath}`);
|
|
3105
3162
|
} catch (error) {
|
|
3106
3163
|
if (error instanceof DriftError) {
|
|
3107
3164
|
throw new Error(
|
|
@@ -3117,9 +3174,9 @@ async function genResource(rawName, options2) {
|
|
|
3117
3174
|
try {
|
|
3118
3175
|
const all = await loadResources(appRoot);
|
|
3119
3176
|
table = all.find(({ resource }) => resource.name === name).resource.table;
|
|
3120
|
-
ensureSupportFiles(appRoot,
|
|
3177
|
+
ensureSupportFiles(appRoot, log2);
|
|
3121
3178
|
written = applyPlan(appRoot, planFiles(all), { force: options2.force });
|
|
3122
|
-
logResults(written,
|
|
3179
|
+
logResults(written, log2);
|
|
3123
3180
|
} catch (error) {
|
|
3124
3181
|
const hint = exists ? "" : `
|
|
3125
3182
|
(${relativePath} was written; fix it and run \`flare sync-types\`.)`;
|
|
@@ -3127,75 +3184,138 @@ async function genResource(rawName, options2) {
|
|
|
3127
3184
|
}
|
|
3128
3185
|
const skipped = written.filter((r) => r.outcome.startsWith("skipped"));
|
|
3129
3186
|
if (skipped.length) {
|
|
3130
|
-
|
|
3187
|
+
log2(pc10.yellow(`
|
|
3131
3188
|
Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for details.`));
|
|
3132
3189
|
}
|
|
3133
3190
|
let migrations = [];
|
|
3134
3191
|
if (!options2.skipMigration) {
|
|
3135
3192
|
const result = await generateSchemaMigration(appRoot, `${exists ? "update" : "create"}_${table}`);
|
|
3136
3193
|
migrations = result.files;
|
|
3137
|
-
for (const file of migrations)
|
|
3138
|
-
for (const repair of result.repairs)
|
|
3139
|
-
if (migrations.length)
|
|
3140
|
-
Next: ${
|
|
3141
|
-
else if (exists)
|
|
3194
|
+
for (const file of migrations) log2(`${pc10.green("create".padEnd(9))} ${file}`);
|
|
3195
|
+
for (const repair of result.repairs) log2(pc10.dim(`repaired ${repair}`));
|
|
3196
|
+
if (migrations.length) log2(`
|
|
3197
|
+
Next: ${pc10.bold("flare migrate")} to apply it locally.`);
|
|
3198
|
+
else if (exists) log2(pc10.dim("\nNo table changes, so no migration."));
|
|
3142
3199
|
}
|
|
3143
3200
|
const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
|
|
3144
3201
|
return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
|
|
3145
3202
|
}
|
|
3146
3203
|
|
|
3147
|
-
// src/commands/gen-
|
|
3204
|
+
// src/commands/gen-endpoint.ts
|
|
3148
3205
|
import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
|
|
3149
|
-
import {
|
|
3206
|
+
import { join as join15 } from "path";
|
|
3207
|
+
import { kebabCase as kebabCase3, pascalCase as pascalCase2 } from "@flaredev/core";
|
|
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";
|
|
3150
3270
|
import { snakeCase } from "@flaredev/core";
|
|
3151
|
-
import
|
|
3271
|
+
import pc12 from "picocolors";
|
|
3152
3272
|
var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
|
|
3153
3273
|
-- Write SQL that undoes the up migration, e.g.:
|
|
3154
3274
|
-- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
|
|
3155
3275
|
-- While this file has no statements, rollback of ${up} is refused.
|
|
3156
3276
|
`;
|
|
3157
3277
|
async function genMigration(rawName, options2 = {}) {
|
|
3158
|
-
const
|
|
3278
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3159
3279
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3160
3280
|
const name = snakeCase(rawName);
|
|
3161
3281
|
if (!name) throw new Error(`Invalid migration name "${rawName}". Use letters, digits, and underscores.`);
|
|
3162
3282
|
const { files, repairs } = await generateSchemaMigration(appRoot, name, { custom: !options2.fromSchema });
|
|
3163
3283
|
if (files.length === 0) {
|
|
3164
|
-
|
|
3284
|
+
log2(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
|
|
3165
3285
|
return [];
|
|
3166
3286
|
}
|
|
3167
|
-
for (const file of files)
|
|
3168
|
-
for (const repair of repairs)
|
|
3287
|
+
for (const file of files) log2(`${pc12.green("create".padEnd(9))} ${file}`);
|
|
3288
|
+
for (const repair of repairs) log2(pc12.dim(`repaired ${repair}`));
|
|
3169
3289
|
if (!options2.fromSchema) {
|
|
3170
|
-
const downDir =
|
|
3171
|
-
|
|
3290
|
+
const downDir = join16(appRoot, "migrations", "down");
|
|
3291
|
+
mkdirSync9(downDir, { recursive: true });
|
|
3172
3292
|
for (const file of files) {
|
|
3173
|
-
const down =
|
|
3174
|
-
if (
|
|
3175
|
-
|
|
3176
|
-
|
|
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)}`);
|
|
3177
3297
|
}
|
|
3178
|
-
|
|
3179
|
-
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${
|
|
3298
|
+
log2(`
|
|
3299
|
+
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc12.bold("flare migrate")}.`);
|
|
3180
3300
|
} else {
|
|
3181
|
-
|
|
3182
|
-
Next: ${
|
|
3301
|
+
log2(`
|
|
3302
|
+
Next: ${pc12.bold("flare migrate")}.`);
|
|
3183
3303
|
}
|
|
3184
3304
|
return files;
|
|
3185
3305
|
}
|
|
3186
3306
|
|
|
3187
3307
|
// src/commands/gen-policy.ts
|
|
3188
|
-
import { existsSync as
|
|
3189
|
-
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";
|
|
3190
3310
|
import { definePolicy } from "@flaredev/core";
|
|
3191
|
-
import
|
|
3311
|
+
import pc13 from "picocolors";
|
|
3192
3312
|
function parseRoles(input, what = "--roles") {
|
|
3193
3313
|
const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
3194
3314
|
if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
|
|
3195
3315
|
return [...new Set(roles)];
|
|
3196
3316
|
}
|
|
3197
3317
|
async function genPolicy(rawName, options2) {
|
|
3198
|
-
const
|
|
3318
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3199
3319
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3200
3320
|
const name = resourceName(rawName);
|
|
3201
3321
|
const resources = await loadResources(appRoot);
|
|
@@ -3204,8 +3324,8 @@ async function genPolicy(rawName, options2) {
|
|
|
3204
3324
|
throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
|
|
3205
3325
|
}
|
|
3206
3326
|
const relative2 = policyPath(name);
|
|
3207
|
-
const path =
|
|
3208
|
-
const exists =
|
|
3327
|
+
const path = join17(appRoot, relative2);
|
|
3328
|
+
const exists = existsSync18(path);
|
|
3209
3329
|
let roles;
|
|
3210
3330
|
let deleteRoles;
|
|
3211
3331
|
if (options2.roles || !exists) {
|
|
@@ -3216,13 +3336,13 @@ async function genPolicy(rawName, options2) {
|
|
|
3216
3336
|
if (roles && deleteRoles) {
|
|
3217
3337
|
const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
|
|
3218
3338
|
if (!exists) {
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
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}`);
|
|
3222
3342
|
} else {
|
|
3223
3343
|
try {
|
|
3224
3344
|
const outcome = writeGenerated(path, block, { force: options2.force });
|
|
3225
|
-
|
|
3345
|
+
log2(`${(outcome === "identical" ? pc13.dim : pc13.yellow)(outcome.padEnd(9))} ${relative2}`);
|
|
3226
3346
|
} catch (error) {
|
|
3227
3347
|
if (error instanceof DriftError) {
|
|
3228
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.`);
|
|
@@ -3232,17 +3352,17 @@ async function genPolicy(rawName, options2) {
|
|
|
3232
3352
|
}
|
|
3233
3353
|
}
|
|
3234
3354
|
const policies = await loadPolicies(appRoot);
|
|
3235
|
-
logResults(applyPlan(appRoot, planFiles(resources, policies)),
|
|
3236
|
-
|
|
3237
|
-
${
|
|
3355
|
+
logResults(applyPlan(appRoot, planFiles(resources, policies)), log2);
|
|
3356
|
+
log2(`
|
|
3357
|
+
${pc13.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
|
|
3238
3358
|
return { name, path: relative2 };
|
|
3239
3359
|
}
|
|
3240
3360
|
|
|
3241
3361
|
// src/commands/migrate.ts
|
|
3242
|
-
import { existsSync as
|
|
3362
|
+
import { existsSync as existsSync19, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync13 } from "fs";
|
|
3243
3363
|
import { tmpdir } from "os";
|
|
3244
|
-
import { join as
|
|
3245
|
-
import
|
|
3364
|
+
import { join as join18 } from "path";
|
|
3365
|
+
import pc14 from "picocolors";
|
|
3246
3366
|
|
|
3247
3367
|
// src/generator/down.ts
|
|
3248
3368
|
var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
|
|
@@ -3358,18 +3478,18 @@ ${result.output.trim()}`);
|
|
|
3358
3478
|
async function rollback(options2 = {}) {
|
|
3359
3479
|
const steps = options2.steps ?? 1;
|
|
3360
3480
|
if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
|
|
3361
|
-
const { appRoot, db, target, where, wrangler, log } = context(options2);
|
|
3362
|
-
const migrationsDir =
|
|
3481
|
+
const { appRoot, db, target, where, wrangler, log: log2 } = context(options2);
|
|
3482
|
+
const migrationsDir = join18(appRoot, db.migrations_dir ?? "migrations");
|
|
3363
3483
|
const applied = await appliedMigrations(options2, steps);
|
|
3364
3484
|
if (applied.length === 0) {
|
|
3365
|
-
|
|
3485
|
+
log2("Nothing to roll back.");
|
|
3366
3486
|
return 0;
|
|
3367
3487
|
}
|
|
3368
3488
|
const plan = [];
|
|
3369
3489
|
for (const { name } of applied) {
|
|
3370
3490
|
if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
|
|
3371
|
-
const handWritten =
|
|
3372
|
-
if (
|
|
3491
|
+
const handWritten = join18(migrationsDir, "down", name);
|
|
3492
|
+
if (existsSync19(handWritten)) {
|
|
3373
3493
|
const sql2 = readFileSync11(handWritten, "utf8");
|
|
3374
3494
|
if (splitStatements(sql2).length === 0) {
|
|
3375
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.`);
|
|
@@ -3377,8 +3497,8 @@ async function rollback(options2 = {}) {
|
|
|
3377
3497
|
plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
|
|
3378
3498
|
continue;
|
|
3379
3499
|
}
|
|
3380
|
-
const upPath =
|
|
3381
|
-
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.`);
|
|
3382
3502
|
const down = deriveDown(readFileSync11(upPath, "utf8"));
|
|
3383
3503
|
if (!down.reversible) {
|
|
3384
3504
|
throw new Error(
|
|
@@ -3388,37 +3508,37 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
|
|
|
3388
3508
|
}
|
|
3389
3509
|
plan.push({ name, sql: down.sql, source: "derived" });
|
|
3390
3510
|
}
|
|
3391
|
-
|
|
3511
|
+
log2(`${pc14.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
|
|
3392
3512
|
for (const step of plan) {
|
|
3393
|
-
|
|
3394
|
-
for (const line of step.sql.trim().split("\n"))
|
|
3513
|
+
log2(` ${step.name} ${pc14.dim(`(${step.source})`)}`);
|
|
3514
|
+
for (const line of step.sql.trim().split("\n")) log2(pc14.dim(` ${line}`));
|
|
3395
3515
|
}
|
|
3396
3516
|
if (options2.remote && !options2.yes) {
|
|
3397
|
-
|
|
3517
|
+
log2(pc14.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
|
|
3398
3518
|
return 1;
|
|
3399
3519
|
}
|
|
3400
3520
|
const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
|
|
3401
3521
|
DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
|
|
3402
|
-
const dir = mkdtempSync(
|
|
3522
|
+
const dir = mkdtempSync(join18(tmpdir(), "flare-rollback-"));
|
|
3403
3523
|
try {
|
|
3404
|
-
const file =
|
|
3405
|
-
|
|
3524
|
+
const file = join18(dir, "rollback.sql");
|
|
3525
|
+
writeFileSync13(file, script);
|
|
3406
3526
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3407
3527
|
if (result.code !== 0) throw new Error(`Rollback failed:
|
|
3408
3528
|
${result.output.trim()}`);
|
|
3409
3529
|
} finally {
|
|
3410
3530
|
rmSync2(dir, { recursive: true, force: true });
|
|
3411
3531
|
}
|
|
3412
|
-
|
|
3532
|
+
log2(pc14.green(`
|
|
3413
3533
|
Rolled back ${plan.map((step) => step.name).join(", ")}.`));
|
|
3414
3534
|
return 0;
|
|
3415
3535
|
}
|
|
3416
3536
|
|
|
3417
3537
|
// src/commands/rm.ts
|
|
3418
|
-
import { existsSync as
|
|
3419
|
-
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";
|
|
3420
3540
|
import { relationGraph as relationGraph3 } from "@flaredev/core";
|
|
3421
|
-
import
|
|
3541
|
+
import pc15 from "picocolors";
|
|
3422
3542
|
var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
|
|
3423
3543
|
function handWrittenReason(source, header, options2 = {}) {
|
|
3424
3544
|
const parts = splitMarkers(source);
|
|
@@ -3435,16 +3555,16 @@ function handWrittenReason(source, header, options2 = {}) {
|
|
|
3435
3555
|
return null;
|
|
3436
3556
|
}
|
|
3437
3557
|
function removeEmptyDirs(appRoot, start) {
|
|
3438
|
-
let dir = dirname10(
|
|
3439
|
-
const roots = [
|
|
3558
|
+
let dir = dirname10(join19(appRoot, start));
|
|
3559
|
+
const roots = [join19(appRoot, "app", "api"), join19(appRoot, "app", "dashboard")];
|
|
3440
3560
|
const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
|
|
3441
|
-
while (inside() &&
|
|
3561
|
+
while (inside() && existsSync20(dir) && readdirSync7(dir).length === 0) {
|
|
3442
3562
|
rmdirSync(dir);
|
|
3443
3563
|
dir = dirname10(dir);
|
|
3444
3564
|
}
|
|
3445
3565
|
}
|
|
3446
3566
|
async function rmResource(rawName, options2 = {}) {
|
|
3447
|
-
const
|
|
3567
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3448
3568
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3449
3569
|
const name = resourceName(rawName);
|
|
3450
3570
|
const all = await loadResources(appRoot);
|
|
@@ -3467,7 +3587,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
3467
3587
|
if (entry) {
|
|
3468
3588
|
owned.add(entry.file);
|
|
3469
3589
|
for (const file of planFiles(all)) {
|
|
3470
|
-
if (
|
|
3590
|
+
if (existsSync20(join19(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
|
|
3471
3591
|
}
|
|
3472
3592
|
}
|
|
3473
3593
|
for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
|
|
@@ -3476,7 +3596,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
3476
3596
|
if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
|
|
3477
3597
|
const findings = [];
|
|
3478
3598
|
for (const path of owned) {
|
|
3479
|
-
const source = readFileSync12(
|
|
3599
|
+
const source = readFileSync12(join19(appRoot, path), "utf8");
|
|
3480
3600
|
let wrapper;
|
|
3481
3601
|
if (entry && path === entry.file) {
|
|
3482
3602
|
const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
|
|
@@ -3497,34 +3617,34 @@ Move what you want to keep, or pass --force to delete anyway.`);
|
|
|
3497
3617
|
}
|
|
3498
3618
|
const removed = [...owned].sort();
|
|
3499
3619
|
for (const path of removed) {
|
|
3500
|
-
unlinkSync2(
|
|
3620
|
+
unlinkSync2(join19(appRoot, path));
|
|
3501
3621
|
removeEmptyDirs(appRoot, path);
|
|
3502
|
-
|
|
3622
|
+
log2(`${pc15.red("remove".padEnd(9))} ${path}`);
|
|
3503
3623
|
}
|
|
3504
|
-
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)),
|
|
3624
|
+
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log2);
|
|
3505
3625
|
const table = entry ? tableExport(entry.resource) : void 0;
|
|
3506
|
-
if (table &&
|
|
3507
|
-
const seeds = readdirSync7(
|
|
3508
|
-
(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"))
|
|
3509
3629
|
);
|
|
3510
|
-
if (seeds.length)
|
|
3630
|
+
if (seeds.length) log2(pc15.yellow(`
|
|
3511
3631
|
These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
|
|
3512
3632
|
}
|
|
3513
3633
|
let migrations = [];
|
|
3514
3634
|
if (!options2.skipMigration && entry) {
|
|
3515
3635
|
const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
|
|
3516
3636
|
migrations = result.files;
|
|
3517
|
-
for (const file of migrations)
|
|
3518
|
-
for (const repair of result.repairs)
|
|
3519
|
-
if (migrations.length)
|
|
3520
|
-
Next: ${
|
|
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}`));
|
|
3639
|
+
if (migrations.length) log2(`
|
|
3640
|
+
Next: ${pc15.bold("flare migrate")} to drop the table locally.`);
|
|
3521
3641
|
}
|
|
3522
3642
|
return { removed, migrations };
|
|
3523
3643
|
}
|
|
3524
3644
|
|
|
3525
3645
|
// src/commands/role.ts
|
|
3526
3646
|
import { humanize } from "@flaredev/core";
|
|
3527
|
-
import
|
|
3647
|
+
import pc16 from "picocolors";
|
|
3528
3648
|
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3529
3649
|
function roleInsertSql(name, label) {
|
|
3530
3650
|
if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
|
|
@@ -3557,7 +3677,7 @@ ${result.output.trim()}`);
|
|
|
3557
3677
|
return (first?.results ?? []).map((row) => row.name);
|
|
3558
3678
|
}
|
|
3559
3679
|
async function addRole(name, options2 = {}) {
|
|
3560
|
-
const { appRoot, binding, wrangler, target, log } = context2(options2);
|
|
3680
|
+
const { appRoot, binding, wrangler, target, log: log2 } = context2(options2);
|
|
3561
3681
|
const sql2 = roleInsertSql(name, options2.label ?? humanize(name));
|
|
3562
3682
|
const result = await runWrangler(wrangler, ["d1", "execute", binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
|
|
3563
3683
|
if (result.code !== 0) {
|
|
@@ -3569,24 +3689,137 @@ ${result.output.trim()}`);
|
|
|
3569
3689
|
}
|
|
3570
3690
|
const [first] = extractJson(result.output);
|
|
3571
3691
|
const added = first?.results?.[0];
|
|
3572
|
-
|
|
3573
|
-
|
|
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" : ""}`));
|
|
3574
3694
|
return 0;
|
|
3575
3695
|
}
|
|
3576
3696
|
|
|
3577
3697
|
// src/commands/seed.ts
|
|
3578
|
-
import { existsSync 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";
|
|
3701
|
+
import { createJiti as createJiti4 } from "jiti";
|
|
3702
|
+
import pc17 from "picocolors";
|
|
3703
|
+
|
|
3704
|
+
// src/seed/bulk.ts
|
|
3705
|
+
var MAX_STATEMENT_BYTES = 8e4;
|
|
3706
|
+
var STATEMENTS_PER_CALL = 25;
|
|
3707
|
+
function sqlLiteral(value) {
|
|
3708
|
+
if (value === null || value === void 0) return "NULL";
|
|
3709
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
|
3710
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
3711
|
+
if (value instanceof Date) return String(value.getTime());
|
|
3712
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
3713
|
+
return `'${text.replaceAll("'", "''")}'`;
|
|
3714
|
+
}
|
|
3715
|
+
var tuple = (row, columns) => `(${columns.map((column) => sqlLiteral(row[column])).join(",")})`;
|
|
3716
|
+
function* insertStatements(table, columns, rows, maxBytes = MAX_STATEMENT_BYTES) {
|
|
3717
|
+
const head = `INSERT INTO "${table}" (${columns.map((column) => `"${column}"`).join(",")}) VALUES `;
|
|
3718
|
+
let values = [];
|
|
3719
|
+
let bytes = head.length;
|
|
3720
|
+
for (const row of rows) {
|
|
3721
|
+
const text = tuple(row, columns);
|
|
3722
|
+
if (values.length > 0 && bytes + text.length + 1 > maxBytes) {
|
|
3723
|
+
yield { sql: `${head}${values.join(",")};`, rows: values.length };
|
|
3724
|
+
values = [];
|
|
3725
|
+
bytes = head.length;
|
|
3726
|
+
}
|
|
3727
|
+
values.push(text);
|
|
3728
|
+
bytes += text.length + 1;
|
|
3729
|
+
}
|
|
3730
|
+
if (values.length > 0) yield { sql: `${head}${values.join(",")};`, rows: values.length };
|
|
3731
|
+
}
|
|
3732
|
+
async function bulkInsert(sink, options2) {
|
|
3733
|
+
const perCall = options2.statementsPerCall ?? STATEMENTS_PER_CALL;
|
|
3734
|
+
let written = 0;
|
|
3735
|
+
let batch = [];
|
|
3736
|
+
let batchRows = 0;
|
|
3737
|
+
const flush = async () => {
|
|
3738
|
+
if (batch.length === 0) return;
|
|
3739
|
+
await sink.exec(batch.join("\n"));
|
|
3740
|
+
written += batchRows;
|
|
3741
|
+
batch = [];
|
|
3742
|
+
batchRows = 0;
|
|
3743
|
+
options2.onProgress?.(written);
|
|
3744
|
+
};
|
|
3745
|
+
for (const statement of insertStatements(options2.table, options2.columns, options2.rows, options2.maxBytes)) {
|
|
3746
|
+
batch.push(statement.sql);
|
|
3747
|
+
batchRows += statement.rows;
|
|
3748
|
+
if (batch.length === perCall) await flush();
|
|
3749
|
+
}
|
|
3750
|
+
await flush();
|
|
3751
|
+
return written;
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
// src/seed/insert-many.ts
|
|
3755
|
+
var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
|
|
3756
|
+
function createInsertMany(sink, drizzle, onProgress) {
|
|
3757
|
+
return async function insertMany(table, count, build) {
|
|
3758
|
+
const name = drizzle.getTableName(table);
|
|
3759
|
+
const columns = drizzle.getTableColumns(table);
|
|
3760
|
+
if (typeof count === "number" && !build) throw new Error(`insertMany(${name}, ${count}) needs a function to build each row.`);
|
|
3761
|
+
const source = typeof count === "number" ? (function* () {
|
|
3762
|
+
for (let i = 0; i < count; i++) yield build(i);
|
|
3763
|
+
})() : isIterable(count) ? count : [];
|
|
3764
|
+
const iterator = source[Symbol.iterator]();
|
|
3765
|
+
const first = iterator.next();
|
|
3766
|
+
if (first.done) return 0;
|
|
3767
|
+
const keys = new Set(Object.keys(first.value));
|
|
3768
|
+
for (const [key, column] of Object.entries(columns)) if (column.defaultFn) keys.add(key);
|
|
3769
|
+
const used = Object.keys(columns).filter((key) => keys.has(key));
|
|
3770
|
+
const unknownKeys = [...keys].filter((key) => !columns[key]);
|
|
3771
|
+
if (unknownKeys.length) throw new Error(`${name} has no column called ${unknownKeys.join(", ")}.`);
|
|
3772
|
+
const toRow = (value) => {
|
|
3773
|
+
const row = {};
|
|
3774
|
+
for (const key of used) {
|
|
3775
|
+
const column = columns[key];
|
|
3776
|
+
let raw = value[key];
|
|
3777
|
+
if (raw === void 0 && column.defaultFn) raw = column.defaultFn();
|
|
3778
|
+
row[column.name] = raw === void 0 || raw === null ? null : column.mapToDriverValue?.(raw) ?? raw;
|
|
3779
|
+
}
|
|
3780
|
+
return row;
|
|
3781
|
+
};
|
|
3782
|
+
const rows = (function* () {
|
|
3783
|
+
yield toRow(first.value);
|
|
3784
|
+
for (let next = iterator.next(); !next.done; next = iterator.next()) yield toRow(next.value);
|
|
3785
|
+
})();
|
|
3786
|
+
return bulkInsert(sink, {
|
|
3787
|
+
table: name,
|
|
3788
|
+
columns: used.map((key) => columns[key].name),
|
|
3789
|
+
rows,
|
|
3790
|
+
onProgress: onProgress && ((written) => onProgress(written, name))
|
|
3791
|
+
});
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
// src/seed/local-d1.ts
|
|
3579
3796
|
import { createRequire } from "module";
|
|
3580
|
-
import { join as
|
|
3797
|
+
import { join as join20 } from "path";
|
|
3581
3798
|
import { pathToFileURL } from "url";
|
|
3582
|
-
import { kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
|
|
3583
|
-
import { createJiti as createJiti4 } from "jiti";
|
|
3584
|
-
import pc15 from "picocolors";
|
|
3585
|
-
var SEEDS_DIR = "seeds";
|
|
3586
3799
|
var LOCAL_STATE = ".wrangler/state/v3";
|
|
3800
|
+
async function openLocalD1(appRoot, binding) {
|
|
3801
|
+
process.env.WRANGLER_LOG ??= "error";
|
|
3802
|
+
const appRequire = createRequire(join20(appRoot, "package.json"));
|
|
3803
|
+
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3804
|
+
const { env, dispose } = await wrangler.getPlatformProxy({
|
|
3805
|
+
configPath: join20(appRoot, "wrangler.jsonc"),
|
|
3806
|
+
persist: { path: join20(appRoot, LOCAL_STATE) }
|
|
3807
|
+
});
|
|
3808
|
+
const bindings = env;
|
|
3809
|
+
const name = binding ?? "DB";
|
|
3810
|
+
const db = bindings[name];
|
|
3811
|
+
if (!db) {
|
|
3812
|
+
const available = Object.keys(bindings).filter((key) => typeof bindings[key]?.prepare === "function");
|
|
3813
|
+
throw new Error(`No "${name}" database binding in wrangler.jsonc.${available.length ? ` Available: ${available.join(", ")}.` : ""}`);
|
|
3814
|
+
}
|
|
3815
|
+
return { db, env: bindings, dispose };
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
// src/commands/seed.ts
|
|
3819
|
+
var SEEDS_DIR = "seeds";
|
|
3587
3820
|
function seedFiles(appRoot, names) {
|
|
3588
|
-
const dir =
|
|
3589
|
-
const all =
|
|
3821
|
+
const dir = join21(appRoot, SEEDS_DIR);
|
|
3822
|
+
const all = existsSync21(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
|
|
3590
3823
|
if (!names?.length) return all;
|
|
3591
3824
|
return names.map((name) => {
|
|
3592
3825
|
const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
|
|
@@ -3597,65 +3830,66 @@ function seedFiles(appRoot, names) {
|
|
|
3597
3830
|
});
|
|
3598
3831
|
}
|
|
3599
3832
|
async function runSeeds(options2 = {}) {
|
|
3600
|
-
const
|
|
3833
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3601
3834
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3602
3835
|
const files = seedFiles(appRoot, options2.names);
|
|
3603
3836
|
if (files.length === 0) {
|
|
3604
|
-
|
|
3837
|
+
log2(`No seeds found. Create one with ${pc17.bold("flare seed:make <name>")}.`);
|
|
3605
3838
|
return;
|
|
3606
3839
|
}
|
|
3607
|
-
const jiti = createJiti4(
|
|
3608
|
-
const appRequire = createRequire(join19(appRoot, "package.json"));
|
|
3609
|
-
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3840
|
+
const jiti = createJiti4(join21(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
|
|
3610
3841
|
const { drizzle } = await jiti.import("drizzle-orm/d1");
|
|
3611
|
-
const
|
|
3612
|
-
const
|
|
3613
|
-
|
|
3614
|
-
persist: { path: join19(appRoot, LOCAL_STATE) }
|
|
3615
|
-
});
|
|
3842
|
+
const drizzleHelpers = await jiti.import("drizzle-orm");
|
|
3843
|
+
const schema = await jiti.import(join21(appRoot, "db/schema.ts"));
|
|
3844
|
+
const local = await openLocalD1(appRoot);
|
|
3616
3845
|
try {
|
|
3617
|
-
const
|
|
3618
|
-
|
|
3619
|
-
const db = drizzle(bindings.DB, { schema });
|
|
3846
|
+
const db = drizzle(local.db, { schema });
|
|
3847
|
+
const insertMany = createInsertMany(local.db, drizzleHelpers);
|
|
3620
3848
|
for (const file of files) {
|
|
3621
|
-
const mod = await jiti.import(
|
|
3849
|
+
const mod = await jiti.import(join21(appRoot, SEEDS_DIR, file));
|
|
3622
3850
|
if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
|
|
3623
|
-
|
|
3624
|
-
await mod.default({ db, env:
|
|
3851
|
+
log2(`${pc17.cyan("seed")} ${file}`);
|
|
3852
|
+
await mod.default({ db, env: local.env, insertMany, fake: createFake(), log: (message) => log2(` ${message}`) });
|
|
3625
3853
|
}
|
|
3626
|
-
|
|
3854
|
+
log2(pc17.green(`
|
|
3627
3855
|
Ran ${files.length} seed(s) against the local database.`));
|
|
3628
3856
|
} finally {
|
|
3629
|
-
await dispose();
|
|
3857
|
+
await local.dispose();
|
|
3630
3858
|
}
|
|
3631
3859
|
}
|
|
3632
|
-
function sampleValue(key, def
|
|
3860
|
+
function sampleValue(key, def) {
|
|
3861
|
+
const name = key.toLowerCase();
|
|
3633
3862
|
switch (def.kind) {
|
|
3634
3863
|
case "string":
|
|
3635
|
-
if (def.format === "email") return
|
|
3636
|
-
if (def.format === "url") return
|
|
3637
|
-
if (def.format === "tel") return
|
|
3638
|
-
if (def.format === "domain") return
|
|
3639
|
-
if (def.format === "country") return
|
|
3640
|
-
if (def.format === "color") return
|
|
3641
|
-
if (def.format === "slug") return
|
|
3642
|
-
return
|
|
3864
|
+
if (def.format === "email") return "fake.email()";
|
|
3865
|
+
if (def.format === "url") return "fake.url()";
|
|
3866
|
+
if (def.format === "tel") return "fake.phone()";
|
|
3867
|
+
if (def.format === "domain") return "fake.domain()";
|
|
3868
|
+
if (def.format === "country") return "fake.country()";
|
|
3869
|
+
if (def.format === "color") return "fake.color()";
|
|
3870
|
+
if (def.format === "slug") return "fake.slug()";
|
|
3871
|
+
if (name.includes("email")) return "fake.email()";
|
|
3872
|
+
if (name.includes("phone") || name.includes("mobile")) return "fake.phone()";
|
|
3873
|
+
if (name.includes("company")) return "fake.company()";
|
|
3874
|
+
if (name.includes("city")) return "fake.city()";
|
|
3875
|
+
if (name.includes("name")) return "fake.fullName()";
|
|
3876
|
+
return "fake.words(2)";
|
|
3643
3877
|
case "text":
|
|
3644
|
-
return
|
|
3878
|
+
return "fake.paragraph()";
|
|
3645
3879
|
case "int":
|
|
3646
|
-
return "
|
|
3880
|
+
return "fake.int(1, 1000)";
|
|
3647
3881
|
case "float":
|
|
3648
|
-
return "
|
|
3882
|
+
return "fake.float(1, 1000)";
|
|
3649
3883
|
case "boolean":
|
|
3650
|
-
return "
|
|
3884
|
+
return "fake.bool()";
|
|
3651
3885
|
case "date":
|
|
3652
|
-
return "
|
|
3886
|
+
return "fake.date()";
|
|
3653
3887
|
case "datetime":
|
|
3654
|
-
return "
|
|
3888
|
+
return "fake.datetime()";
|
|
3655
3889
|
case "enum":
|
|
3656
|
-
return JSON.stringify(def.options
|
|
3890
|
+
return `fake.pick(${JSON.stringify(def.options)})`;
|
|
3657
3891
|
case "multiselect":
|
|
3658
|
-
return JSON.stringify(def.options
|
|
3892
|
+
return `fake.some(${JSON.stringify(def.options)}, ${Math.max(1, def.minItems ?? 1)})`;
|
|
3659
3893
|
case "file":
|
|
3660
3894
|
return void 0;
|
|
3661
3895
|
case "belongsTo":
|
|
@@ -3666,9 +3900,10 @@ function renderSeed(resource) {
|
|
|
3666
3900
|
if (!resource) {
|
|
3667
3901
|
return `import { defineSeed } from "@flaredev/core";
|
|
3668
3902
|
|
|
3669
|
-
export default defineSeed(async ({ db, log }) => {
|
|
3903
|
+
export default defineSeed(async ({ db, insertMany, fake, log }) => {
|
|
3670
3904
|
// Insert rows with Drizzle, e.g.:
|
|
3671
3905
|
// await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
|
|
3906
|
+
// Or many at once, batched: insertMany(contacts, 10_000, () => ({ name: fake.fullName() }));
|
|
3672
3907
|
log("nothing to seed yet");
|
|
3673
3908
|
});
|
|
3674
3909
|
`;
|
|
@@ -3677,58 +3912,369 @@ export default defineSeed(async ({ db, log }) => {
|
|
|
3677
3912
|
const lines = [];
|
|
3678
3913
|
const todos = [];
|
|
3679
3914
|
for (const [key, def] of storedFields2(resource)) {
|
|
3680
|
-
const value = sampleValue(key, def
|
|
3915
|
+
const value = sampleValue(key, def);
|
|
3681
3916
|
if (value !== void 0) lines.push(` ${key}: ${value},`);
|
|
3682
3917
|
else if (def.required) todos.push(` // ${key}: TODO (${def.kind === "belongsTo" ? `id of an existing ${def.target}` : "R2 object key"}),`);
|
|
3683
3918
|
}
|
|
3684
3919
|
return `import { defineSeed } from "@flaredev/core";
|
|
3685
3920
|
import { ${table} } from "@/db/schema";
|
|
3686
3921
|
|
|
3687
|
-
|
|
3688
|
-
|
|
3922
|
+
/** How many to create. insertMany batches them, so hundreds of thousands are fine. */
|
|
3923
|
+
const COUNT = 50;
|
|
3924
|
+
|
|
3925
|
+
export default defineSeed(async ({ insertMany, fake, log }) => {
|
|
3926
|
+
const rows = await insertMany(${table}, COUNT, () => ({
|
|
3689
3927
|
${[...lines, ...todos].join("\n")}
|
|
3690
3928
|
}));
|
|
3691
|
-
|
|
3692
|
-
log(\`inserted \${rows.length} ${resource.pluralLabel.toLowerCase()}\`);
|
|
3929
|
+
log(\`inserted \${rows.toLocaleString()} ${resource.pluralLabel.toLowerCase()}\`);
|
|
3693
3930
|
});
|
|
3694
3931
|
`;
|
|
3695
3932
|
}
|
|
3696
3933
|
async function makeSeed(rawName, options2 = {}) {
|
|
3697
|
-
const
|
|
3934
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3698
3935
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3699
|
-
const name =
|
|
3936
|
+
const name = kebabCase4(rawName);
|
|
3700
3937
|
if (!name) throw new Error(`Invalid seed name "${rawName}".`);
|
|
3701
3938
|
const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
|
|
3702
|
-
const path =
|
|
3703
|
-
if (
|
|
3939
|
+
const path = join21(appRoot, relative2);
|
|
3940
|
+
if (existsSync21(path)) throw new Error(`${relative2} already exists.`);
|
|
3704
3941
|
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
3705
3942
|
let resource;
|
|
3706
3943
|
if (options2.resource) {
|
|
3707
3944
|
resource = resources.find((r) => r.name.toLowerCase() === options2.resource.toLowerCase());
|
|
3708
3945
|
if (!resource) throw new Error(`No resource "${options2.resource}". Available: ${resources.map((r) => r.name).join(", ") || "none"}.`);
|
|
3709
3946
|
} else {
|
|
3710
|
-
resource = resources.find((r) => [
|
|
3947
|
+
resource = resources.find((r) => [kebabCase4(r.name), r.slug].includes(name));
|
|
3711
3948
|
}
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
Run it with ${
|
|
3949
|
+
mkdirSync11(join21(appRoot, SEEDS_DIR), { recursive: true });
|
|
3950
|
+
writeFileSync14(path, renderSeed(resource));
|
|
3951
|
+
log2(`${pc17.green("create".padEnd(9))} ${relative2}`);
|
|
3952
|
+
log2(`
|
|
3953
|
+
Run it with ${pc17.bold(`flare seed ${name}`)}.`);
|
|
3717
3954
|
return relative2;
|
|
3718
3955
|
}
|
|
3719
3956
|
|
|
3957
|
+
// src/commands/seed-resource.ts
|
|
3958
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync15 } from "fs";
|
|
3959
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3960
|
+
import { join as join22 } from "path";
|
|
3961
|
+
import pc18 from "picocolors";
|
|
3962
|
+
|
|
3963
|
+
// src/seed/rows.ts
|
|
3964
|
+
import { columnName as columnName2, createFake as createFake2, storedFields as storedFields3 } from "@flaredev/core";
|
|
3965
|
+
var has = (key, ...words) => words.some((word) => key.includes(word));
|
|
3966
|
+
function stringValue(key, def, fake, resource, index) {
|
|
3967
|
+
switch (def.format) {
|
|
3968
|
+
case "email":
|
|
3969
|
+
return fake.email();
|
|
3970
|
+
case "url":
|
|
3971
|
+
return fake.url();
|
|
3972
|
+
case "tel":
|
|
3973
|
+
return fake.phone();
|
|
3974
|
+
case "domain":
|
|
3975
|
+
return fake.domain();
|
|
3976
|
+
case "country":
|
|
3977
|
+
return fake.country();
|
|
3978
|
+
case "color":
|
|
3979
|
+
return fake.color();
|
|
3980
|
+
case "slug":
|
|
3981
|
+
return fake.slug();
|
|
3982
|
+
}
|
|
3983
|
+
const name = key.toLowerCase();
|
|
3984
|
+
const organisation = has(resource.name.toLowerCase(), "company", "vendor", "supplier", "organisation", "organization", "account", "team", "store", "brand");
|
|
3985
|
+
let value;
|
|
3986
|
+
if (has(name, "email")) value = fake.email();
|
|
3987
|
+
else if (has(name, "phone", "mobile", "tel", "whatsapp")) value = fake.phone();
|
|
3988
|
+
else if (has(name, "firstname", "givenname")) value = fake.firstName();
|
|
3989
|
+
else if (has(name, "lastname", "surname", "familyname")) value = fake.lastName();
|
|
3990
|
+
else if (has(name, "company", "organisation", "organization", "employer", "vendor", "supplier", "business")) value = fake.company();
|
|
3991
|
+
else if (has(name, "city", "town")) value = fake.city();
|
|
3992
|
+
else if (has(name, "country")) value = fake.country();
|
|
3993
|
+
else if (has(name, "address", "street")) value = `${fake.int(1, 240)} ${fake.words(1)} Street, ${fake.city()}`;
|
|
3994
|
+
else if (has(name, "website", "url", "link")) value = fake.url();
|
|
3995
|
+
else if (has(name, "slug", "handle", "username")) value = fake.slug();
|
|
3996
|
+
else if (has(name, "colour", "color")) value = fake.color();
|
|
3997
|
+
else if (has(name, "code", "sku", "reference", "number")) value = `${fake.words(1).slice(0, 3).toUpperCase()}-${String(index + 1).padStart(6, "0")}`;
|
|
3998
|
+
else if (has(name, "title", "position", "role", "job")) value = fake.jobTitle();
|
|
3999
|
+
else if (has(name, "name")) value = organisation ? fake.company() : fake.fullName();
|
|
4000
|
+
else if (has(name, "description", "summary", "note", "bio", "about", "message", "comment")) value = fake.sentence();
|
|
4001
|
+
else value = `${fake.words(2)}`.replace(/^./, (letter) => letter.toUpperCase());
|
|
4002
|
+
if (def.maxLength && value.length > def.maxLength) value = value.slice(0, def.maxLength);
|
|
4003
|
+
if (def.minLength && value.length < def.minLength) value = value.padEnd(def.minLength, "x");
|
|
4004
|
+
return value;
|
|
4005
|
+
}
|
|
4006
|
+
function numberValue(key, def, fake) {
|
|
4007
|
+
const name = key.toLowerCase();
|
|
4008
|
+
const money = has(name, "price", "amount", "total", "cost", "fee", "revenue", "salary", "balance");
|
|
4009
|
+
let min = def.min;
|
|
4010
|
+
let max = def.max;
|
|
4011
|
+
if (min === void 0 || max === void 0) {
|
|
4012
|
+
const [low, high] = money ? [5, 5e3] : has(name, "age") ? [18, 78] : has(name, "year") ? [2015, (/* @__PURE__ */ new Date()).getFullYear()] : has(name, "quantity", "qty", "stock", "seats", "items") ? [1, 250] : has(name, "percent", "rate", "score", "discount") ? [0, 100] : [1, 1e3];
|
|
4013
|
+
min = min ?? low;
|
|
4014
|
+
max = max ?? high;
|
|
4015
|
+
}
|
|
4016
|
+
return def.kind === "int" ? fake.int(Math.ceil(min), Math.floor(max)) : fake.float(min, max, money ? 2 : 2);
|
|
4017
|
+
}
|
|
4018
|
+
function fieldValue(key, def, fake, resource, parentIds, index) {
|
|
4019
|
+
const name = key.toLowerCase();
|
|
4020
|
+
if (!def.required && def.kind !== "belongsTo" && fake.bool(0.15)) return null;
|
|
4021
|
+
switch (def.kind) {
|
|
4022
|
+
case "string":
|
|
4023
|
+
return stringValue(key, def, fake, resource, index);
|
|
4024
|
+
case "text":
|
|
4025
|
+
return fake.paragraph(fake.int(1, 3));
|
|
4026
|
+
case "int":
|
|
4027
|
+
case "float":
|
|
4028
|
+
return numberValue(key, def, fake);
|
|
4029
|
+
case "boolean":
|
|
4030
|
+
return fake.bool(has(name, "active", "enabled", "published", "verified", "visible") ? 0.85 : 0.4);
|
|
4031
|
+
case "date":
|
|
4032
|
+
return has(name, "due", "expire", "end", "renew") ? fake.date(-90) : fake.date();
|
|
4033
|
+
case "datetime":
|
|
4034
|
+
return has(name, "due", "expire", "end", "renew") ? fake.datetime(-90) : fake.datetime();
|
|
4035
|
+
case "enum":
|
|
4036
|
+
return fake.pick(def.options);
|
|
4037
|
+
case "multiselect":
|
|
4038
|
+
return fake.some(def.options, Math.max(1, def.minItems ?? 1), def.maxItems ?? def.options.length);
|
|
4039
|
+
case "file":
|
|
4040
|
+
return def.required ? `seed/${resource.table}/${fake.count}.bin` : null;
|
|
4041
|
+
case "belongsTo": {
|
|
4042
|
+
const ids = parentIds.get(def.target) ?? [];
|
|
4043
|
+
if (ids.length === 0) {
|
|
4044
|
+
if (def.required) {
|
|
4045
|
+
throw new Error(`${resource.name}.${key} needs a ${def.target} to belong to, and there are none. Seed ${def.target} first.`);
|
|
4046
|
+
}
|
|
4047
|
+
return null;
|
|
4048
|
+
}
|
|
4049
|
+
return fake.pick(ids);
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
function uniquify(value, row) {
|
|
4054
|
+
const trailing = /(\d+)$/.exec(value)?.[1];
|
|
4055
|
+
if (trailing && Number(trailing) === row) return value;
|
|
4056
|
+
const at = value.indexOf("@");
|
|
4057
|
+
if (at > 0) return `${value.slice(0, at)}.${row}${value.slice(at)}`;
|
|
4058
|
+
if (/^[a-z0-9.-]+$/.test(value)) return `${value}-${row}`;
|
|
4059
|
+
return `${value} ${row}`;
|
|
4060
|
+
}
|
|
4061
|
+
function seedColumns(resource) {
|
|
4062
|
+
return ["id", ...storedFields3(resource).map(([key]) => columnName2(key)), "created_at", "updated_at"];
|
|
4063
|
+
}
|
|
4064
|
+
function* seedRows(resource, count, options2 = {}) {
|
|
4065
|
+
const fake = createFake2(options2.seed ?? Math.floor(Math.random() * 2 ** 31));
|
|
4066
|
+
const parentIds = options2.parentIds ?? /* @__PURE__ */ new Map();
|
|
4067
|
+
const fields = storedFields3(resource);
|
|
4068
|
+
const now = options2.now ?? Date.now();
|
|
4069
|
+
const start = options2.startIndex ?? 0;
|
|
4070
|
+
for (let i = 0; i < count; i++) {
|
|
4071
|
+
const number = start + i;
|
|
4072
|
+
const row = { id: fake.id() };
|
|
4073
|
+
for (const [key, def] of fields) {
|
|
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);
|
|
4076
|
+
row[columnName2(key)] = value;
|
|
4077
|
+
}
|
|
4078
|
+
const created = now - fake.int(0, 365) * 864e5 - fake.int(0, 86399999);
|
|
4079
|
+
row.created_at = created;
|
|
4080
|
+
row.updated_at = created + fake.int(0, Math.min(now - created, 30 * 864e5));
|
|
4081
|
+
yield row;
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|
|
4127
|
+
var PARENT_SAMPLE = 5e3;
|
|
4128
|
+
function parseCount(value, fallback = 25) {
|
|
4129
|
+
if (value === void 0 || value === "") return fallback;
|
|
4130
|
+
if (typeof value === "number") return Math.floor(value);
|
|
4131
|
+
const text = String(value).trim().toLowerCase().replaceAll(",", "").replaceAll("_", "").replaceAll(" ", "");
|
|
4132
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(text);
|
|
4133
|
+
if (!match) throw new Error(`"${value}" is not a number of rows. Try 1000, 25k or 1m.`);
|
|
4134
|
+
const size = Number(match[1]) * (match[2] === "k" ? 1e3 : match[2] === "m" ? 1e6 : 1);
|
|
4135
|
+
if (size < 1) throw new Error("Ask for at least one row.");
|
|
4136
|
+
return Math.floor(size);
|
|
4137
|
+
}
|
|
4138
|
+
function pickResource(resources, name) {
|
|
4139
|
+
const wanted = name.toLowerCase();
|
|
4140
|
+
const match = resources.find((resource) => [resource.name.toLowerCase(), resource.slug, resource.table].includes(wanted));
|
|
4141
|
+
if (!match) {
|
|
4142
|
+
throw new Error(`No resource "${name}". Available: ${resources.map((resource) => resource.name).join(", ") || "none"}.`);
|
|
4143
|
+
}
|
|
4144
|
+
return match;
|
|
4145
|
+
}
|
|
4146
|
+
function pickDatabase(databases, wanted) {
|
|
4147
|
+
if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
4148
|
+
if (wanted) {
|
|
4149
|
+
const match = databases.find((db) => db.binding === wanted || db.database_name === wanted);
|
|
4150
|
+
if (!match) throw new Error(`No D1 database "${wanted}". Available: ${databases.map((db) => db.binding).join(", ")}.`);
|
|
4151
|
+
return match;
|
|
4152
|
+
}
|
|
4153
|
+
if (databases.length > 1) throw new Error(`Several D1 databases; pick one with --database (${databases.map((db) => db.binding).join(", ")}).`);
|
|
4154
|
+
return databases[0];
|
|
4155
|
+
}
|
|
4156
|
+
async function parentsOf(resource, all, ids) {
|
|
4157
|
+
const targets = new Set(
|
|
4158
|
+
Object.values(resource.fields).filter((field) => field.kind === "belongsTo").map((field) => field.target)
|
|
4159
|
+
);
|
|
4160
|
+
const parents = /* @__PURE__ */ new Map();
|
|
4161
|
+
for (const target of targets) {
|
|
4162
|
+
const parent = all.find((candidate) => candidate.name === target);
|
|
4163
|
+
if (parent) parents.set(target, await ids(parent.table));
|
|
4164
|
+
}
|
|
4165
|
+
return parents;
|
|
4166
|
+
}
|
|
4167
|
+
async function seedResourceCommand(name, options2 = {}) {
|
|
4168
|
+
const progress = progressLine();
|
|
4169
|
+
const started = Date.now();
|
|
4170
|
+
try {
|
|
4171
|
+
const result = await seedResource(name, {
|
|
4172
|
+
...options2,
|
|
4173
|
+
onProgress: (written, total) => {
|
|
4174
|
+
const elapsed = Date.now() - started;
|
|
4175
|
+
progress.update(` ${pc18.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc18.dim(formatRate(written, elapsed))}`);
|
|
4176
|
+
}
|
|
4177
|
+
});
|
|
4178
|
+
progress.done();
|
|
4179
|
+
const { rows, ms, where } = result;
|
|
4180
|
+
console.log(
|
|
4181
|
+
`${pc18.green("\u2714")} Seeded ${pc18.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc18.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
|
|
4182
|
+
);
|
|
4183
|
+
return result;
|
|
4184
|
+
} catch (error) {
|
|
4185
|
+
progress.done();
|
|
4186
|
+
throw error;
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
async function seedResource(name, options2 = {}) {
|
|
4190
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
4191
|
+
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
4192
|
+
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
4193
|
+
const resource = pickResource(resources, name);
|
|
4194
|
+
const total = parseCount(options2.count);
|
|
4195
|
+
const where = options2.remote ? "remote" : "local";
|
|
4196
|
+
if (options2.remote && !options2.yes) {
|
|
4197
|
+
throw new Error(`Seeding the remote database writes ${total.toLocaleString()} rows to ${resource.table}. Re-run with --yes to confirm.`);
|
|
4198
|
+
}
|
|
4199
|
+
const columns = seedColumns(resource);
|
|
4200
|
+
const started = Date.now();
|
|
4201
|
+
let parents = /* @__PURE__ */ new Map();
|
|
4202
|
+
let startIndex = 0;
|
|
4203
|
+
const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents, startIndex });
|
|
4204
|
+
if (options2.remote) {
|
|
4205
|
+
const db = pickDatabase(readD1Databases(appRoot), options2.database);
|
|
4206
|
+
const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
|
|
4207
|
+
const target = ["--remote", ...options2.env ? ["--env", options2.env] : []];
|
|
4208
|
+
const query = async (sql2) => {
|
|
4209
|
+
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
|
|
4210
|
+
if (result.code !== 0) throw new Error(`Query failed on the remote database:
|
|
4211
|
+
${result.output.trim()}`);
|
|
4212
|
+
const start = result.output.indexOf("[");
|
|
4213
|
+
const parsed = JSON.parse(result.output.slice(start, result.output.lastIndexOf("]") + 1));
|
|
4214
|
+
return parsed[0]?.results ?? [];
|
|
4215
|
+
};
|
|
4216
|
+
parents = await parentsOf(resource, resources, async (table) => {
|
|
4217
|
+
const found = await query(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`);
|
|
4218
|
+
return found.map((row) => row.id);
|
|
4219
|
+
});
|
|
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`);
|
|
4226
|
+
try {
|
|
4227
|
+
const statements = [];
|
|
4228
|
+
if (options2.truncate) statements.push(`DELETE FROM "${resource.table}";`);
|
|
4229
|
+
let written = 0;
|
|
4230
|
+
for (const statement of insertStatements(resource.table, columns, rows())) {
|
|
4231
|
+
statements.push(statement.sql);
|
|
4232
|
+
written += statement.rows;
|
|
4233
|
+
options2.onProgress?.(written, total);
|
|
4234
|
+
}
|
|
4235
|
+
writeFileSync15(file, statements.join("\n"));
|
|
4236
|
+
log2(`${pc18.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc18.bold(db.database_name)}`);
|
|
4237
|
+
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
|
|
4238
|
+
if (result.code !== 0) throw new Error(`Seeding the remote database failed:
|
|
4239
|
+
${result.output.trim()}`);
|
|
4240
|
+
} finally {
|
|
4241
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
4242
|
+
}
|
|
4243
|
+
return { resource, rows: total, ms: Date.now() - started, where };
|
|
4244
|
+
}
|
|
4245
|
+
const local = await openLocalD1(appRoot, options2.database);
|
|
4246
|
+
try {
|
|
4247
|
+
parents = await parentsOf(resource, resources, async (table) => {
|
|
4248
|
+
const found = await local.db.prepare(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`).all();
|
|
4249
|
+
return (found.results ?? []).map((row) => row.id);
|
|
4250
|
+
});
|
|
4251
|
+
if (options2.truncate) await local.db.exec(`DELETE FROM "${resource.table}";`);
|
|
4252
|
+
else startIndex = await countRows(local.db, resource.table);
|
|
4253
|
+
const sink = { exec: (sql2) => local.db.exec(sql2) };
|
|
4254
|
+
const written = await bulkInsert(sink, {
|
|
4255
|
+
table: resource.table,
|
|
4256
|
+
columns,
|
|
4257
|
+
rows: rows(),
|
|
4258
|
+
onProgress: (count) => options2.onProgress?.(count, total)
|
|
4259
|
+
});
|
|
4260
|
+
return { resource, rows: written, ms: Date.now() - started, where };
|
|
4261
|
+
} finally {
|
|
4262
|
+
await local.dispose();
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
|
|
3720
4266
|
// src/commands/billing-sync.ts
|
|
3721
4267
|
import { createRequire as createRequire2 } from "module";
|
|
3722
|
-
import { existsSync as
|
|
3723
|
-
import { tmpdir as
|
|
3724
|
-
import { join as
|
|
4268
|
+
import { existsSync as existsSync22, mkdtempSync as mkdtempSync3, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync16 } from "fs";
|
|
4269
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
4270
|
+
import { join as join23 } from "path";
|
|
3725
4271
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
3726
|
-
import { kebabCase as
|
|
3727
|
-
import
|
|
4272
|
+
import { kebabCase as kebabCase5 } from "@flaredev/core";
|
|
4273
|
+
import pc19 from "picocolors";
|
|
3728
4274
|
function stripeKeys(appRoot) {
|
|
3729
4275
|
const map = {};
|
|
3730
|
-
const path =
|
|
3731
|
-
if (
|
|
4276
|
+
const path = join23(appRoot, ".dev.vars");
|
|
4277
|
+
if (existsSync22(path)) {
|
|
3732
4278
|
for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
|
|
3733
4279
|
const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
3734
4280
|
if (match) map[match[1]] = match[2].trim();
|
|
@@ -3749,7 +4295,7 @@ function planRows(catalog) {
|
|
|
3749
4295
|
return ok;
|
|
3750
4296
|
});
|
|
3751
4297
|
const currencies = new Set(usable.map((price) => price.currency));
|
|
3752
|
-
const base = product.metadata?.slug ||
|
|
4298
|
+
const base = product.metadata?.slug || kebabCase5(product.name);
|
|
3753
4299
|
for (const price of usable) {
|
|
3754
4300
|
const interval = price.recurring?.interval ?? null;
|
|
3755
4301
|
const parts = [base];
|
|
@@ -3806,16 +4352,16 @@ async function syncPortalConfiguration(stripe, rows, appName) {
|
|
|
3806
4352
|
return existing ? (await stripe.billingPortal.configurations.update(existing.id, settings)).id : (await stripe.billingPortal.configurations.create(settings)).id;
|
|
3807
4353
|
}
|
|
3808
4354
|
async function syncPlans(options2 = {}) {
|
|
3809
|
-
const
|
|
4355
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3810
4356
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3811
4357
|
const key = stripeKeys(appRoot).STRIPE_SECRET_KEY;
|
|
3812
4358
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
|
|
3813
4359
|
const db = readD1Databases(appRoot)[0];
|
|
3814
4360
|
if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
3815
|
-
const appRequire = createRequire2(
|
|
4361
|
+
const appRequire = createRequire2(join23(appRoot, "package.json"));
|
|
3816
4362
|
const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
|
|
3817
4363
|
const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
|
|
3818
|
-
const appName = JSON.parse(readFileSync13(
|
|
4364
|
+
const appName = JSON.parse(readFileSync13(join23(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
3819
4365
|
const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
|
|
3820
4366
|
const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
|
|
3821
4367
|
if (products.length === 0) {
|
|
@@ -3836,88 +4382,88 @@ async function syncPlans(options2 = {}) {
|
|
|
3836
4382
|
${existingResult.output.trim()}`);
|
|
3837
4383
|
}
|
|
3838
4384
|
const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
|
|
3839
|
-
const dir =
|
|
4385
|
+
const dir = mkdtempSync3(join23(tmpdir3(), "flare-sync-"));
|
|
3840
4386
|
try {
|
|
3841
|
-
const file =
|
|
3842
|
-
|
|
4387
|
+
const file = join23(dir, "sync-plans.sql");
|
|
4388
|
+
writeFileSync16(file, renderSyncSql(rows));
|
|
3843
4389
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3844
4390
|
if (result.code !== 0) throw new Error(`Could not write plans:
|
|
3845
4391
|
${result.output.trim()}`);
|
|
3846
4392
|
} finally {
|
|
3847
|
-
|
|
4393
|
+
rmSync4(dir, { recursive: true, force: true });
|
|
3848
4394
|
}
|
|
3849
4395
|
let created = 0;
|
|
3850
4396
|
for (const row of rows) {
|
|
3851
4397
|
const isNew = !known.has(row.stripePriceId);
|
|
3852
4398
|
if (isNew) created++;
|
|
3853
4399
|
const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
|
|
3854
|
-
|
|
4400
|
+
log2(`${(isNew ? pc19.green("create") : pc19.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc19.dim(price)}`);
|
|
3855
4401
|
}
|
|
3856
|
-
for (const name of skipped)
|
|
4402
|
+
for (const name of skipped) log2(pc19.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
|
|
3857
4403
|
const configuration = await syncPortalConfiguration(stripe, rows, appName);
|
|
3858
|
-
if (configuration)
|
|
3859
|
-
|
|
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(`
|
|
3860
4406
|
${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
|
|
3861
4407
|
return { created, updated: rows.length - created, skipped };
|
|
3862
4408
|
}
|
|
3863
4409
|
|
|
3864
4410
|
// src/commands/sync.ts
|
|
3865
|
-
import { existsSync as
|
|
3866
|
-
import { join as
|
|
3867
|
-
import
|
|
4411
|
+
import { existsSync as existsSync23 } from "fs";
|
|
4412
|
+
import { join as join24 } from "path";
|
|
4413
|
+
import pc20 from "picocolors";
|
|
3868
4414
|
async function syncTypes(options2 = {}) {
|
|
3869
|
-
const
|
|
4415
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3870
4416
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3871
4417
|
const all = await loadResources(appRoot);
|
|
3872
4418
|
const policies = await loadPolicies(appRoot);
|
|
3873
|
-
const tracksPolicies = policies.length > 0 ||
|
|
4419
|
+
const tracksPolicies = policies.length > 0 || existsSync23(join24(appRoot, "policies/index.ts"));
|
|
3874
4420
|
const plan = planFiles(all, tracksPolicies ? policies : void 0);
|
|
3875
|
-
if (!options2.check) ensureSupportFiles(appRoot,
|
|
4421
|
+
if (!options2.check) ensureSupportFiles(appRoot, log2);
|
|
3876
4422
|
const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
|
|
3877
|
-
logResults(results,
|
|
4423
|
+
logResults(results, log2);
|
|
3878
4424
|
const drift = results.filter((r) => r.outcome === "skipped-drift");
|
|
3879
4425
|
const unmarked = results.filter((r) => r.outcome === "skipped-unmarked");
|
|
3880
4426
|
const changed = results.filter((r) => r.outcome === "create" || r.outcome === "update");
|
|
3881
4427
|
const orphans = findOrphans(appRoot, all);
|
|
3882
4428
|
if (drift.length) {
|
|
3883
|
-
|
|
4429
|
+
log2(
|
|
3884
4430
|
`
|
|
3885
|
-
${
|
|
4431
|
+
${pc20.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
|
|
3886
4432
|
Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
|
|
3887
4433
|
);
|
|
3888
4434
|
}
|
|
3889
4435
|
if (unmarked.length) {
|
|
3890
|
-
|
|
3891
|
-
${
|
|
4436
|
+
log2(`
|
|
4437
|
+
${pc20.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
|
|
3892
4438
|
}
|
|
3893
4439
|
if (orphans.length) {
|
|
3894
4440
|
const resources = [...new Set(orphans.map((o) => o.resource))];
|
|
3895
|
-
|
|
3896
|
-
${
|
|
3897
|
-
for (const orphan of orphans)
|
|
3898
|
-
|
|
3899
|
-
`If the resource was deleted, remove them with ${
|
|
4441
|
+
log2(`
|
|
4442
|
+
${pc20.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
|
|
4443
|
+
for (const orphan of orphans) log2(` ${orphan.path}`);
|
|
4444
|
+
log2(
|
|
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.`
|
|
3900
4446
|
);
|
|
3901
4447
|
}
|
|
3902
4448
|
const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
|
|
3903
4449
|
if (tablesChanged && !options2.check) {
|
|
3904
|
-
|
|
3905
|
-
${
|
|
4450
|
+
log2(`
|
|
4451
|
+
${pc20.cyan("Tables changed.")} Create a migration with ${pc20.bold("flare gen migration <name> --from-schema")}, then ${pc20.bold("flare migrate")}.`);
|
|
3906
4452
|
}
|
|
3907
4453
|
const problems = drift.length + unmarked.length + orphans.length;
|
|
3908
4454
|
if (options2.check) {
|
|
3909
4455
|
const stale = changed.length;
|
|
3910
|
-
if (stale === 0 && problems === 0)
|
|
3911
|
-
else
|
|
3912
|
-
${
|
|
4456
|
+
if (stale === 0 && problems === 0) log2(pc20.green("Generated files are in sync with the resource descriptors."));
|
|
4457
|
+
else log2(`
|
|
4458
|
+
${pc20.red("Out of sync:")} ${stale} file(s) would change. Run ${pc20.bold("flare sync-types")}.`);
|
|
3913
4459
|
return stale + problems > 0 ? 1 : 0;
|
|
3914
4460
|
}
|
|
3915
|
-
if (changed.length === 0 && problems === 0)
|
|
4461
|
+
if (changed.length === 0 && problems === 0) log2(pc20.green("Everything is in sync."));
|
|
3916
4462
|
return problems > 0 ? 1 : 0;
|
|
3917
4463
|
}
|
|
3918
4464
|
|
|
3919
4465
|
// src/commands/user-role.ts
|
|
3920
|
-
import
|
|
4466
|
+
import pc21 from "picocolors";
|
|
3921
4467
|
var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3922
4468
|
var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
|
|
3923
4469
|
function userRoleSql(email, role) {
|
|
@@ -3926,13 +4472,13 @@ function userRoleSql(email, role) {
|
|
|
3926
4472
|
return `UPDATE "user" SET role = '${role}', updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE lower(email) = lower('${email}') RETURNING email, role`;
|
|
3927
4473
|
}
|
|
3928
4474
|
async function setUserRole(email, role, options2 = {}) {
|
|
3929
|
-
const
|
|
4475
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3930
4476
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3931
4477
|
const sql2 = userRoleSql(email, role);
|
|
3932
4478
|
const roles = await listRoles(options2);
|
|
3933
4479
|
if (roles && !roles.includes(role)) {
|
|
3934
|
-
|
|
3935
|
-
|
|
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" : ""}`));
|
|
3936
4482
|
return 1;
|
|
3937
4483
|
}
|
|
3938
4484
|
const db = readD1Databases(appRoot)[0];
|
|
@@ -3946,32 +4492,130 @@ ${result.output.trim()}`);
|
|
|
3946
4492
|
const [first] = extractJson(result.output);
|
|
3947
4493
|
const updated = first?.results ?? [];
|
|
3948
4494
|
if (updated.length === 0) {
|
|
3949
|
-
|
|
4495
|
+
log2(pc21.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
|
|
3950
4496
|
return 1;
|
|
3951
4497
|
}
|
|
3952
|
-
|
|
4498
|
+
log2(pc21.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
|
|
3953
4499
|
return 0;
|
|
3954
4500
|
}
|
|
3955
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
|
+
}
|
|
4526
|
+
return 0;
|
|
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
|
+
}
|
|
4577
|
+
|
|
3956
4578
|
// src/cli.ts
|
|
3957
4579
|
function createCli() {
|
|
3958
4580
|
const cli = cac("flare");
|
|
3959
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) => {
|
|
3960
|
-
const
|
|
4582
|
+
const wanted = !options2.skipInstall;
|
|
3961
4583
|
let answers = { theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
|
|
3962
|
-
if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers);
|
|
3963
|
-
const result = createApp(dir, { pm: options2.pm, install, ...answers });
|
|
3964
|
-
|
|
4584
|
+
if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers, toAppName(dir));
|
|
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;
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
printNextSteps(result, installed);
|
|
3965
4605
|
});
|
|
3966
|
-
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(
|
|
3967
|
-
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) => {
|
|
3968
4608
|
if (generator === "billing")
|
|
3969
4609
|
return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
|
|
3970
4610
|
if (generator === "security") return genSecurity({ force: options2.force, skipMigration: options2.skipMigration });
|
|
3971
4611
|
if (["resource", "migration", "policy"].includes(generator) && !name) {
|
|
3972
4612
|
throw new Error(`flare gen ${generator} needs a name, e.g. flare gen ${generator} ${generator === "migration" ? "add_phone_to_contacts" : "Contact"}`);
|
|
3973
4613
|
}
|
|
3974
|
-
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
|
+
}
|
|
3975
4619
|
if (generator === "migration") return genMigration(name, { fromSchema: options2.fromSchema });
|
|
3976
4620
|
if (generator === "policy") return genPolicy(name, { roles: options2.roles, deleteRoles: options2.deleteRoles, force: options2.force });
|
|
3977
4621
|
throw new Error(`Unknown generator "${generator}". Available: resource, migration, policy, billing, security.`);
|
|
@@ -3996,6 +4640,9 @@ function createCli() {
|
|
|
3996
4640
|
cli.command("seed [...names]", "Run seed files from seeds/ against the local D1 database").example("flare seed # every seed, in file-name order").example("flare seed contacts # just seeds/contacts.seed.ts").action(async (names) => {
|
|
3997
4641
|
await runSeeds({ names });
|
|
3998
4642
|
});
|
|
4643
|
+
cli.command("seed:resource <resource> [count]", "Fill a resource's table with sample rows built from its descriptor").option("--count <rows>", "How many rows: 1000, 25k, 1m (default: 25)").option("--remote", "Seed the deployed database instead of the local one (needs --yes)").option("--truncate", "Delete the table's rows first").option("--seed <number>", "Same number, same rows").option("--env <name>", "Wrangler environment").option("--database <binding>", "D1 binding, when the app has several").option("-y, --yes", "Confirm writing to the remote database").example("flare seed:resource Contact --count 1000").example("flare seed:resource Contact 1m # a million rows").example("flare seed:resource Contact 5k --remote --yes").action(async (resource, count, options2) => {
|
|
4644
|
+
await seedResourceCommand(resource, { ...options2, count: options2.count ?? count });
|
|
4645
|
+
});
|
|
3999
4646
|
cli.command("seed:make <name>", "Create seeds/<name>.seed.ts (with example rows for a matching resource)").option("--resource <name>", "Resource to write example rows for").action(async (name, options2) => {
|
|
4000
4647
|
await makeSeed(name, options2);
|
|
4001
4648
|
});
|
|
@@ -4022,7 +4669,7 @@ function createCli() {
|
|
|
4022
4669
|
cli.command(`${name} [...args]`, spec.description).allowUnknownOptions();
|
|
4023
4670
|
}
|
|
4024
4671
|
cli.help();
|
|
4025
|
-
cli.version(
|
|
4672
|
+
cli.version(FLARE_VERSION3);
|
|
4026
4673
|
return cli;
|
|
4027
4674
|
}
|
|
4028
4675
|
|
|
@@ -4041,6 +4688,6 @@ try {
|
|
|
4041
4688
|
}
|
|
4042
4689
|
}
|
|
4043
4690
|
} catch (error) {
|
|
4044
|
-
console.error(
|
|
4691
|
+
console.error(pc23.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
|
|
4045
4692
|
process.exitCode = 1;
|
|
4046
4693
|
}
|