@flaredev/cli 0.2.0 → 0.3.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 +751 -280
- package/package.json +2 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/dashboard/account/page.tsx +6 -4
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -39
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +106 -25
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +6 -9
- 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/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +166 -120
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -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 +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -281
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- 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/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -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/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -47
- package/templates/app/migrations/0000_init.sql +15 -3
- package/templates/app/migrations/meta/0000_snapshot.json +97 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- 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/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/dashboard/dashboard-nav.tsx +0 -35
- package/templates/app/lib/admin-nav.ts +0 -15
- /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}/query.ts +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,16 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import pc21 from "picocolors";
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
5
|
import { cac } from "cac";
|
|
6
|
-
import
|
|
6
|
+
import * as prompts3 from "@clack/prompts";
|
|
7
|
+
import { FLARE_VERSION as FLARE_VERSION3 } from "@flaredev/core";
|
|
7
8
|
|
|
8
9
|
// src/commands/create.ts
|
|
9
10
|
import { randomBytes } from "crypto";
|
|
10
|
-
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11
|
+
import { appendFileSync, copyFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11
12
|
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
|
|
13
|
+
import * as prompts from "@clack/prompts";
|
|
12
14
|
import pc from "picocolors";
|
|
13
15
|
import { FLARE_VERSION } from "@flaredev/core";
|
|
14
16
|
|
|
@@ -86,6 +88,11 @@ export const authConfig = {
|
|
|
86
88
|
social: ${JSON.stringify(providers).replace(/","/g, '", "')} as SocialProvider[],
|
|
87
89
|
/** Refuse password sign-in until the email address is verified. */
|
|
88
90
|
requireEmailVerification: false,
|
|
91
|
+
/**
|
|
92
|
+
* Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
93
|
+
* characters of the password's hash are sent, and an outage lets the password through.
|
|
94
|
+
*/
|
|
95
|
+
checkBreachedPasswords: true,
|
|
89
96
|
};
|
|
90
97
|
|
|
91
98
|
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
@@ -178,6 +185,43 @@ var APP_DEV_DEPENDENCIES = {
|
|
|
178
185
|
wrangler: "^4.132.0"
|
|
179
186
|
};
|
|
180
187
|
|
|
188
|
+
// src/terminal.ts
|
|
189
|
+
var isTTY = () => Boolean(process.stdout.isTTY);
|
|
190
|
+
function formatDuration(ms) {
|
|
191
|
+
const seconds = Math.round(ms / 1e3);
|
|
192
|
+
if (seconds < 60) return `${seconds}s`;
|
|
193
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
194
|
+
return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, "0")}m`;
|
|
195
|
+
}
|
|
196
|
+
var formatCount = (value) => value.toLocaleString();
|
|
197
|
+
function formatRate(count, ms, unit = "rows") {
|
|
198
|
+
if (ms <= 0) return `${formatCount(count)} ${unit}/s`;
|
|
199
|
+
return `${formatCount(Math.round(count / ms * 1e3))} ${unit}/s`;
|
|
200
|
+
}
|
|
201
|
+
function progressLine(everyMs = 120) {
|
|
202
|
+
if (!isTTY()) return { update: () => {
|
|
203
|
+
}, done: () => {
|
|
204
|
+
} };
|
|
205
|
+
let last = 0;
|
|
206
|
+
let width = 0;
|
|
207
|
+
const write = (message) => {
|
|
208
|
+
process.stdout.write(`\r${message.padEnd(width)}`);
|
|
209
|
+
width = Math.max(width, message.length);
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
update(message) {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
if (now - last < everyMs) return;
|
|
215
|
+
last = now;
|
|
216
|
+
write(message);
|
|
217
|
+
},
|
|
218
|
+
done() {
|
|
219
|
+
if (width > 0) process.stdout.write(`\r${" ".repeat(width)}\r`);
|
|
220
|
+
width = 0;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
181
225
|
// src/utils/fs.ts
|
|
182
226
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
183
227
|
import { dirname, join, resolve } from "path";
|
|
@@ -185,16 +229,19 @@ import { fileURLToPath } from "url";
|
|
|
185
229
|
var RENAMES = { _gitignore: ".gitignore" };
|
|
186
230
|
function copyTemplate(from, to, tokens) {
|
|
187
231
|
mkdirSync(to, { recursive: true });
|
|
232
|
+
let files = 0;
|
|
188
233
|
for (const entry of readdirSync(from)) {
|
|
189
234
|
const src = join(from, entry);
|
|
190
235
|
const dest = join(to, RENAMES[entry] ?? entry);
|
|
191
236
|
if (statSync(src).isDirectory()) {
|
|
192
|
-
copyTemplate(src, dest, tokens);
|
|
237
|
+
files += copyTemplate(src, dest, tokens);
|
|
193
238
|
continue;
|
|
194
239
|
}
|
|
195
240
|
const content = readFileSync(src, "utf8").replace(/__([A-Z_]+)__/g, (match, key) => tokens[key] ?? match);
|
|
196
241
|
writeFileSync(dest, content);
|
|
242
|
+
files++;
|
|
197
243
|
}
|
|
244
|
+
return files;
|
|
198
245
|
}
|
|
199
246
|
function writeJson(path, value) {
|
|
200
247
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -275,7 +322,7 @@ function createApp(target, options2 = {}) {
|
|
|
275
322
|
const theme = parseTheme(options2.theme);
|
|
276
323
|
const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
|
|
277
324
|
const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
278
|
-
copyTemplate(join2(templatesDir, "app"), dir, {
|
|
325
|
+
const files = copyTemplate(join2(templatesDir, "app"), dir, {
|
|
279
326
|
APP_NAME: name,
|
|
280
327
|
COMPAT_DATE: compatibilityDate,
|
|
281
328
|
PM: packageManager,
|
|
@@ -310,42 +357,40 @@ ${devVarsEntries(authProviders)}`
|
|
|
310
357
|
if (packageManager === "pnpm" && !inWorkspace) {
|
|
311
358
|
writeFileSync2(join2(dir, "pnpm-workspace.yaml"), "allowBuilds:\n esbuild: true\n workerd: true\n sharp: false\n");
|
|
312
359
|
}
|
|
313
|
-
const
|
|
360
|
+
const pinned = flarePackageSpec("core", dir, packageManager).startsWith("^");
|
|
361
|
+
if (pinned) copyLockfile(packageManager, dir);
|
|
362
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
363
|
+
log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
|
|
314
364
|
if (options2.install !== false) {
|
|
315
|
-
log(`${pc.green("\u2714")} Wrote the app to ${relative(process.cwd(), dir) || "."}`);
|
|
316
|
-
log(`${pc.cyan("\u25CF")} Installing dependencies with ${packageManager}. The first install downloads the Workers runtime and toolchain, so it can take a few minutes.
|
|
317
|
-
`);
|
|
318
365
|
const started = Date.now();
|
|
319
|
-
const code = run(packageManager, installArgs(packageManager), dir);
|
|
366
|
+
const code = (options2.installer ?? run)(packageManager, installArgs(packageManager), dir);
|
|
320
367
|
if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
|
|
321
|
-
|
|
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`);
|
|
368
|
+
if (!options2.installer) log2(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
|
|
328
369
|
}
|
|
329
370
|
return { dir, name, packageManager, inWorkspace };
|
|
330
371
|
}
|
|
331
|
-
function
|
|
332
|
-
const
|
|
333
|
-
|
|
372
|
+
function copyLockfile(packageManager, dir) {
|
|
373
|
+
const file = packageManager === "npm" ? "package-lock.json" : packageManager === "pnpm" ? "pnpm-lock.yaml" : void 0;
|
|
374
|
+
if (!file) return false;
|
|
375
|
+
const source = join2(templatesDir, "locks", file);
|
|
376
|
+
if (!existsSync2(source)) return false;
|
|
377
|
+
copyFileSync(source, join2(dir, file));
|
|
378
|
+
return true;
|
|
334
379
|
}
|
|
335
380
|
function printNextSteps(result, installed) {
|
|
336
381
|
const rel = relative(process.cwd(), result.dir) || ".";
|
|
337
382
|
const pm = result.packageManager;
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
383
|
+
const commands = [
|
|
384
|
+
[`cd ${rel}`, "your new app"],
|
|
385
|
+
...installed ? [] : [[`${pm} install`, "dependencies first"]],
|
|
386
|
+
[`${pm} run dev`, "http://localhost:3000"],
|
|
387
|
+
["npx flare gen resource Contact", "a table, API and admin screens"],
|
|
388
|
+
["npx flare seed:resource Contact 1000", "fill it with sample rows"]
|
|
389
|
+
];
|
|
390
|
+
const width = Math.max(...commands.map(([command]) => command.length)) + 2;
|
|
391
|
+
const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
|
|
392
|
+
prompts.note(steps.join("\n"), "Next");
|
|
393
|
+
prompts.outro(`${pc.bold(result.name)} is ready ${pc.dim("\xB7")} ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
|
|
349
394
|
}
|
|
350
395
|
|
|
351
396
|
// src/commands/gen-billing.ts
|
|
@@ -1394,12 +1439,12 @@ function onPath() {
|
|
|
1394
1439
|
const probe = spawnSync2(process.platform === "win32" ? "where" : "which", ["cloudflared"], { encoding: "utf8" });
|
|
1395
1440
|
return probe.status === 0 ? probe.stdout.split(/\r?\n/)[0]?.trim() ?? null : null;
|
|
1396
1441
|
}
|
|
1397
|
-
async function download(
|
|
1442
|
+
async function download(log2) {
|
|
1398
1443
|
const asset = releaseAsset();
|
|
1399
1444
|
const dir = cacheDir();
|
|
1400
1445
|
mkdirSync3(dir, { recursive: true });
|
|
1401
1446
|
const target = join4(dir, binName);
|
|
1402
|
-
|
|
1447
|
+
log2(pc2.dim(`Downloading cloudflared (one time) into ${dir}\u2026`));
|
|
1403
1448
|
const response = await fetch(`https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`);
|
|
1404
1449
|
if (!response.ok) throw new Error(`Downloading cloudflared failed: HTTP ${response.status}.`);
|
|
1405
1450
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -1416,12 +1461,12 @@ async function download(log) {
|
|
|
1416
1461
|
if (process.platform !== "win32") chmodSync(target, 493);
|
|
1417
1462
|
return target;
|
|
1418
1463
|
}
|
|
1419
|
-
async function ensureCloudflared(
|
|
1464
|
+
async function ensureCloudflared(log2 = console.log) {
|
|
1420
1465
|
const found = onPath();
|
|
1421
1466
|
if (found) return found;
|
|
1422
1467
|
const cached = join4(cacheDir(), binName);
|
|
1423
1468
|
if (existsSync5(cached)) return cached;
|
|
1424
|
-
return download(
|
|
1469
|
+
return download(log2);
|
|
1425
1470
|
}
|
|
1426
1471
|
async function openTunnel(localUrl, options2 = {}) {
|
|
1427
1472
|
const bin = await ensureCloudflared(options2.log);
|
|
@@ -1575,9 +1620,9 @@ async function runDeploy(args, context3) {
|
|
|
1575
1620
|
if (plan.passthroughOnly) return context3.deploy(plan.forwarded);
|
|
1576
1621
|
const { appRoot, wranglerBin } = context3;
|
|
1577
1622
|
const shellEnv = context3.env ?? process.env;
|
|
1578
|
-
const
|
|
1623
|
+
const log2 = context3.log ?? ((message) => console.log(message));
|
|
1579
1624
|
const envArgs = plan.env ? ["--env", plan.env] : [];
|
|
1580
|
-
const step = (message) =>
|
|
1625
|
+
const step = (message) => log2(`
|
|
1581
1626
|
${pc3.bold(pc3.cyan("flare"))} ${message}`);
|
|
1582
1627
|
const databases = plan.skipMigrations ? [] : readD1Databases(appRoot).filter((db) => existsSync7(join6(appRoot, db.migrations_dir ?? "migrations")));
|
|
1583
1628
|
const migrateAfterDeploy = [];
|
|
@@ -1609,7 +1654,7 @@ ${info.output.trim()}`);
|
|
|
1609
1654
|
const apiToken = shellEnv.FLARE_SECURITY_API_TOKEN;
|
|
1610
1655
|
if (security && zoneId && apiToken) {
|
|
1611
1656
|
step("Provisioning zone security rules");
|
|
1612
|
-
await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log, fetch: context3.fetch });
|
|
1657
|
+
await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log: log2, fetch: context3.fetch });
|
|
1613
1658
|
} else if (security) {
|
|
1614
1659
|
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
1660
|
}
|
|
@@ -1642,7 +1687,7 @@ ${put.output.trim()}`);
|
|
|
1642
1687
|
const optional = existsSync7(examplePath) ? declaredSecretNames(readFileSync4(examplePath, "utf8")).filter((name) => !existing.has(name)) : [];
|
|
1643
1688
|
if (optional.length > 0) {
|
|
1644
1689
|
step("Optional secrets not set in production (features using them stay off):");
|
|
1645
|
-
for (const name of optional)
|
|
1690
|
+
for (const name of optional) log2(` wrangler secret put ${name}${plan.env ? ` --env ${plan.env}` : ""}`);
|
|
1646
1691
|
}
|
|
1647
1692
|
}
|
|
1648
1693
|
return 0;
|
|
@@ -1849,7 +1894,7 @@ function previousSnapshot(dir, migrationFile) {
|
|
|
1849
1894
|
}
|
|
1850
1895
|
|
|
1851
1896
|
// src/generator/plan.ts
|
|
1852
|
-
import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
|
|
1897
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
|
|
1853
1898
|
import { dirname as dirname5, join as join10 } from "path";
|
|
1854
1899
|
import pc5 from "picocolors";
|
|
1855
1900
|
|
|
@@ -2078,16 +2123,24 @@ var adminImports = (entry) => [
|
|
|
2078
2123
|
function renderAdminListPage(entry) {
|
|
2079
2124
|
const local = resourceLocal(entry.stem);
|
|
2080
2125
|
return [
|
|
2081
|
-
`import {
|
|
2082
|
-
`import
|
|
2126
|
+
`import { PageHeader } from "@/components/dashboard/page-header";`,
|
|
2127
|
+
`import { ResourceStats } from "@/components/dashboard/resource-stats";`,
|
|
2128
|
+
`import { ResourceTable } from "@/components/dashboard/resource-table";`,
|
|
2129
|
+
`import type { SearchParams } from "@/components/dashboard/query";`,
|
|
2083
2130
|
...adminImports(entry),
|
|
2084
2131
|
"",
|
|
2132
|
+
`export const metadata = { title: ${local}.pluralLabel };`,
|
|
2133
|
+
"",
|
|
2085
2134
|
`export default async function ${entry.resource.name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {`,
|
|
2086
2135
|
" return (",
|
|
2087
|
-
|
|
2088
|
-
|
|
2136
|
+
" <>",
|
|
2137
|
+
" <PageHeader",
|
|
2138
|
+
` title={${local}.pluralLabel}`,
|
|
2139
|
+
` crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: ${local}.pluralLabel }]}`,
|
|
2140
|
+
" />",
|
|
2141
|
+
` <ResourceStats resource={${local}} />`,
|
|
2089
2142
|
` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
|
|
2090
|
-
"
|
|
2143
|
+
" </>",
|
|
2091
2144
|
" );",
|
|
2092
2145
|
"}",
|
|
2093
2146
|
""
|
|
@@ -2095,7 +2148,7 @@ function renderAdminListPage(entry) {
|
|
|
2095
2148
|
}
|
|
2096
2149
|
function renderAdminNewPage(entry) {
|
|
2097
2150
|
return [
|
|
2098
|
-
`import { ResourceFormPage } from "@/components/
|
|
2151
|
+
`import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
|
|
2099
2152
|
...adminImports(entry),
|
|
2100
2153
|
"",
|
|
2101
2154
|
`export default function New${entry.resource.name}Page() {`,
|
|
@@ -2106,7 +2159,7 @@ function renderAdminNewPage(entry) {
|
|
|
2106
2159
|
}
|
|
2107
2160
|
function renderAdminEditPage(entry) {
|
|
2108
2161
|
return [
|
|
2109
|
-
`import { ResourceFormPage } from "@/components/
|
|
2162
|
+
`import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
|
|
2110
2163
|
...adminImports(entry),
|
|
2111
2164
|
"",
|
|
2112
2165
|
`export default async function Edit${entry.resource.name}Page({ params }: { params: Promise<{ id: string }> }) {`,
|
|
@@ -2174,9 +2227,9 @@ function resourceFiles(entry, all) {
|
|
|
2174
2227
|
{ path: `app/api/${resource.slug}/[id]/route.ts`, content: renderItemRoute(entry) },
|
|
2175
2228
|
{ path: `resources/${stem}.client.ts`, content: renderClient(entry) },
|
|
2176
2229
|
{ path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
|
|
2177
|
-
{ path: `app/
|
|
2178
|
-
{ path: `app/
|
|
2179
|
-
{ path: `app/
|
|
2230
|
+
{ path: `app/dashboard/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
|
|
2231
|
+
{ path: `app/dashboard/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
|
|
2232
|
+
{ path: `app/dashboard/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
|
|
2180
2233
|
];
|
|
2181
2234
|
}
|
|
2182
2235
|
|
|
@@ -2236,7 +2289,7 @@ function findOrphans(appRoot, all) {
|
|
|
2236
2289
|
for (const dir of list2("app/api", () => true)) {
|
|
2237
2290
|
for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync11(join10(appRoot, file))) candidates.push(file);
|
|
2238
2291
|
}
|
|
2239
|
-
for (const dir of list2("app/
|
|
2292
|
+
for (const dir of list2("app/dashboard", () => true)) {
|
|
2240
2293
|
for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
|
|
2241
2294
|
if (existsSync11(join10(appRoot, file))) candidates.push(file);
|
|
2242
2295
|
}
|
|
@@ -2248,20 +2301,20 @@ function findOrphans(appRoot, all) {
|
|
|
2248
2301
|
}
|
|
2249
2302
|
return orphans;
|
|
2250
2303
|
}
|
|
2251
|
-
function ensureSupportFiles(appRoot,
|
|
2304
|
+
function ensureSupportFiles(appRoot, log2) {
|
|
2252
2305
|
for (const path of ["lib/api.ts", "lib/cache.ts"]) {
|
|
2253
2306
|
const target = join10(appRoot, path);
|
|
2254
2307
|
if (existsSync11(target)) continue;
|
|
2255
2308
|
mkdirSync4(dirname5(target), { recursive: true });
|
|
2256
|
-
|
|
2257
|
-
|
|
2309
|
+
copyFileSync2(join10(templatesDir, "app", path), target);
|
|
2310
|
+
log2(`${pc5.green("create".padEnd(9))} ${path}`);
|
|
2258
2311
|
}
|
|
2259
2312
|
}
|
|
2260
|
-
function logResults(results,
|
|
2313
|
+
function logResults(results, log2, options2 = {}) {
|
|
2261
2314
|
for (const { path, outcome } of results) {
|
|
2262
2315
|
if (outcome === "identical" && !options2.showIdentical) continue;
|
|
2263
2316
|
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
|
-
|
|
2317
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2265
2318
|
}
|
|
2266
2319
|
}
|
|
2267
2320
|
|
|
@@ -2296,20 +2349,20 @@ function addStripeDependency(appRoot) {
|
|
|
2296
2349
|
writeJson(path, pkg);
|
|
2297
2350
|
return true;
|
|
2298
2351
|
}
|
|
2299
|
-
function writeBillingResource(appRoot, name, force,
|
|
2352
|
+
function writeBillingResource(appRoot, name, force, log2) {
|
|
2300
2353
|
const relative2 = descriptorPath(name);
|
|
2301
2354
|
const path = join11(appRoot, relative2);
|
|
2302
2355
|
if (!existsSync12(path)) {
|
|
2303
2356
|
mkdirSync5(dirname6(path), { recursive: true });
|
|
2304
2357
|
writeFileSync6(path, renderBillingDescriptor(name));
|
|
2305
|
-
|
|
2358
|
+
log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
|
|
2306
2359
|
return;
|
|
2307
2360
|
}
|
|
2308
2361
|
const parts = splitMarkers(readFileSync8(path, "utf8"));
|
|
2309
2362
|
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
2363
|
const merged = mergeBillingFields(name, parts.block);
|
|
2311
2364
|
if (!merged) {
|
|
2312
|
-
|
|
2365
|
+
log2(`${pc6.dim("identical".padEnd(9))} ${relative2} (has every billing field)`);
|
|
2313
2366
|
return;
|
|
2314
2367
|
}
|
|
2315
2368
|
try {
|
|
@@ -2320,22 +2373,22 @@ function writeBillingResource(appRoot, name, force, log) {
|
|
|
2320
2373
|
}
|
|
2321
2374
|
throw error;
|
|
2322
2375
|
}
|
|
2323
|
-
|
|
2376
|
+
log2(`${pc6.yellow("update".padEnd(9))} ${relative2} (added billing fields, kept yours)`);
|
|
2324
2377
|
}
|
|
2325
2378
|
async function genBilling(options2 = {}) {
|
|
2326
|
-
const
|
|
2379
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2327
2380
|
validateBillingOptions(options2.provider, options2.mode);
|
|
2328
2381
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2329
|
-
for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force,
|
|
2382
|
+
for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force, log2);
|
|
2330
2383
|
for (const [name, roles] of Object.entries(BILLING_POLICIES)) {
|
|
2331
2384
|
const relative2 = policyPath(name);
|
|
2332
2385
|
if (existsSync12(join11(appRoot, relative2))) {
|
|
2333
|
-
|
|
2386
|
+
log2(`${pc6.dim("kept".padEnd(9))} ${relative2} (already exists)`);
|
|
2334
2387
|
continue;
|
|
2335
2388
|
}
|
|
2336
2389
|
mkdirSync5(join11(appRoot, "policies"), { recursive: true });
|
|
2337
2390
|
writeFileSync6(join11(appRoot, relative2), renderPolicy(name, roles));
|
|
2338
|
-
|
|
2391
|
+
log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
|
|
2339
2392
|
}
|
|
2340
2393
|
const files = [];
|
|
2341
2394
|
const appName = JSON.parse(readFileSync8(join11(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
@@ -2343,7 +2396,7 @@ async function genBilling(options2 = {}) {
|
|
|
2343
2396
|
const outcome = writeGenerated(join11(appRoot, path), content, { header: billingHeader(), force: options2.force });
|
|
2344
2397
|
files.push({ path, status: "missing", outcome });
|
|
2345
2398
|
const label = outcome === "create" ? pc6.green("create") : outcome === "update" ? pc6.yellow("update") : pc6.dim("identical");
|
|
2346
|
-
|
|
2399
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2347
2400
|
}
|
|
2348
2401
|
for (const path of RETIRED_FILES) {
|
|
2349
2402
|
const absolute = join11(appRoot, path);
|
|
@@ -2352,15 +2405,15 @@ async function genBilling(options2 = {}) {
|
|
|
2352
2405
|
const untouched = parts && parts.before.includes("flare gen billing") && !parts.after.trim() && parts.hash !== void 0 && hashBlock(parts.block) === parts.hash;
|
|
2353
2406
|
if (untouched) {
|
|
2354
2407
|
unlinkSync(absolute);
|
|
2355
|
-
|
|
2408
|
+
log2(`${pc6.red("remove".padEnd(9))} ${path} (no longer generated)`);
|
|
2356
2409
|
} else {
|
|
2357
|
-
|
|
2410
|
+
log2(pc6.yellow(`${path} is no longer generated but has your changes; remove it when you're done with it.`));
|
|
2358
2411
|
}
|
|
2359
2412
|
}
|
|
2360
|
-
if (appendVars(appRoot, ".dev.vars.example"))
|
|
2361
|
-
if (appendVars(appRoot, ".dev.vars"))
|
|
2413
|
+
if (appendVars(appRoot, ".dev.vars.example")) log2(`${pc6.green("append".padEnd(9))} .dev.vars.example`);
|
|
2414
|
+
if (appendVars(appRoot, ".dev.vars")) log2(`${pc6.green("append".padEnd(9))} .dev.vars`);
|
|
2362
2415
|
const addedStripe = addStripeDependency(appRoot);
|
|
2363
|
-
if (addedStripe)
|
|
2416
|
+
if (addedStripe) log2(`${pc6.yellow("update".padEnd(9))} package.json (stripe@${STRIPE_DEP})`);
|
|
2364
2417
|
if (!options2.skipInstall) {
|
|
2365
2418
|
const pm = detectPackageManager();
|
|
2366
2419
|
const code = run(pm, ["install"], appRoot);
|
|
@@ -2368,16 +2421,16 @@ async function genBilling(options2 = {}) {
|
|
|
2368
2421
|
run(pm, ["run", "cf-typegen"], appRoot);
|
|
2369
2422
|
}
|
|
2370
2423
|
const all = await loadResources(appRoot);
|
|
2371
|
-
ensureSupportFiles(appRoot,
|
|
2372
|
-
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }),
|
|
2424
|
+
ensureSupportFiles(appRoot, log2);
|
|
2425
|
+
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
|
|
2373
2426
|
let migrations = [];
|
|
2374
2427
|
if (!options2.skipMigration) {
|
|
2375
2428
|
const result = await generateSchemaMigration(appRoot, "billing");
|
|
2376
2429
|
migrations = result.files;
|
|
2377
|
-
for (const file of migrations)
|
|
2378
|
-
for (const repair of result.repairs)
|
|
2430
|
+
for (const file of migrations) log2(`${pc6.green("create".padEnd(9))} ${file}`);
|
|
2431
|
+
for (const repair of result.repairs) log2(pc6.dim(`repaired ${repair}`));
|
|
2379
2432
|
}
|
|
2380
|
-
|
|
2433
|
+
log2(
|
|
2381
2434
|
[
|
|
2382
2435
|
"",
|
|
2383
2436
|
`Next:`,
|
|
@@ -2519,7 +2572,7 @@ var renderSecurityActions = () => `"use server";
|
|
|
2519
2572
|
import { revalidatePath } from "next/cache";
|
|
2520
2573
|
import { can } from "@flaredev/core";
|
|
2521
2574
|
import { isIp } from "@flaredev/core/security";
|
|
2522
|
-
import {
|
|
2575
|
+
import { dashboardSession, policyFor } from "@/lib/dashboard";
|
|
2523
2576
|
import { logSecurityEvent, security } from "@/lib/security";
|
|
2524
2577
|
|
|
2525
2578
|
/** On failure the submitted values come back, so the form (which React resets) keeps them. */
|
|
@@ -2528,7 +2581,7 @@ export type BanResult = { ok: true } | { ok: false; error: string; ip?: string;
|
|
|
2528
2581
|
const DURATIONS: Record<string, number | null> = { "1h": 3600, "24h": 86_400, "7d": 604_800, permanent: null };
|
|
2529
2582
|
|
|
2530
2583
|
async function adminOnly(): Promise<{ email: string } | { error: string }> {
|
|
2531
|
-
const { session, role } = await
|
|
2584
|
+
const { session, role } = await dashboardSession();
|
|
2532
2585
|
if (!session) return { error: "Sign in required." };
|
|
2533
2586
|
if (!can(policyFor("SecurityEvent"), role, "create")) return { error: "Only admins can ban or unban." };
|
|
2534
2587
|
return { email: session.user.email };
|
|
@@ -2549,8 +2602,8 @@ export async function banAction(_previous: BanResult | null, form: FormData): Pr
|
|
|
2549
2602
|
} catch (error) {
|
|
2550
2603
|
return { ok: false, error: \`The ban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\`, ip, reason };
|
|
2551
2604
|
}
|
|
2552
|
-
await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/
|
|
2553
|
-
revalidatePath("/
|
|
2605
|
+
await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: true, detail: \`\${reason} (by \${admin.email})\` });
|
|
2606
|
+
revalidatePath("/dashboard/security");
|
|
2554
2607
|
return { ok: true };
|
|
2555
2608
|
}
|
|
2556
2609
|
|
|
@@ -2563,8 +2616,8 @@ export async function unbanAction(ip: string): Promise<BanResult> {
|
|
|
2563
2616
|
} catch (error) {
|
|
2564
2617
|
return { ok: false, error: \`The unban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\` };
|
|
2565
2618
|
}
|
|
2566
|
-
await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/
|
|
2567
|
-
revalidatePath("/
|
|
2619
|
+
await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: false, detail: \`by \${admin.email}\` });
|
|
2620
|
+
revalidatePath("/dashboard/security");
|
|
2568
2621
|
return { ok: true };
|
|
2569
2622
|
}
|
|
2570
2623
|
`;
|
|
@@ -2657,7 +2710,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|
|
2657
2710
|
import { Separator } from "@/components/ui/separator";
|
|
2658
2711
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
2659
2712
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
2660
|
-
import {
|
|
2713
|
+
import { dashboardStore, policyFor, requireDashboard } from "@/lib/dashboard";
|
|
2661
2714
|
import { security, zoneClient } from "@/lib/security";
|
|
2662
2715
|
import { BanForm, UnbanButton } from "./ban-controls";
|
|
2663
2716
|
|
|
@@ -2666,7 +2719,7 @@ const when = (value: string | number | Date | null) =>
|
|
|
2666
2719
|
value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Until unbanned";
|
|
2667
2720
|
|
|
2668
2721
|
export default async function SecurityPage() {
|
|
2669
|
-
const { role } = await
|
|
2722
|
+
const { role } = await requireDashboard("/dashboard/security");
|
|
2670
2723
|
if (!can(policyFor("SecurityEvent"), role, "read")) {
|
|
2671
2724
|
return (
|
|
2672
2725
|
<Alert>
|
|
@@ -2679,7 +2732,7 @@ export default async function SecurityPage() {
|
|
|
2679
2732
|
|
|
2680
2733
|
const [bans, events, edge] = await Promise.all([
|
|
2681
2734
|
security().bans(),
|
|
2682
|
-
|
|
2735
|
+
dashboardStore("SecurityEvent").list(new URLSearchParams({ sort: "-createdAt", perPage: "20" })),
|
|
2683
2736
|
zone
|
|
2684
2737
|
? Promise.all([zone.bans(), zone.firewallEvents(new Date(Date.now() - 24 * 3600 * 1000))]).then(
|
|
2685
2738
|
([zoneBans, firewall]) => ({ ok: true as const, zoneBans, firewall }),
|
|
@@ -2790,7 +2843,7 @@ export default async function SecurityPage() {
|
|
|
2790
2843
|
<CardHeader>
|
|
2791
2844
|
<CardTitle>Recent events</CardTitle>
|
|
2792
2845
|
<CardDescription>
|
|
2793
|
-
Detectors that tripped, and manual bans. <Link href="/
|
|
2846
|
+
Detectors that tripped, and manual bans. <Link href="/dashboard/security-events" className="underline underline-offset-4">All events</Link>
|
|
2794
2847
|
</CardDescription>
|
|
2795
2848
|
</CardHeader>
|
|
2796
2849
|
<CardContent>
|
|
@@ -2900,43 +2953,43 @@ function addSecurityBindings(source) {
|
|
|
2900
2953
|
}
|
|
2901
2954
|
return { text, added };
|
|
2902
2955
|
}
|
|
2903
|
-
var renderAdminNavBlock = () => `export interface
|
|
2956
|
+
var renderAdminNavBlock = () => `export interface DashboardLink {
|
|
2904
2957
|
label: string;
|
|
2905
2958
|
href: string;
|
|
2906
|
-
/** A resource icon name, e.g. "shield" (see components/
|
|
2959
|
+
/** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
|
|
2907
2960
|
icon: string;
|
|
2908
2961
|
}
|
|
2909
2962
|
|
|
2910
|
-
export const
|
|
2963
|
+
export const generatedDashboardLinks: DashboardLink[] = [{ label: "Security", href: "/dashboard/security", icon: "shield" }];
|
|
2911
2964
|
`;
|
|
2912
2965
|
|
|
2913
2966
|
// src/commands/gen-security.ts
|
|
2914
2967
|
function securityFiles(appName) {
|
|
2915
2968
|
return [
|
|
2916
2969
|
{ path: "lib/security.ts", content: renderLibSecurity(appName) },
|
|
2917
|
-
{ path: "app/
|
|
2918
|
-
{ path: "app/
|
|
2919
|
-
{ path: "app/
|
|
2970
|
+
{ path: "app/dashboard/security/page.tsx", content: renderSecurityPage() },
|
|
2971
|
+
{ path: "app/dashboard/security/actions.ts", content: renderSecurityActions() },
|
|
2972
|
+
{ path: "app/dashboard/security/ban-controls.tsx", content: renderBanControls() }
|
|
2920
2973
|
];
|
|
2921
2974
|
}
|
|
2922
2975
|
var PASS_THROUGH_HEADER = /^\/\/ Request protection for worker\/index\.ts\.[\s\S]*?\n(?=\/\/ generated:start)/;
|
|
2923
|
-
function writeOnce(appRoot, relative2, content,
|
|
2976
|
+
function writeOnce(appRoot, relative2, content, log2) {
|
|
2924
2977
|
const path = join12(appRoot, relative2);
|
|
2925
2978
|
if (existsSync13(path)) {
|
|
2926
|
-
|
|
2979
|
+
log2(`${pc7.dim("kept".padEnd(9))} ${relative2} (already exists)`);
|
|
2927
2980
|
return;
|
|
2928
2981
|
}
|
|
2929
2982
|
mkdirSync6(dirname7(path), { recursive: true });
|
|
2930
2983
|
writeFileSync7(path, content);
|
|
2931
|
-
|
|
2984
|
+
log2(`${pc7.green("create".padEnd(9))} ${relative2}`);
|
|
2932
2985
|
}
|
|
2933
2986
|
async function genSecurity(options2 = {}) {
|
|
2934
|
-
const
|
|
2987
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2935
2988
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2936
2989
|
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),
|
|
2990
|
+
writeOnce(appRoot, "security.config.ts", renderSecurityConfig(), log2);
|
|
2991
|
+
writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(), log2);
|
|
2992
|
+
writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY), log2);
|
|
2940
2993
|
const files = [];
|
|
2941
2994
|
const write = (path, content, header) => {
|
|
2942
2995
|
const absolute = join12(appRoot, path);
|
|
@@ -2947,12 +3000,12 @@ async function genSecurity(options2 = {}) {
|
|
|
2947
3000
|
const outcome = writeGenerated(absolute, content, { header, force: options2.force });
|
|
2948
3001
|
files.push({ path, status: "missing", outcome });
|
|
2949
3002
|
const label = outcome === "create" ? pc7.green("create") : outcome === "update" ? pc7.yellow("update") : pc7.dim("identical");
|
|
2950
|
-
|
|
3003
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2951
3004
|
};
|
|
2952
3005
|
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
|
|
3006
|
+
const navPath = join12(appRoot, "lib/dashboard-nav.ts");
|
|
3007
|
+
if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/dashboard-nav.ts", renderAdminNavBlock());
|
|
3008
|
+
else log2(pc7.yellow(`lib/dashboard-nav.ts not found: link to /dashboard/security from your dashboard sidebar yourself.`));
|
|
2956
3009
|
const wranglerPath = join12(appRoot, "wrangler.jsonc");
|
|
2957
3010
|
let bindings = [];
|
|
2958
3011
|
if (existsSync13(wranglerPath)) {
|
|
@@ -2960,27 +3013,27 @@ async function genSecurity(options2 = {}) {
|
|
|
2960
3013
|
bindings = added;
|
|
2961
3014
|
if (added.length) {
|
|
2962
3015
|
writeFileSync7(wranglerPath, text);
|
|
2963
|
-
for (const line of added)
|
|
3016
|
+
for (const line of added) log2(`${pc7.yellow("update".padEnd(9))} wrangler.jsonc (${line})`);
|
|
2964
3017
|
}
|
|
2965
3018
|
} else {
|
|
2966
|
-
|
|
3019
|
+
log2(pc7.yellow("wrangler.jsonc not found: add the FLARE_SECURITY, FLARE_SECURITY_MONITOR and FLARE_RATE_LIMIT bindings yourself."));
|
|
2967
3020
|
}
|
|
2968
3021
|
const all = await loadResources(appRoot);
|
|
2969
|
-
ensureSupportFiles(appRoot,
|
|
2970
|
-
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }),
|
|
3022
|
+
ensureSupportFiles(appRoot, log2);
|
|
3023
|
+
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
|
|
2971
3024
|
let migrations = [];
|
|
2972
3025
|
if (!options2.skipMigration) {
|
|
2973
3026
|
const result = await generateSchemaMigration(appRoot, "security");
|
|
2974
3027
|
migrations = result.files;
|
|
2975
|
-
for (const file of migrations)
|
|
2976
|
-
for (const repair of result.repairs)
|
|
3028
|
+
for (const file of migrations) log2(`${pc7.green("create".padEnd(9))} ${file}`);
|
|
3029
|
+
for (const repair of result.repairs) log2(pc7.dim(`repaired ${repair}`));
|
|
2977
3030
|
}
|
|
2978
|
-
|
|
3031
|
+
log2(
|
|
2979
3032
|
[
|
|
2980
3033
|
"",
|
|
2981
3034
|
`Next:`,
|
|
2982
3035
|
` 1. ${pc7.bold("flare migrate")} to create the security_events table.`,
|
|
2983
|
-
` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/
|
|
3036
|
+
` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/dashboard/security")}. Tune detectors in security.config.ts.`,
|
|
2984
3037
|
` 3. Optional zone layer: deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set (a token scoped to that`,
|
|
2985
3038
|
` one zone with Zone WAF Edit, Firewall Services Edit, Analytics Read, Zone Read). ${pc7.bold("flare deploy")} pushes the`,
|
|
2986
3039
|
` zone rules and uploads both as secrets.`
|
|
@@ -2994,7 +3047,7 @@ import { existsSync as existsSync14, readFileSync as readFileSync10, writeFileSy
|
|
|
2994
3047
|
import { join as join13 } from "path";
|
|
2995
3048
|
import pc8 from "picocolors";
|
|
2996
3049
|
var THEME_LINE = /theme: "([a-z]+)" as ThemeName,/;
|
|
2997
|
-
function setTheme(name, cwd = process.cwd(),
|
|
3050
|
+
function setTheme(name, cwd = process.cwd(), log2 = console.log) {
|
|
2998
3051
|
const appRoot = findAppRoot(cwd);
|
|
2999
3052
|
const path = join13(appRoot, "lib/site.ts");
|
|
3000
3053
|
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 +3055,50 @@ function setTheme(name, cwd = process.cwd(), log = console.log) {
|
|
|
3002
3055
|
const current = THEME_LINE.exec(source)?.[1];
|
|
3003
3056
|
if (!name) {
|
|
3004
3057
|
for (const [theme2, description] of Object.entries(THEMES)) {
|
|
3005
|
-
|
|
3058
|
+
log2(`${theme2 === current ? pc8.green("\u25CF") : " "} ${theme2.padEnd(8)} ${pc8.dim(description)}`);
|
|
3006
3059
|
}
|
|
3007
|
-
|
|
3060
|
+
log2(pc8.dim("\nSwitch with `flare theme <name>`. FLARE_THEME in .env overrides it for a build."));
|
|
3008
3061
|
return;
|
|
3009
3062
|
}
|
|
3010
3063
|
const theme = parseTheme(name);
|
|
3011
3064
|
if (!current) throw new Error('lib/site.ts has no `theme: "\u2026" as ThemeName,` line to change.');
|
|
3012
3065
|
writeFileSync8(path, source.replace(THEME_LINE, `theme: "${theme}" as ThemeName,`));
|
|
3013
|
-
|
|
3066
|
+
log2(`${pc8.green("\u2714")} Theme is now ${pc8.bold(theme)}. Restart \`flare dev\` (or rebuild) to see it.`);
|
|
3014
3067
|
}
|
|
3015
3068
|
|
|
3016
3069
|
// src/commands/create-prompts.ts
|
|
3017
|
-
import * as
|
|
3070
|
+
import * as prompts2 from "@clack/prompts";
|
|
3071
|
+
import pc9 from "picocolors";
|
|
3072
|
+
import { FLARE_VERSION as FLARE_VERSION2 } from "@flaredev/core";
|
|
3018
3073
|
var canPrompt = (yes) => !yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
3074
|
+
var AUTH_HINTS = {
|
|
3075
|
+
"magic-link": "a sign-in link by email",
|
|
3076
|
+
"email-otp": "a 6-digit sign-in code by email",
|
|
3077
|
+
passkeys: "Face ID, Touch ID, Windows Hello",
|
|
3078
|
+
"2fa-app": "authenticator app, with backup codes",
|
|
3079
|
+
"2fa-email": "a code by email as the second step"
|
|
3080
|
+
};
|
|
3081
|
+
var AUTH_LABELS = {
|
|
3082
|
+
"magic-link": "Magic links",
|
|
3083
|
+
"email-otp": "Email codes",
|
|
3084
|
+
passkeys: "Passkeys",
|
|
3085
|
+
"2fa-app": "Two-factor: authenticator app",
|
|
3086
|
+
"2fa-email": "Two-factor: email code"
|
|
3087
|
+
};
|
|
3019
3088
|
function stopIfCancelled(value) {
|
|
3020
|
-
if (
|
|
3021
|
-
|
|
3089
|
+
if (prompts2.isCancel(value)) {
|
|
3090
|
+
prompts2.cancel("Cancelled: nothing was created.");
|
|
3022
3091
|
process.exit(1);
|
|
3023
3092
|
}
|
|
3024
3093
|
return value;
|
|
3025
3094
|
}
|
|
3026
|
-
async function askCreateQuestions(given) {
|
|
3027
|
-
|
|
3095
|
+
async function askCreateQuestions(given, appName) {
|
|
3096
|
+
prompts2.intro(`${pc9.bgRed(pc9.white(" Flare "))} ${pc9.dim(`v${FLARE_VERSION2}`)}${appName ? ` ${pc9.dim("\xB7")} ${pc9.bold(appName)}` : ""}`);
|
|
3028
3097
|
const answers = { ...given };
|
|
3029
3098
|
if (given.theme === void 0) {
|
|
3030
3099
|
answers.theme = stopIfCancelled(
|
|
3031
|
-
await
|
|
3032
|
-
message: "Pick a look
|
|
3100
|
+
await prompts2.select({
|
|
3101
|
+
message: "Pick a look",
|
|
3033
3102
|
initialValue: "default",
|
|
3034
3103
|
options: Object.entries(THEMES).map(([value, hint]) => ({ value, label: value, hint }))
|
|
3035
3104
|
})
|
|
@@ -3037,19 +3106,23 @@ async function askCreateQuestions(given) {
|
|
|
3037
3106
|
}
|
|
3038
3107
|
if (given.auth === void 0) {
|
|
3039
3108
|
const methods = stopIfCancelled(
|
|
3040
|
-
await
|
|
3041
|
-
message:
|
|
3109
|
+
await prompts2.multiselect({
|
|
3110
|
+
message: `How can people sign in? ${pc9.dim("Email and password is always on")}`,
|
|
3042
3111
|
initialValues: DEFAULT_AUTH_METHODS,
|
|
3043
3112
|
required: false,
|
|
3044
|
-
options: Object.
|
|
3113
|
+
options: Object.keys(AUTH_METHODS).map((value) => ({
|
|
3114
|
+
value,
|
|
3115
|
+
label: AUTH_LABELS[value],
|
|
3116
|
+
hint: AUTH_HINTS[value]
|
|
3117
|
+
}))
|
|
3045
3118
|
})
|
|
3046
3119
|
);
|
|
3047
3120
|
answers.auth = methods.length ? methods.join(",") : "none";
|
|
3048
3121
|
}
|
|
3049
3122
|
if (given.authProviders === void 0) {
|
|
3050
3123
|
const providers = stopIfCancelled(
|
|
3051
|
-
await
|
|
3052
|
-
message:
|
|
3124
|
+
await prompts2.multiselect({
|
|
3125
|
+
message: `Social sign-in? ${pc9.dim("each needs its own credentials later")}`,
|
|
3053
3126
|
initialValues: [],
|
|
3054
3127
|
required: false,
|
|
3055
3128
|
options: Object.entries(AUTH_PROVIDERS).map(([value, { label }]) => ({ value, label }))
|
|
@@ -3057,7 +3130,6 @@ async function askCreateQuestions(given) {
|
|
|
3057
3130
|
);
|
|
3058
3131
|
answers.authProviders = providers.join(",");
|
|
3059
3132
|
}
|
|
3060
|
-
prompts.outro("Creating your app");
|
|
3061
3133
|
return answers;
|
|
3062
3134
|
}
|
|
3063
3135
|
|
|
@@ -3065,8 +3137,8 @@ async function askCreateQuestions(given) {
|
|
|
3065
3137
|
import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
|
|
3066
3138
|
import { dirname as dirname8, join as join14 } from "path";
|
|
3067
3139
|
import { camelCase as camelCase4, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
|
|
3068
|
-
import
|
|
3069
|
-
function validateFields(name, fields, others,
|
|
3140
|
+
import pc10 from "picocolors";
|
|
3141
|
+
function validateFields(name, fields, others, log2) {
|
|
3070
3142
|
const known = /* @__PURE__ */ new Set([name, ...others.map((resource) => resource.name)]);
|
|
3071
3143
|
for (const field of fields) {
|
|
3072
3144
|
if (field.kind === "belongsTo" && !known.has(field.target)) {
|
|
@@ -3076,11 +3148,11 @@ function validateFields(name, fields, others, log) {
|
|
|
3076
3148
|
const candidate = defineResource2({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
|
|
3077
3149
|
const graph = relationGraph2([...others, candidate]);
|
|
3078
3150
|
for (const pending of graph.pending.filter((p) => p.resource === name)) {
|
|
3079
|
-
|
|
3151
|
+
log2(pc10.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
|
|
3080
3152
|
}
|
|
3081
3153
|
}
|
|
3082
3154
|
async function genResource(rawName, options2) {
|
|
3083
|
-
const
|
|
3155
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3084
3156
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3085
3157
|
const name = resourceName(rawName);
|
|
3086
3158
|
const relativePath = descriptorPath(name);
|
|
@@ -3093,15 +3165,15 @@ async function genResource(rawName, options2) {
|
|
|
3093
3165
|
const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
|
|
3094
3166
|
if (options2.fields) {
|
|
3095
3167
|
const fields = parseFields(options2.fields);
|
|
3096
|
-
validateFields(name, fields, others,
|
|
3168
|
+
validateFields(name, fields, others, log2);
|
|
3097
3169
|
if (!exists) {
|
|
3098
3170
|
mkdirSync7(dirname8(path), { recursive: true });
|
|
3099
3171
|
writeFileSync9(path, renderDescriptor(name, fields));
|
|
3100
|
-
|
|
3172
|
+
log2(`${pc10.green("create".padEnd(9))} ${relativePath}`);
|
|
3101
3173
|
} else {
|
|
3102
3174
|
try {
|
|
3103
3175
|
const outcome = writeGenerated(path, renderFieldsBlock(fields), { force: options2.force });
|
|
3104
|
-
|
|
3176
|
+
log2(`${(outcome === "identical" ? pc10.dim : pc10.yellow)(outcome.padEnd(9))} ${relativePath}`);
|
|
3105
3177
|
} catch (error) {
|
|
3106
3178
|
if (error instanceof DriftError) {
|
|
3107
3179
|
throw new Error(
|
|
@@ -3117,9 +3189,9 @@ async function genResource(rawName, options2) {
|
|
|
3117
3189
|
try {
|
|
3118
3190
|
const all = await loadResources(appRoot);
|
|
3119
3191
|
table = all.find(({ resource }) => resource.name === name).resource.table;
|
|
3120
|
-
ensureSupportFiles(appRoot,
|
|
3192
|
+
ensureSupportFiles(appRoot, log2);
|
|
3121
3193
|
written = applyPlan(appRoot, planFiles(all), { force: options2.force });
|
|
3122
|
-
logResults(written,
|
|
3194
|
+
logResults(written, log2);
|
|
3123
3195
|
} catch (error) {
|
|
3124
3196
|
const hint = exists ? "" : `
|
|
3125
3197
|
(${relativePath} was written; fix it and run \`flare sync-types\`.)`;
|
|
@@ -3127,18 +3199,18 @@ async function genResource(rawName, options2) {
|
|
|
3127
3199
|
}
|
|
3128
3200
|
const skipped = written.filter((r) => r.outcome.startsWith("skipped"));
|
|
3129
3201
|
if (skipped.length) {
|
|
3130
|
-
|
|
3202
|
+
log2(pc10.yellow(`
|
|
3131
3203
|
Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for details.`));
|
|
3132
3204
|
}
|
|
3133
3205
|
let migrations = [];
|
|
3134
3206
|
if (!options2.skipMigration) {
|
|
3135
3207
|
const result = await generateSchemaMigration(appRoot, `${exists ? "update" : "create"}_${table}`);
|
|
3136
3208
|
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)
|
|
3209
|
+
for (const file of migrations) log2(`${pc10.green("create".padEnd(9))} ${file}`);
|
|
3210
|
+
for (const repair of result.repairs) log2(pc10.dim(`repaired ${repair}`));
|
|
3211
|
+
if (migrations.length) log2(`
|
|
3212
|
+
Next: ${pc10.bold("flare migrate")} to apply it locally.`);
|
|
3213
|
+
else if (exists) log2(pc10.dim("\nNo table changes, so no migration."));
|
|
3142
3214
|
}
|
|
3143
3215
|
const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
|
|
3144
3216
|
return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
|
|
@@ -3148,24 +3220,24 @@ Next: ${pc9.bold("flare migrate")} to apply it locally.`);
|
|
|
3148
3220
|
import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
|
|
3149
3221
|
import { basename as basename2, join as join15 } from "path";
|
|
3150
3222
|
import { snakeCase } from "@flaredev/core";
|
|
3151
|
-
import
|
|
3223
|
+
import pc11 from "picocolors";
|
|
3152
3224
|
var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
|
|
3153
3225
|
-- Write SQL that undoes the up migration, e.g.:
|
|
3154
3226
|
-- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
|
|
3155
3227
|
-- While this file has no statements, rollback of ${up} is refused.
|
|
3156
3228
|
`;
|
|
3157
3229
|
async function genMigration(rawName, options2 = {}) {
|
|
3158
|
-
const
|
|
3230
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3159
3231
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3160
3232
|
const name = snakeCase(rawName);
|
|
3161
3233
|
if (!name) throw new Error(`Invalid migration name "${rawName}". Use letters, digits, and underscores.`);
|
|
3162
3234
|
const { files, repairs } = await generateSchemaMigration(appRoot, name, { custom: !options2.fromSchema });
|
|
3163
3235
|
if (files.length === 0) {
|
|
3164
|
-
|
|
3236
|
+
log2(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
|
|
3165
3237
|
return [];
|
|
3166
3238
|
}
|
|
3167
|
-
for (const file of files)
|
|
3168
|
-
for (const repair of repairs)
|
|
3239
|
+
for (const file of files) log2(`${pc11.green("create".padEnd(9))} ${file}`);
|
|
3240
|
+
for (const repair of repairs) log2(pc11.dim(`repaired ${repair}`));
|
|
3169
3241
|
if (!options2.fromSchema) {
|
|
3170
3242
|
const downDir = join15(appRoot, "migrations", "down");
|
|
3171
3243
|
mkdirSync8(downDir, { recursive: true });
|
|
@@ -3173,13 +3245,13 @@ async function genMigration(rawName, options2 = {}) {
|
|
|
3173
3245
|
const down = join15(downDir, basename2(file));
|
|
3174
3246
|
if (existsSync16(down)) continue;
|
|
3175
3247
|
writeFileSync10(down, DOWN_TEMPLATE(basename2(file)));
|
|
3176
|
-
|
|
3248
|
+
log2(`${pc11.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
|
|
3177
3249
|
}
|
|
3178
|
-
|
|
3179
|
-
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${
|
|
3250
|
+
log2(`
|
|
3251
|
+
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc11.bold("flare migrate")}.`);
|
|
3180
3252
|
} else {
|
|
3181
|
-
|
|
3182
|
-
Next: ${
|
|
3253
|
+
log2(`
|
|
3254
|
+
Next: ${pc11.bold("flare migrate")}.`);
|
|
3183
3255
|
}
|
|
3184
3256
|
return files;
|
|
3185
3257
|
}
|
|
@@ -3188,14 +3260,14 @@ Next: ${pc10.bold("flare migrate")}.`);
|
|
|
3188
3260
|
import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
|
|
3189
3261
|
import { dirname as dirname9, join as join16 } from "path";
|
|
3190
3262
|
import { definePolicy } from "@flaredev/core";
|
|
3191
|
-
import
|
|
3263
|
+
import pc12 from "picocolors";
|
|
3192
3264
|
function parseRoles(input, what = "--roles") {
|
|
3193
3265
|
const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
3194
3266
|
if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
|
|
3195
3267
|
return [...new Set(roles)];
|
|
3196
3268
|
}
|
|
3197
3269
|
async function genPolicy(rawName, options2) {
|
|
3198
|
-
const
|
|
3270
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3199
3271
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3200
3272
|
const name = resourceName(rawName);
|
|
3201
3273
|
const resources = await loadResources(appRoot);
|
|
@@ -3218,11 +3290,11 @@ async function genPolicy(rawName, options2) {
|
|
|
3218
3290
|
if (!exists) {
|
|
3219
3291
|
mkdirSync9(dirname9(path), { recursive: true });
|
|
3220
3292
|
writeFileSync11(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
|
|
3221
|
-
|
|
3293
|
+
log2(`${pc12.green("create".padEnd(9))} ${relative2}`);
|
|
3222
3294
|
} else {
|
|
3223
3295
|
try {
|
|
3224
3296
|
const outcome = writeGenerated(path, block, { force: options2.force });
|
|
3225
|
-
|
|
3297
|
+
log2(`${(outcome === "identical" ? pc12.dim : pc12.yellow)(outcome.padEnd(9))} ${relative2}`);
|
|
3226
3298
|
} catch (error) {
|
|
3227
3299
|
if (error instanceof DriftError) {
|
|
3228
3300
|
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,9 +3304,9 @@ async function genPolicy(rawName, options2) {
|
|
|
3232
3304
|
}
|
|
3233
3305
|
}
|
|
3234
3306
|
const policies = await loadPolicies(appRoot);
|
|
3235
|
-
logResults(applyPlan(appRoot, planFiles(resources, policies)),
|
|
3236
|
-
|
|
3237
|
-
${
|
|
3307
|
+
logResults(applyPlan(appRoot, planFiles(resources, policies)), log2);
|
|
3308
|
+
log2(`
|
|
3309
|
+
${pc12.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
|
|
3238
3310
|
return { name, path: relative2 };
|
|
3239
3311
|
}
|
|
3240
3312
|
|
|
@@ -3242,7 +3314,7 @@ ${pc11.dim("Roles come from `flare role:add`; the API and the admin both enforce
|
|
|
3242
3314
|
import { existsSync as existsSync18, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
|
|
3243
3315
|
import { tmpdir } from "os";
|
|
3244
3316
|
import { join as join17 } from "path";
|
|
3245
|
-
import
|
|
3317
|
+
import pc13 from "picocolors";
|
|
3246
3318
|
|
|
3247
3319
|
// src/generator/down.ts
|
|
3248
3320
|
var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
|
|
@@ -3358,11 +3430,11 @@ ${result.output.trim()}`);
|
|
|
3358
3430
|
async function rollback(options2 = {}) {
|
|
3359
3431
|
const steps = options2.steps ?? 1;
|
|
3360
3432
|
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);
|
|
3433
|
+
const { appRoot, db, target, where, wrangler, log: log2 } = context(options2);
|
|
3362
3434
|
const migrationsDir = join17(appRoot, db.migrations_dir ?? "migrations");
|
|
3363
3435
|
const applied = await appliedMigrations(options2, steps);
|
|
3364
3436
|
if (applied.length === 0) {
|
|
3365
|
-
|
|
3437
|
+
log2("Nothing to roll back.");
|
|
3366
3438
|
return 0;
|
|
3367
3439
|
}
|
|
3368
3440
|
const plan = [];
|
|
@@ -3388,13 +3460,13 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
|
|
|
3388
3460
|
}
|
|
3389
3461
|
plan.push({ name, sql: down.sql, source: "derived" });
|
|
3390
3462
|
}
|
|
3391
|
-
|
|
3463
|
+
log2(`${pc13.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
|
|
3392
3464
|
for (const step of plan) {
|
|
3393
|
-
|
|
3394
|
-
for (const line of step.sql.trim().split("\n"))
|
|
3465
|
+
log2(` ${step.name} ${pc13.dim(`(${step.source})`)}`);
|
|
3466
|
+
for (const line of step.sql.trim().split("\n")) log2(pc13.dim(` ${line}`));
|
|
3395
3467
|
}
|
|
3396
3468
|
if (options2.remote && !options2.yes) {
|
|
3397
|
-
|
|
3469
|
+
log2(pc13.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
|
|
3398
3470
|
return 1;
|
|
3399
3471
|
}
|
|
3400
3472
|
const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
|
|
@@ -3409,7 +3481,7 @@ ${result.output.trim()}`);
|
|
|
3409
3481
|
} finally {
|
|
3410
3482
|
rmSync2(dir, { recursive: true, force: true });
|
|
3411
3483
|
}
|
|
3412
|
-
|
|
3484
|
+
log2(pc13.green(`
|
|
3413
3485
|
Rolled back ${plan.map((step) => step.name).join(", ")}.`));
|
|
3414
3486
|
return 0;
|
|
3415
3487
|
}
|
|
@@ -3418,7 +3490,7 @@ Rolled back ${plan.map((step) => step.name).join(", ")}.`));
|
|
|
3418
3490
|
import { existsSync as existsSync19, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
3419
3491
|
import { dirname as dirname10, join as join18 } from "path";
|
|
3420
3492
|
import { relationGraph as relationGraph3 } from "@flaredev/core";
|
|
3421
|
-
import
|
|
3493
|
+
import pc14 from "picocolors";
|
|
3422
3494
|
var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
|
|
3423
3495
|
function handWrittenReason(source, header, options2 = {}) {
|
|
3424
3496
|
const parts = splitMarkers(source);
|
|
@@ -3436,7 +3508,7 @@ function handWrittenReason(source, header, options2 = {}) {
|
|
|
3436
3508
|
}
|
|
3437
3509
|
function removeEmptyDirs(appRoot, start) {
|
|
3438
3510
|
let dir = dirname10(join18(appRoot, start));
|
|
3439
|
-
const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "
|
|
3511
|
+
const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "dashboard")];
|
|
3440
3512
|
const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
|
|
3441
3513
|
while (inside() && existsSync19(dir) && readdirSync7(dir).length === 0) {
|
|
3442
3514
|
rmdirSync(dir);
|
|
@@ -3444,7 +3516,7 @@ function removeEmptyDirs(appRoot, start) {
|
|
|
3444
3516
|
}
|
|
3445
3517
|
}
|
|
3446
3518
|
async function rmResource(rawName, options2 = {}) {
|
|
3447
|
-
const
|
|
3519
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3448
3520
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3449
3521
|
const name = resourceName(rawName);
|
|
3450
3522
|
const all = await loadResources(appRoot);
|
|
@@ -3499,32 +3571,32 @@ Move what you want to keep, or pass --force to delete anyway.`);
|
|
|
3499
3571
|
for (const path of removed) {
|
|
3500
3572
|
unlinkSync2(join18(appRoot, path));
|
|
3501
3573
|
removeEmptyDirs(appRoot, path);
|
|
3502
|
-
|
|
3574
|
+
log2(`${pc14.red("remove".padEnd(9))} ${path}`);
|
|
3503
3575
|
}
|
|
3504
|
-
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)),
|
|
3576
|
+
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log2);
|
|
3505
3577
|
const table = entry ? tableExport(entry.resource) : void 0;
|
|
3506
3578
|
if (table && existsSync19(join18(appRoot, "seeds"))) {
|
|
3507
3579
|
const seeds = readdirSync7(join18(appRoot, "seeds")).filter(
|
|
3508
3580
|
(file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join18(appRoot, "seeds", file), "utf8"))
|
|
3509
3581
|
);
|
|
3510
|
-
if (seeds.length)
|
|
3582
|
+
if (seeds.length) log2(pc14.yellow(`
|
|
3511
3583
|
These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
|
|
3512
3584
|
}
|
|
3513
3585
|
let migrations = [];
|
|
3514
3586
|
if (!options2.skipMigration && entry) {
|
|
3515
3587
|
const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
|
|
3516
3588
|
migrations = result.files;
|
|
3517
|
-
for (const file of migrations)
|
|
3518
|
-
for (const repair of result.repairs)
|
|
3519
|
-
if (migrations.length)
|
|
3520
|
-
Next: ${
|
|
3589
|
+
for (const file of migrations) log2(`${pc14.green("create".padEnd(9))} ${file}`);
|
|
3590
|
+
for (const repair of result.repairs) log2(pc14.dim(`repaired ${repair}`));
|
|
3591
|
+
if (migrations.length) log2(`
|
|
3592
|
+
Next: ${pc14.bold("flare migrate")} to drop the table locally.`);
|
|
3521
3593
|
}
|
|
3522
3594
|
return { removed, migrations };
|
|
3523
3595
|
}
|
|
3524
3596
|
|
|
3525
3597
|
// src/commands/role.ts
|
|
3526
3598
|
import { humanize } from "@flaredev/core";
|
|
3527
|
-
import
|
|
3599
|
+
import pc15 from "picocolors";
|
|
3528
3600
|
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3529
3601
|
function roleInsertSql(name, label) {
|
|
3530
3602
|
if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
|
|
@@ -3557,7 +3629,7 @@ ${result.output.trim()}`);
|
|
|
3557
3629
|
return (first?.results ?? []).map((row) => row.name);
|
|
3558
3630
|
}
|
|
3559
3631
|
async function addRole(name, options2 = {}) {
|
|
3560
|
-
const { appRoot, binding, wrangler, target, log } = context2(options2);
|
|
3632
|
+
const { appRoot, binding, wrangler, target, log: log2 } = context2(options2);
|
|
3561
3633
|
const sql2 = roleInsertSql(name, options2.label ?? humanize(name));
|
|
3562
3634
|
const result = await runWrangler(wrangler, ["d1", "execute", binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
|
|
3563
3635
|
if (result.code !== 0) {
|
|
@@ -3569,23 +3641,136 @@ ${result.output.trim()}`);
|
|
|
3569
3641
|
}
|
|
3570
3642
|
const [first] = extractJson(result.output);
|
|
3571
3643
|
const added = first?.results?.[0];
|
|
3572
|
-
|
|
3573
|
-
|
|
3644
|
+
log2(pc15.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
|
|
3645
|
+
log2(pc15.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
|
|
3574
3646
|
return 0;
|
|
3575
3647
|
}
|
|
3576
3648
|
|
|
3577
3649
|
// src/commands/seed.ts
|
|
3578
3650
|
import { existsSync as existsSync20, mkdirSync as mkdirSync10, readdirSync as readdirSync8, writeFileSync as writeFileSync13 } from "fs";
|
|
3651
|
+
import { join as join20 } from "path";
|
|
3652
|
+
import { createFake, kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
|
|
3653
|
+
import { createJiti as createJiti4 } from "jiti";
|
|
3654
|
+
import pc16 from "picocolors";
|
|
3655
|
+
|
|
3656
|
+
// src/seed/bulk.ts
|
|
3657
|
+
var MAX_STATEMENT_BYTES = 8e4;
|
|
3658
|
+
var STATEMENTS_PER_CALL = 25;
|
|
3659
|
+
function sqlLiteral(value) {
|
|
3660
|
+
if (value === null || value === void 0) return "NULL";
|
|
3661
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
|
3662
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
3663
|
+
if (value instanceof Date) return String(value.getTime());
|
|
3664
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
3665
|
+
return `'${text.replaceAll("'", "''")}'`;
|
|
3666
|
+
}
|
|
3667
|
+
var tuple = (row, columns) => `(${columns.map((column) => sqlLiteral(row[column])).join(",")})`;
|
|
3668
|
+
function* insertStatements(table, columns, rows, maxBytes = MAX_STATEMENT_BYTES) {
|
|
3669
|
+
const head = `INSERT INTO "${table}" (${columns.map((column) => `"${column}"`).join(",")}) VALUES `;
|
|
3670
|
+
let values = [];
|
|
3671
|
+
let bytes = head.length;
|
|
3672
|
+
for (const row of rows) {
|
|
3673
|
+
const text = tuple(row, columns);
|
|
3674
|
+
if (values.length > 0 && bytes + text.length + 1 > maxBytes) {
|
|
3675
|
+
yield { sql: `${head}${values.join(",")};`, rows: values.length };
|
|
3676
|
+
values = [];
|
|
3677
|
+
bytes = head.length;
|
|
3678
|
+
}
|
|
3679
|
+
values.push(text);
|
|
3680
|
+
bytes += text.length + 1;
|
|
3681
|
+
}
|
|
3682
|
+
if (values.length > 0) yield { sql: `${head}${values.join(",")};`, rows: values.length };
|
|
3683
|
+
}
|
|
3684
|
+
async function bulkInsert(sink, options2) {
|
|
3685
|
+
const perCall = options2.statementsPerCall ?? STATEMENTS_PER_CALL;
|
|
3686
|
+
let written = 0;
|
|
3687
|
+
let batch = [];
|
|
3688
|
+
let batchRows = 0;
|
|
3689
|
+
const flush = async () => {
|
|
3690
|
+
if (batch.length === 0) return;
|
|
3691
|
+
await sink.exec(batch.join("\n"));
|
|
3692
|
+
written += batchRows;
|
|
3693
|
+
batch = [];
|
|
3694
|
+
batchRows = 0;
|
|
3695
|
+
options2.onProgress?.(written);
|
|
3696
|
+
};
|
|
3697
|
+
for (const statement of insertStatements(options2.table, options2.columns, options2.rows, options2.maxBytes)) {
|
|
3698
|
+
batch.push(statement.sql);
|
|
3699
|
+
batchRows += statement.rows;
|
|
3700
|
+
if (batch.length === perCall) await flush();
|
|
3701
|
+
}
|
|
3702
|
+
await flush();
|
|
3703
|
+
return written;
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// src/seed/insert-many.ts
|
|
3707
|
+
var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
|
|
3708
|
+
function createInsertMany(sink, drizzle, onProgress) {
|
|
3709
|
+
return async function insertMany(table, count, build) {
|
|
3710
|
+
const name = drizzle.getTableName(table);
|
|
3711
|
+
const columns = drizzle.getTableColumns(table);
|
|
3712
|
+
if (typeof count === "number" && !build) throw new Error(`insertMany(${name}, ${count}) needs a function to build each row.`);
|
|
3713
|
+
const source = typeof count === "number" ? (function* () {
|
|
3714
|
+
for (let i = 0; i < count; i++) yield build(i);
|
|
3715
|
+
})() : isIterable(count) ? count : [];
|
|
3716
|
+
const iterator = source[Symbol.iterator]();
|
|
3717
|
+
const first = iterator.next();
|
|
3718
|
+
if (first.done) return 0;
|
|
3719
|
+
const keys = new Set(Object.keys(first.value));
|
|
3720
|
+
for (const [key, column] of Object.entries(columns)) if (column.defaultFn) keys.add(key);
|
|
3721
|
+
const used = Object.keys(columns).filter((key) => keys.has(key));
|
|
3722
|
+
const unknownKeys = [...keys].filter((key) => !columns[key]);
|
|
3723
|
+
if (unknownKeys.length) throw new Error(`${name} has no column called ${unknownKeys.join(", ")}.`);
|
|
3724
|
+
const toRow = (value) => {
|
|
3725
|
+
const row = {};
|
|
3726
|
+
for (const key of used) {
|
|
3727
|
+
const column = columns[key];
|
|
3728
|
+
let raw = value[key];
|
|
3729
|
+
if (raw === void 0 && column.defaultFn) raw = column.defaultFn();
|
|
3730
|
+
row[column.name] = raw === void 0 || raw === null ? null : column.mapToDriverValue?.(raw) ?? raw;
|
|
3731
|
+
}
|
|
3732
|
+
return row;
|
|
3733
|
+
};
|
|
3734
|
+
const rows = (function* () {
|
|
3735
|
+
yield toRow(first.value);
|
|
3736
|
+
for (let next = iterator.next(); !next.done; next = iterator.next()) yield toRow(next.value);
|
|
3737
|
+
})();
|
|
3738
|
+
return bulkInsert(sink, {
|
|
3739
|
+
table: name,
|
|
3740
|
+
columns: used.map((key) => columns[key].name),
|
|
3741
|
+
rows,
|
|
3742
|
+
onProgress: onProgress && ((written) => onProgress(written, name))
|
|
3743
|
+
});
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
// src/seed/local-d1.ts
|
|
3579
3748
|
import { createRequire } from "module";
|
|
3580
3749
|
import { join as join19 } from "path";
|
|
3581
3750
|
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
3751
|
var LOCAL_STATE = ".wrangler/state/v3";
|
|
3752
|
+
async function openLocalD1(appRoot, binding) {
|
|
3753
|
+
process.env.WRANGLER_LOG ??= "error";
|
|
3754
|
+
const appRequire = createRequire(join19(appRoot, "package.json"));
|
|
3755
|
+
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3756
|
+
const { env, dispose } = await wrangler.getPlatformProxy({
|
|
3757
|
+
configPath: join19(appRoot, "wrangler.jsonc"),
|
|
3758
|
+
persist: { path: join19(appRoot, LOCAL_STATE) }
|
|
3759
|
+
});
|
|
3760
|
+
const bindings = env;
|
|
3761
|
+
const name = binding ?? "DB";
|
|
3762
|
+
const db = bindings[name];
|
|
3763
|
+
if (!db) {
|
|
3764
|
+
const available = Object.keys(bindings).filter((key) => typeof bindings[key]?.prepare === "function");
|
|
3765
|
+
throw new Error(`No "${name}" database binding in wrangler.jsonc.${available.length ? ` Available: ${available.join(", ")}.` : ""}`);
|
|
3766
|
+
}
|
|
3767
|
+
return { db, env: bindings, dispose };
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
// src/commands/seed.ts
|
|
3771
|
+
var SEEDS_DIR = "seeds";
|
|
3587
3772
|
function seedFiles(appRoot, names) {
|
|
3588
|
-
const dir =
|
|
3773
|
+
const dir = join20(appRoot, SEEDS_DIR);
|
|
3589
3774
|
const all = existsSync20(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
|
|
3590
3775
|
if (!names?.length) return all;
|
|
3591
3776
|
return names.map((name) => {
|
|
@@ -3597,65 +3782,66 @@ function seedFiles(appRoot, names) {
|
|
|
3597
3782
|
});
|
|
3598
3783
|
}
|
|
3599
3784
|
async function runSeeds(options2 = {}) {
|
|
3600
|
-
const
|
|
3785
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3601
3786
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3602
3787
|
const files = seedFiles(appRoot, options2.names);
|
|
3603
3788
|
if (files.length === 0) {
|
|
3604
|
-
|
|
3789
|
+
log2(`No seeds found. Create one with ${pc16.bold("flare seed:make <name>")}.`);
|
|
3605
3790
|
return;
|
|
3606
3791
|
}
|
|
3607
|
-
const jiti = createJiti4(
|
|
3608
|
-
const appRequire = createRequire(join19(appRoot, "package.json"));
|
|
3609
|
-
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3792
|
+
const jiti = createJiti4(join20(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
|
|
3610
3793
|
const { drizzle } = await jiti.import("drizzle-orm/d1");
|
|
3611
|
-
const
|
|
3612
|
-
const
|
|
3613
|
-
|
|
3614
|
-
persist: { path: join19(appRoot, LOCAL_STATE) }
|
|
3615
|
-
});
|
|
3794
|
+
const drizzleHelpers = await jiti.import("drizzle-orm");
|
|
3795
|
+
const schema = await jiti.import(join20(appRoot, "db/schema.ts"));
|
|
3796
|
+
const local = await openLocalD1(appRoot);
|
|
3616
3797
|
try {
|
|
3617
|
-
const
|
|
3618
|
-
|
|
3619
|
-
const db = drizzle(bindings.DB, { schema });
|
|
3798
|
+
const db = drizzle(local.db, { schema });
|
|
3799
|
+
const insertMany = createInsertMany(local.db, drizzleHelpers);
|
|
3620
3800
|
for (const file of files) {
|
|
3621
|
-
const mod = await jiti.import(
|
|
3801
|
+
const mod = await jiti.import(join20(appRoot, SEEDS_DIR, file));
|
|
3622
3802
|
if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
|
|
3623
|
-
|
|
3624
|
-
await mod.default({ db, env:
|
|
3803
|
+
log2(`${pc16.cyan("seed")} ${file}`);
|
|
3804
|
+
await mod.default({ db, env: local.env, insertMany, fake: createFake(), log: (message) => log2(` ${message}`) });
|
|
3625
3805
|
}
|
|
3626
|
-
|
|
3806
|
+
log2(pc16.green(`
|
|
3627
3807
|
Ran ${files.length} seed(s) against the local database.`));
|
|
3628
3808
|
} finally {
|
|
3629
|
-
await dispose();
|
|
3809
|
+
await local.dispose();
|
|
3630
3810
|
}
|
|
3631
3811
|
}
|
|
3632
|
-
function sampleValue(key, def
|
|
3812
|
+
function sampleValue(key, def) {
|
|
3813
|
+
const name = key.toLowerCase();
|
|
3633
3814
|
switch (def.kind) {
|
|
3634
3815
|
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
|
|
3816
|
+
if (def.format === "email") return "fake.email()";
|
|
3817
|
+
if (def.format === "url") return "fake.url()";
|
|
3818
|
+
if (def.format === "tel") return "fake.phone()";
|
|
3819
|
+
if (def.format === "domain") return "fake.domain()";
|
|
3820
|
+
if (def.format === "country") return "fake.country()";
|
|
3821
|
+
if (def.format === "color") return "fake.color()";
|
|
3822
|
+
if (def.format === "slug") return "fake.slug()";
|
|
3823
|
+
if (name.includes("email")) return "fake.email()";
|
|
3824
|
+
if (name.includes("phone") || name.includes("mobile")) return "fake.phone()";
|
|
3825
|
+
if (name.includes("company")) return "fake.company()";
|
|
3826
|
+
if (name.includes("city")) return "fake.city()";
|
|
3827
|
+
if (name.includes("name")) return "fake.fullName()";
|
|
3828
|
+
return "fake.words(2)";
|
|
3643
3829
|
case "text":
|
|
3644
|
-
return
|
|
3830
|
+
return "fake.paragraph()";
|
|
3645
3831
|
case "int":
|
|
3646
|
-
return "
|
|
3832
|
+
return "fake.int(1, 1000)";
|
|
3647
3833
|
case "float":
|
|
3648
|
-
return "
|
|
3834
|
+
return "fake.float(1, 1000)";
|
|
3649
3835
|
case "boolean":
|
|
3650
|
-
return "
|
|
3836
|
+
return "fake.bool()";
|
|
3651
3837
|
case "date":
|
|
3652
|
-
return "
|
|
3838
|
+
return "fake.date()";
|
|
3653
3839
|
case "datetime":
|
|
3654
|
-
return "
|
|
3840
|
+
return "fake.datetime()";
|
|
3655
3841
|
case "enum":
|
|
3656
|
-
return JSON.stringify(def.options
|
|
3842
|
+
return `fake.pick(${JSON.stringify(def.options)})`;
|
|
3657
3843
|
case "multiselect":
|
|
3658
|
-
return JSON.stringify(def.options
|
|
3844
|
+
return `fake.some(${JSON.stringify(def.options)}, ${Math.max(1, def.minItems ?? 1)})`;
|
|
3659
3845
|
case "file":
|
|
3660
3846
|
return void 0;
|
|
3661
3847
|
case "belongsTo":
|
|
@@ -3666,9 +3852,10 @@ function renderSeed(resource) {
|
|
|
3666
3852
|
if (!resource) {
|
|
3667
3853
|
return `import { defineSeed } from "@flaredev/core";
|
|
3668
3854
|
|
|
3669
|
-
export default defineSeed(async ({ db, log }) => {
|
|
3855
|
+
export default defineSeed(async ({ db, insertMany, fake, log }) => {
|
|
3670
3856
|
// Insert rows with Drizzle, e.g.:
|
|
3671
3857
|
// await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
|
|
3858
|
+
// Or many at once, batched: insertMany(contacts, 10_000, () => ({ name: fake.fullName() }));
|
|
3672
3859
|
log("nothing to seed yet");
|
|
3673
3860
|
});
|
|
3674
3861
|
`;
|
|
@@ -3677,29 +3864,31 @@ export default defineSeed(async ({ db, log }) => {
|
|
|
3677
3864
|
const lines = [];
|
|
3678
3865
|
const todos = [];
|
|
3679
3866
|
for (const [key, def] of storedFields2(resource)) {
|
|
3680
|
-
const value = sampleValue(key, def
|
|
3867
|
+
const value = sampleValue(key, def);
|
|
3681
3868
|
if (value !== void 0) lines.push(` ${key}: ${value},`);
|
|
3682
3869
|
else if (def.required) todos.push(` // ${key}: TODO (${def.kind === "belongsTo" ? `id of an existing ${def.target}` : "R2 object key"}),`);
|
|
3683
3870
|
}
|
|
3684
3871
|
return `import { defineSeed } from "@flaredev/core";
|
|
3685
3872
|
import { ${table} } from "@/db/schema";
|
|
3686
3873
|
|
|
3687
|
-
|
|
3688
|
-
|
|
3874
|
+
/** How many to create. insertMany batches them, so hundreds of thousands are fine. */
|
|
3875
|
+
const COUNT = 50;
|
|
3876
|
+
|
|
3877
|
+
export default defineSeed(async ({ insertMany, fake, log }) => {
|
|
3878
|
+
const rows = await insertMany(${table}, COUNT, () => ({
|
|
3689
3879
|
${[...lines, ...todos].join("\n")}
|
|
3690
3880
|
}));
|
|
3691
|
-
|
|
3692
|
-
log(\`inserted \${rows.length} ${resource.pluralLabel.toLowerCase()}\`);
|
|
3881
|
+
log(\`inserted \${rows.toLocaleString()} ${resource.pluralLabel.toLowerCase()}\`);
|
|
3693
3882
|
});
|
|
3694
3883
|
`;
|
|
3695
3884
|
}
|
|
3696
3885
|
async function makeSeed(rawName, options2 = {}) {
|
|
3697
|
-
const
|
|
3886
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3698
3887
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3699
3888
|
const name = kebabCase3(rawName);
|
|
3700
3889
|
if (!name) throw new Error(`Invalid seed name "${rawName}".`);
|
|
3701
3890
|
const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
|
|
3702
|
-
const path =
|
|
3891
|
+
const path = join20(appRoot, relative2);
|
|
3703
3892
|
if (existsSync20(path)) throw new Error(`${relative2} already exists.`);
|
|
3704
3893
|
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
3705
3894
|
let resource;
|
|
@@ -3709,25 +3898,285 @@ async function makeSeed(rawName, options2 = {}) {
|
|
|
3709
3898
|
} else {
|
|
3710
3899
|
resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
|
|
3711
3900
|
}
|
|
3712
|
-
mkdirSync10(
|
|
3901
|
+
mkdirSync10(join20(appRoot, SEEDS_DIR), { recursive: true });
|
|
3713
3902
|
writeFileSync13(path, renderSeed(resource));
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
Run it with ${
|
|
3903
|
+
log2(`${pc16.green("create".padEnd(9))} ${relative2}`);
|
|
3904
|
+
log2(`
|
|
3905
|
+
Run it with ${pc16.bold(`flare seed ${name}`)}.`);
|
|
3717
3906
|
return relative2;
|
|
3718
3907
|
}
|
|
3719
3908
|
|
|
3909
|
+
// src/commands/seed-resource.ts
|
|
3910
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync14 } from "fs";
|
|
3911
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3912
|
+
import { join as join21 } from "path";
|
|
3913
|
+
import pc17 from "picocolors";
|
|
3914
|
+
|
|
3915
|
+
// src/seed/rows.ts
|
|
3916
|
+
import { columnName as columnName2, createFake as createFake2, storedFields as storedFields3 } from "@flaredev/core";
|
|
3917
|
+
var has = (key, ...words) => words.some((word) => key.includes(word));
|
|
3918
|
+
function stringValue(key, def, fake, resource, index) {
|
|
3919
|
+
switch (def.format) {
|
|
3920
|
+
case "email":
|
|
3921
|
+
return fake.email();
|
|
3922
|
+
case "url":
|
|
3923
|
+
return fake.url();
|
|
3924
|
+
case "tel":
|
|
3925
|
+
return fake.phone();
|
|
3926
|
+
case "domain":
|
|
3927
|
+
return fake.domain();
|
|
3928
|
+
case "country":
|
|
3929
|
+
return fake.country();
|
|
3930
|
+
case "color":
|
|
3931
|
+
return fake.color();
|
|
3932
|
+
case "slug":
|
|
3933
|
+
return fake.slug();
|
|
3934
|
+
}
|
|
3935
|
+
const name = key.toLowerCase();
|
|
3936
|
+
const organisation = has(resource.name.toLowerCase(), "company", "vendor", "supplier", "organisation", "organization", "account", "team", "store", "brand");
|
|
3937
|
+
let value;
|
|
3938
|
+
if (has(name, "email")) value = fake.email();
|
|
3939
|
+
else if (has(name, "phone", "mobile", "tel", "whatsapp")) value = fake.phone();
|
|
3940
|
+
else if (has(name, "firstname", "givenname")) value = fake.firstName();
|
|
3941
|
+
else if (has(name, "lastname", "surname", "familyname")) value = fake.lastName();
|
|
3942
|
+
else if (has(name, "company", "organisation", "organization", "employer", "vendor", "supplier", "business")) value = fake.company();
|
|
3943
|
+
else if (has(name, "city", "town")) value = fake.city();
|
|
3944
|
+
else if (has(name, "country")) value = fake.country();
|
|
3945
|
+
else if (has(name, "address", "street")) value = `${fake.int(1, 240)} ${fake.words(1)} Street, ${fake.city()}`;
|
|
3946
|
+
else if (has(name, "website", "url", "link")) value = fake.url();
|
|
3947
|
+
else if (has(name, "slug", "handle", "username")) value = fake.slug();
|
|
3948
|
+
else if (has(name, "colour", "color")) value = fake.color();
|
|
3949
|
+
else if (has(name, "code", "sku", "reference", "number")) value = `${fake.words(1).slice(0, 3).toUpperCase()}-${String(index + 1).padStart(6, "0")}`;
|
|
3950
|
+
else if (has(name, "title", "position", "role", "job")) value = fake.jobTitle();
|
|
3951
|
+
else if (has(name, "name")) value = organisation ? fake.company() : fake.fullName();
|
|
3952
|
+
else if (has(name, "description", "summary", "note", "bio", "about", "message", "comment")) value = fake.sentence();
|
|
3953
|
+
else value = `${fake.words(2)}`.replace(/^./, (letter) => letter.toUpperCase());
|
|
3954
|
+
if (def.maxLength && value.length > def.maxLength) value = value.slice(0, def.maxLength);
|
|
3955
|
+
if (def.minLength && value.length < def.minLength) value = value.padEnd(def.minLength, "x");
|
|
3956
|
+
return value;
|
|
3957
|
+
}
|
|
3958
|
+
function numberValue(key, def, fake) {
|
|
3959
|
+
const name = key.toLowerCase();
|
|
3960
|
+
const money = has(name, "price", "amount", "total", "cost", "fee", "revenue", "salary", "balance");
|
|
3961
|
+
let min = def.min;
|
|
3962
|
+
let max = def.max;
|
|
3963
|
+
if (min === void 0 || max === void 0) {
|
|
3964
|
+
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];
|
|
3965
|
+
min = min ?? low;
|
|
3966
|
+
max = max ?? high;
|
|
3967
|
+
}
|
|
3968
|
+
return def.kind === "int" ? fake.int(Math.ceil(min), Math.floor(max)) : fake.float(min, max, money ? 2 : 2);
|
|
3969
|
+
}
|
|
3970
|
+
function fieldValue(key, def, fake, resource, parentIds, index) {
|
|
3971
|
+
const name = key.toLowerCase();
|
|
3972
|
+
if (!def.required && def.kind !== "belongsTo" && fake.bool(0.15)) return null;
|
|
3973
|
+
switch (def.kind) {
|
|
3974
|
+
case "string":
|
|
3975
|
+
return stringValue(key, def, fake, resource, index);
|
|
3976
|
+
case "text":
|
|
3977
|
+
return fake.paragraph(fake.int(1, 3));
|
|
3978
|
+
case "int":
|
|
3979
|
+
case "float":
|
|
3980
|
+
return numberValue(key, def, fake);
|
|
3981
|
+
case "boolean":
|
|
3982
|
+
return fake.bool(has(name, "active", "enabled", "published", "verified", "visible") ? 0.85 : 0.4);
|
|
3983
|
+
case "date":
|
|
3984
|
+
return has(name, "due", "expire", "end", "renew") ? fake.date(-90) : fake.date();
|
|
3985
|
+
case "datetime":
|
|
3986
|
+
return has(name, "due", "expire", "end", "renew") ? fake.datetime(-90) : fake.datetime();
|
|
3987
|
+
case "enum":
|
|
3988
|
+
return fake.pick(def.options);
|
|
3989
|
+
case "multiselect":
|
|
3990
|
+
return fake.some(def.options, Math.max(1, def.minItems ?? 1), def.maxItems ?? def.options.length);
|
|
3991
|
+
case "file":
|
|
3992
|
+
return def.required ? `seed/${resource.table}/${fake.count}.bin` : null;
|
|
3993
|
+
case "belongsTo": {
|
|
3994
|
+
const ids = parentIds.get(def.target) ?? [];
|
|
3995
|
+
if (ids.length === 0) {
|
|
3996
|
+
if (def.required) {
|
|
3997
|
+
throw new Error(`${resource.name}.${key} needs a ${def.target} to belong to, and there are none. Seed ${def.target} first.`);
|
|
3998
|
+
}
|
|
3999
|
+
return null;
|
|
4000
|
+
}
|
|
4001
|
+
return fake.pick(ids);
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
function uniquify(value, row) {
|
|
4006
|
+
const trailing = /(\d+)$/.exec(value)?.[1];
|
|
4007
|
+
if (trailing && Number(trailing) === row) return value;
|
|
4008
|
+
const at = value.indexOf("@");
|
|
4009
|
+
if (at > 0) return `${value.slice(0, at)}.${row}${value.slice(at)}`;
|
|
4010
|
+
if (/^[a-z0-9.-]+$/.test(value)) return `${value}-${row}`;
|
|
4011
|
+
return `${value} ${row}`;
|
|
4012
|
+
}
|
|
4013
|
+
function seedColumns(resource) {
|
|
4014
|
+
return ["id", ...storedFields3(resource).map(([key]) => columnName2(key)), "created_at", "updated_at"];
|
|
4015
|
+
}
|
|
4016
|
+
function* seedRows(resource, count, options2 = {}) {
|
|
4017
|
+
const fake = createFake2(options2.seed ?? Math.floor(Math.random() * 2 ** 31));
|
|
4018
|
+
const parentIds = options2.parentIds ?? /* @__PURE__ */ new Map();
|
|
4019
|
+
const fields = storedFields3(resource);
|
|
4020
|
+
const now = options2.now ?? Date.now();
|
|
4021
|
+
for (let i = 0; i < count; i++) {
|
|
4022
|
+
const row = { id: fake.id() };
|
|
4023
|
+
for (const [key, def] of fields) {
|
|
4024
|
+
let value = fieldValue(key, def, fake, resource, parentIds, i);
|
|
4025
|
+
if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value, i + 1);
|
|
4026
|
+
row[columnName2(key)] = value;
|
|
4027
|
+
}
|
|
4028
|
+
const created = now - fake.int(0, 365) * 864e5 - fake.int(0, 86399999);
|
|
4029
|
+
row.created_at = created;
|
|
4030
|
+
row.updated_at = created + fake.int(0, Math.min(now - created, 30 * 864e5));
|
|
4031
|
+
yield row;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
// src/commands/seed-resource.ts
|
|
4036
|
+
var PARENT_SAMPLE = 5e3;
|
|
4037
|
+
function parseCount(value, fallback = 25) {
|
|
4038
|
+
if (value === void 0 || value === "") return fallback;
|
|
4039
|
+
if (typeof value === "number") return Math.floor(value);
|
|
4040
|
+
const text = String(value).trim().toLowerCase().replaceAll(",", "").replaceAll("_", "").replaceAll(" ", "");
|
|
4041
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(text);
|
|
4042
|
+
if (!match) throw new Error(`"${value}" is not a number of rows. Try 1000, 25k or 1m.`);
|
|
4043
|
+
const size = Number(match[1]) * (match[2] === "k" ? 1e3 : match[2] === "m" ? 1e6 : 1);
|
|
4044
|
+
if (size < 1) throw new Error("Ask for at least one row.");
|
|
4045
|
+
return Math.floor(size);
|
|
4046
|
+
}
|
|
4047
|
+
function pickResource(resources, name) {
|
|
4048
|
+
const wanted = name.toLowerCase();
|
|
4049
|
+
const match = resources.find((resource) => [resource.name.toLowerCase(), resource.slug, resource.table].includes(wanted));
|
|
4050
|
+
if (!match) {
|
|
4051
|
+
throw new Error(`No resource "${name}". Available: ${resources.map((resource) => resource.name).join(", ") || "none"}.`);
|
|
4052
|
+
}
|
|
4053
|
+
return match;
|
|
4054
|
+
}
|
|
4055
|
+
function pickDatabase(databases, wanted) {
|
|
4056
|
+
if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
4057
|
+
if (wanted) {
|
|
4058
|
+
const match = databases.find((db) => db.binding === wanted || db.database_name === wanted);
|
|
4059
|
+
if (!match) throw new Error(`No D1 database "${wanted}". Available: ${databases.map((db) => db.binding).join(", ")}.`);
|
|
4060
|
+
return match;
|
|
4061
|
+
}
|
|
4062
|
+
if (databases.length > 1) throw new Error(`Several D1 databases; pick one with --database (${databases.map((db) => db.binding).join(", ")}).`);
|
|
4063
|
+
return databases[0];
|
|
4064
|
+
}
|
|
4065
|
+
async function parentsOf(resource, all, ids) {
|
|
4066
|
+
const targets = new Set(
|
|
4067
|
+
Object.values(resource.fields).filter((field) => field.kind === "belongsTo").map((field) => field.target)
|
|
4068
|
+
);
|
|
4069
|
+
const parents = /* @__PURE__ */ new Map();
|
|
4070
|
+
for (const target of targets) {
|
|
4071
|
+
const parent = all.find((candidate) => candidate.name === target);
|
|
4072
|
+
if (parent) parents.set(target, await ids(parent.table));
|
|
4073
|
+
}
|
|
4074
|
+
return parents;
|
|
4075
|
+
}
|
|
4076
|
+
async function seedResourceCommand(name, options2 = {}) {
|
|
4077
|
+
const progress = progressLine();
|
|
4078
|
+
const started = Date.now();
|
|
4079
|
+
try {
|
|
4080
|
+
const result = await seedResource(name, {
|
|
4081
|
+
...options2,
|
|
4082
|
+
onProgress: (written, total) => {
|
|
4083
|
+
const elapsed = Date.now() - started;
|
|
4084
|
+
progress.update(` ${pc17.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc17.dim(formatRate(written, elapsed))}`);
|
|
4085
|
+
}
|
|
4086
|
+
});
|
|
4087
|
+
progress.done();
|
|
4088
|
+
const { rows, ms, where } = result;
|
|
4089
|
+
console.log(
|
|
4090
|
+
`${pc17.green("\u2714")} Seeded ${pc17.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc17.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
|
|
4091
|
+
);
|
|
4092
|
+
return result;
|
|
4093
|
+
} catch (error) {
|
|
4094
|
+
progress.done();
|
|
4095
|
+
throw error;
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
async function seedResource(name, options2 = {}) {
|
|
4099
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
4100
|
+
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
4101
|
+
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
4102
|
+
const resource = pickResource(resources, name);
|
|
4103
|
+
const total = parseCount(options2.count);
|
|
4104
|
+
const where = options2.remote ? "remote" : "local";
|
|
4105
|
+
if (options2.remote && !options2.yes) {
|
|
4106
|
+
throw new Error(`Seeding the remote database writes ${total.toLocaleString()} rows to ${resource.table}. Re-run with --yes to confirm.`);
|
|
4107
|
+
}
|
|
4108
|
+
const columns = seedColumns(resource);
|
|
4109
|
+
const started = Date.now();
|
|
4110
|
+
let parents = /* @__PURE__ */ new Map();
|
|
4111
|
+
const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents });
|
|
4112
|
+
if (options2.remote) {
|
|
4113
|
+
const db = pickDatabase(readD1Databases(appRoot), options2.database);
|
|
4114
|
+
const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
|
|
4115
|
+
const target = ["--remote", ...options2.env ? ["--env", options2.env] : []];
|
|
4116
|
+
const query = async (sql2) => {
|
|
4117
|
+
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
|
|
4118
|
+
if (result.code !== 0) throw new Error(`Query failed on the remote database:
|
|
4119
|
+
${result.output.trim()}`);
|
|
4120
|
+
const start = result.output.indexOf("[");
|
|
4121
|
+
const parsed = JSON.parse(result.output.slice(start, result.output.lastIndexOf("]") + 1));
|
|
4122
|
+
return parsed[0]?.results ?? [];
|
|
4123
|
+
};
|
|
4124
|
+
parents = await parentsOf(resource, resources, async (table) => {
|
|
4125
|
+
const found = await query(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`);
|
|
4126
|
+
return found.map((row) => row.id);
|
|
4127
|
+
});
|
|
4128
|
+
const dir = mkdtempSync2(join21(tmpdir2(), "flare-seed-"));
|
|
4129
|
+
const file = join21(dir, `${resource.table}.sql`);
|
|
4130
|
+
try {
|
|
4131
|
+
const statements = [];
|
|
4132
|
+
if (options2.truncate) statements.push(`DELETE FROM "${resource.table}";`);
|
|
4133
|
+
let written = 0;
|
|
4134
|
+
for (const statement of insertStatements(resource.table, columns, rows())) {
|
|
4135
|
+
statements.push(statement.sql);
|
|
4136
|
+
written += statement.rows;
|
|
4137
|
+
options2.onProgress?.(written, total);
|
|
4138
|
+
}
|
|
4139
|
+
writeFileSync14(file, statements.join("\n"));
|
|
4140
|
+
log2(`${pc17.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc17.bold(db.database_name)}`);
|
|
4141
|
+
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
|
|
4142
|
+
if (result.code !== 0) throw new Error(`Seeding the remote database failed:
|
|
4143
|
+
${result.output.trim()}`);
|
|
4144
|
+
} finally {
|
|
4145
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
4146
|
+
}
|
|
4147
|
+
return { resource, rows: total, ms: Date.now() - started, where };
|
|
4148
|
+
}
|
|
4149
|
+
const local = await openLocalD1(appRoot, options2.database);
|
|
4150
|
+
try {
|
|
4151
|
+
parents = await parentsOf(resource, resources, async (table) => {
|
|
4152
|
+
const found = await local.db.prepare(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`).all();
|
|
4153
|
+
return (found.results ?? []).map((row) => row.id);
|
|
4154
|
+
});
|
|
4155
|
+
if (options2.truncate) await local.db.exec(`DELETE FROM "${resource.table}";`);
|
|
4156
|
+
const sink = { exec: (sql2) => local.db.exec(sql2) };
|
|
4157
|
+
const written = await bulkInsert(sink, {
|
|
4158
|
+
table: resource.table,
|
|
4159
|
+
columns,
|
|
4160
|
+
rows: rows(),
|
|
4161
|
+
onProgress: (count) => options2.onProgress?.(count, total)
|
|
4162
|
+
});
|
|
4163
|
+
return { resource, rows: written, ms: Date.now() - started, where };
|
|
4164
|
+
} finally {
|
|
4165
|
+
await local.dispose();
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
|
|
3720
4169
|
// src/commands/billing-sync.ts
|
|
3721
4170
|
import { createRequire as createRequire2 } from "module";
|
|
3722
|
-
import { existsSync as existsSync21, mkdtempSync as
|
|
3723
|
-
import { tmpdir as
|
|
3724
|
-
import { join as
|
|
4171
|
+
import { existsSync as existsSync21, mkdtempSync as mkdtempSync3, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync15 } from "fs";
|
|
4172
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
4173
|
+
import { join as join22 } from "path";
|
|
3725
4174
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
3726
4175
|
import { kebabCase as kebabCase4 } from "@flaredev/core";
|
|
3727
|
-
import
|
|
4176
|
+
import pc18 from "picocolors";
|
|
3728
4177
|
function stripeKeys(appRoot) {
|
|
3729
4178
|
const map = {};
|
|
3730
|
-
const path =
|
|
4179
|
+
const path = join22(appRoot, ".dev.vars");
|
|
3731
4180
|
if (existsSync21(path)) {
|
|
3732
4181
|
for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
|
|
3733
4182
|
const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
@@ -3806,16 +4255,16 @@ async function syncPortalConfiguration(stripe, rows, appName) {
|
|
|
3806
4255
|
return existing ? (await stripe.billingPortal.configurations.update(existing.id, settings)).id : (await stripe.billingPortal.configurations.create(settings)).id;
|
|
3807
4256
|
}
|
|
3808
4257
|
async function syncPlans(options2 = {}) {
|
|
3809
|
-
const
|
|
4258
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3810
4259
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3811
4260
|
const key = stripeKeys(appRoot).STRIPE_SECRET_KEY;
|
|
3812
4261
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
|
|
3813
4262
|
const db = readD1Databases(appRoot)[0];
|
|
3814
4263
|
if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
3815
|
-
const appRequire = createRequire2(
|
|
4264
|
+
const appRequire = createRequire2(join22(appRoot, "package.json"));
|
|
3816
4265
|
const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
|
|
3817
4266
|
const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
|
|
3818
|
-
const appName = JSON.parse(readFileSync13(
|
|
4267
|
+
const appName = JSON.parse(readFileSync13(join22(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
3819
4268
|
const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
|
|
3820
4269
|
const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
|
|
3821
4270
|
if (products.length === 0) {
|
|
@@ -3836,88 +4285,88 @@ async function syncPlans(options2 = {}) {
|
|
|
3836
4285
|
${existingResult.output.trim()}`);
|
|
3837
4286
|
}
|
|
3838
4287
|
const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
|
|
3839
|
-
const dir =
|
|
4288
|
+
const dir = mkdtempSync3(join22(tmpdir3(), "flare-sync-"));
|
|
3840
4289
|
try {
|
|
3841
|
-
const file =
|
|
3842
|
-
|
|
4290
|
+
const file = join22(dir, "sync-plans.sql");
|
|
4291
|
+
writeFileSync15(file, renderSyncSql(rows));
|
|
3843
4292
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3844
4293
|
if (result.code !== 0) throw new Error(`Could not write plans:
|
|
3845
4294
|
${result.output.trim()}`);
|
|
3846
4295
|
} finally {
|
|
3847
|
-
|
|
4296
|
+
rmSync4(dir, { recursive: true, force: true });
|
|
3848
4297
|
}
|
|
3849
4298
|
let created = 0;
|
|
3850
4299
|
for (const row of rows) {
|
|
3851
4300
|
const isNew = !known.has(row.stripePriceId);
|
|
3852
4301
|
if (isNew) created++;
|
|
3853
4302
|
const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
|
|
3854
|
-
|
|
4303
|
+
log2(`${(isNew ? pc18.green("create") : pc18.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc18.dim(price)}`);
|
|
3855
4304
|
}
|
|
3856
|
-
for (const name of skipped)
|
|
4305
|
+
for (const name of skipped) log2(pc18.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
|
|
3857
4306
|
const configuration = await syncPortalConfiguration(stripe, rows, appName);
|
|
3858
|
-
if (configuration)
|
|
3859
|
-
|
|
4307
|
+
if (configuration) log2(`${pc18.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
|
|
4308
|
+
log2(pc18.green(`
|
|
3860
4309
|
${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
|
|
3861
4310
|
return { created, updated: rows.length - created, skipped };
|
|
3862
4311
|
}
|
|
3863
4312
|
|
|
3864
4313
|
// src/commands/sync.ts
|
|
3865
4314
|
import { existsSync as existsSync22 } from "fs";
|
|
3866
|
-
import { join as
|
|
3867
|
-
import
|
|
4315
|
+
import { join as join23 } from "path";
|
|
4316
|
+
import pc19 from "picocolors";
|
|
3868
4317
|
async function syncTypes(options2 = {}) {
|
|
3869
|
-
const
|
|
4318
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3870
4319
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3871
4320
|
const all = await loadResources(appRoot);
|
|
3872
4321
|
const policies = await loadPolicies(appRoot);
|
|
3873
|
-
const tracksPolicies = policies.length > 0 || existsSync22(
|
|
4322
|
+
const tracksPolicies = policies.length > 0 || existsSync22(join23(appRoot, "policies/index.ts"));
|
|
3874
4323
|
const plan = planFiles(all, tracksPolicies ? policies : void 0);
|
|
3875
|
-
if (!options2.check) ensureSupportFiles(appRoot,
|
|
4324
|
+
if (!options2.check) ensureSupportFiles(appRoot, log2);
|
|
3876
4325
|
const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
|
|
3877
|
-
logResults(results,
|
|
4326
|
+
logResults(results, log2);
|
|
3878
4327
|
const drift = results.filter((r) => r.outcome === "skipped-drift");
|
|
3879
4328
|
const unmarked = results.filter((r) => r.outcome === "skipped-unmarked");
|
|
3880
4329
|
const changed = results.filter((r) => r.outcome === "create" || r.outcome === "update");
|
|
3881
4330
|
const orphans = findOrphans(appRoot, all);
|
|
3882
4331
|
if (drift.length) {
|
|
3883
|
-
|
|
4332
|
+
log2(
|
|
3884
4333
|
`
|
|
3885
|
-
${
|
|
4334
|
+
${pc19.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
|
|
3886
4335
|
Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
|
|
3887
4336
|
);
|
|
3888
4337
|
}
|
|
3889
4338
|
if (unmarked.length) {
|
|
3890
|
-
|
|
3891
|
-
${
|
|
4339
|
+
log2(`
|
|
4340
|
+
${pc19.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
|
|
3892
4341
|
}
|
|
3893
4342
|
if (orphans.length) {
|
|
3894
4343
|
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 ${
|
|
4344
|
+
log2(`
|
|
4345
|
+
${pc19.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
|
|
4346
|
+
for (const orphan of orphans) log2(` ${orphan.path}`);
|
|
4347
|
+
log2(
|
|
4348
|
+
`If the resource was deleted, remove them with ${pc19.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
|
|
3900
4349
|
);
|
|
3901
4350
|
}
|
|
3902
4351
|
const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
|
|
3903
4352
|
if (tablesChanged && !options2.check) {
|
|
3904
|
-
|
|
3905
|
-
${
|
|
4353
|
+
log2(`
|
|
4354
|
+
${pc19.cyan("Tables changed.")} Create a migration with ${pc19.bold("flare gen migration <name> --from-schema")}, then ${pc19.bold("flare migrate")}.`);
|
|
3906
4355
|
}
|
|
3907
4356
|
const problems = drift.length + unmarked.length + orphans.length;
|
|
3908
4357
|
if (options2.check) {
|
|
3909
4358
|
const stale = changed.length;
|
|
3910
|
-
if (stale === 0 && problems === 0)
|
|
3911
|
-
else
|
|
3912
|
-
${
|
|
4359
|
+
if (stale === 0 && problems === 0) log2(pc19.green("Generated files are in sync with the resource descriptors."));
|
|
4360
|
+
else log2(`
|
|
4361
|
+
${pc19.red("Out of sync:")} ${stale} file(s) would change. Run ${pc19.bold("flare sync-types")}.`);
|
|
3913
4362
|
return stale + problems > 0 ? 1 : 0;
|
|
3914
4363
|
}
|
|
3915
|
-
if (changed.length === 0 && problems === 0)
|
|
4364
|
+
if (changed.length === 0 && problems === 0) log2(pc19.green("Everything is in sync."));
|
|
3916
4365
|
return problems > 0 ? 1 : 0;
|
|
3917
4366
|
}
|
|
3918
4367
|
|
|
3919
4368
|
// src/commands/user-role.ts
|
|
3920
|
-
import
|
|
4369
|
+
import pc20 from "picocolors";
|
|
3921
4370
|
var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3922
4371
|
var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
|
|
3923
4372
|
function userRoleSql(email, role) {
|
|
@@ -3926,13 +4375,13 @@ function userRoleSql(email, role) {
|
|
|
3926
4375
|
return `UPDATE "user" SET role = '${role}', updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE lower(email) = lower('${email}') RETURNING email, role`;
|
|
3927
4376
|
}
|
|
3928
4377
|
async function setUserRole(email, role, options2 = {}) {
|
|
3929
|
-
const
|
|
4378
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3930
4379
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3931
4380
|
const sql2 = userRoleSql(email, role);
|
|
3932
4381
|
const roles = await listRoles(options2);
|
|
3933
4382
|
if (roles && !roles.includes(role)) {
|
|
3934
|
-
|
|
3935
|
-
|
|
4383
|
+
log2(pc20.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
|
|
4384
|
+
log2(pc20.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
|
|
3936
4385
|
return 1;
|
|
3937
4386
|
}
|
|
3938
4387
|
const db = readD1Databases(appRoot)[0];
|
|
@@ -3946,10 +4395,10 @@ ${result.output.trim()}`);
|
|
|
3946
4395
|
const [first] = extractJson(result.output);
|
|
3947
4396
|
const updated = first?.results ?? [];
|
|
3948
4397
|
if (updated.length === 0) {
|
|
3949
|
-
|
|
4398
|
+
log2(pc20.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
|
|
3950
4399
|
return 1;
|
|
3951
4400
|
}
|
|
3952
|
-
|
|
4401
|
+
log2(pc20.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
|
|
3953
4402
|
return 0;
|
|
3954
4403
|
}
|
|
3955
4404
|
|
|
@@ -3959,8 +4408,27 @@ function createCli() {
|
|
|
3959
4408
|
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
4409
|
const install = !options2.skipInstall;
|
|
3961
4410
|
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, {
|
|
4411
|
+
if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers, toAppName(dir));
|
|
4412
|
+
const result = createApp(dir, {
|
|
4413
|
+
pm: options2.pm,
|
|
4414
|
+
install,
|
|
4415
|
+
...answers,
|
|
4416
|
+
log: (message) => prompts3.log.message(message),
|
|
4417
|
+
// The install is the long part of `flare create`, so it gets a timer rather
|
|
4418
|
+
// than a wall of package manager output. Output is kept for when it fails.
|
|
4419
|
+
installer: (packageManager, args, cwd) => {
|
|
4420
|
+
const spin = prompts3.spinner({ indicator: "timer" });
|
|
4421
|
+
spin.start(`Installing dependencies with ${packageManager}`);
|
|
4422
|
+
const { code, output } = runQuiet(packageManager, args, cwd);
|
|
4423
|
+
if (code === 0) {
|
|
4424
|
+
spin.stop(`Installed dependencies with ${packageManager}`);
|
|
4425
|
+
} else {
|
|
4426
|
+
spin.error(`${packageManager} install failed`);
|
|
4427
|
+
process.stderr.write(output);
|
|
4428
|
+
}
|
|
4429
|
+
return code;
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
3964
4432
|
printNextSteps(result, install);
|
|
3965
4433
|
});
|
|
3966
4434
|
cli.command("gen <generator> [name]", "Generate code. Generators: resource, migration, policy, billing, security").option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration").option("--roles <roles>", "policy: roles allowed to read, create and update, e.g. admin,staff").option("--delete-roles <roles>", "policy: roles allowed to delete (default: the first --roles entry)").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing: write files without installing the stripe dependency").option("--skip-migration", "billing/security: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen migration backfill_contact_status").example("flare gen migration add_phone_to_contacts --from-schema").example("flare gen policy Invoice --roles admin,staff --delete-roles admin").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").action(
|
|
@@ -3996,6 +4464,9 @@ function createCli() {
|
|
|
3996
4464
|
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
4465
|
await runSeeds({ names });
|
|
3998
4466
|
});
|
|
4467
|
+
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) => {
|
|
4468
|
+
await seedResourceCommand(resource, { ...options2, count: options2.count ?? count });
|
|
4469
|
+
});
|
|
3999
4470
|
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
4471
|
await makeSeed(name, options2);
|
|
4001
4472
|
});
|
|
@@ -4022,7 +4493,7 @@ function createCli() {
|
|
|
4022
4493
|
cli.command(`${name} [...args]`, spec.description).allowUnknownOptions();
|
|
4023
4494
|
}
|
|
4024
4495
|
cli.help();
|
|
4025
|
-
cli.version(
|
|
4496
|
+
cli.version(FLARE_VERSION3);
|
|
4026
4497
|
return cli;
|
|
4027
4498
|
}
|
|
4028
4499
|
|
|
@@ -4041,6 +4512,6 @@ try {
|
|
|
4041
4512
|
}
|
|
4042
4513
|
}
|
|
4043
4514
|
} catch (error) {
|
|
4044
|
-
console.error(
|
|
4515
|
+
console.error(pc21.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
|
|
4045
4516
|
process.exitCode = 1;
|
|
4046
4517
|
}
|