@flaredev/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/bin/flare.js +2 -0
  4. package/dist/index.js +3664 -0
  5. package/package.json +56 -0
  6. package/templates/app/.dev.vars.example +13 -0
  7. package/templates/app/README.md +9 -0
  8. package/templates/app/_gitignore +10 -0
  9. package/templates/app/app/admin/actions.ts +113 -0
  10. package/templates/app/app/admin/layout.tsx +32 -0
  11. package/templates/app/app/admin/page.tsx +63 -0
  12. package/templates/app/app/api/auth/[...all]/route.ts +4 -0
  13. package/templates/app/app/api/health/route.ts +7 -0
  14. package/templates/app/app/api/storage/route.ts +6 -0
  15. package/templates/app/app/dashboard/page.tsx +18 -0
  16. package/templates/app/app/globals.css +156 -0
  17. package/templates/app/app/layout.tsx +26 -0
  18. package/templates/app/app/page.tsx +19 -0
  19. package/templates/app/app/sign-in/page.tsx +33 -0
  20. package/templates/app/app/sign-up/page.tsx +23 -0
  21. package/templates/app/components/admin/admin-header.tsx +78 -0
  22. package/templates/app/components/admin/fields/date-field.tsx +112 -0
  23. package/templates/app/components/admin/fields/field-widget.tsx +128 -0
  24. package/templates/app/components/admin/fields/file-field.tsx +302 -0
  25. package/templates/app/components/admin/fields/relation-field.tsx +132 -0
  26. package/templates/app/components/admin/query.ts +25 -0
  27. package/templates/app/components/admin/resource-form-page.tsx +55 -0
  28. package/templates/app/components/admin/resource-form.tsx +139 -0
  29. package/templates/app/components/admin/resource-icon.tsx +85 -0
  30. package/templates/app/components/admin/resource-nav.tsx +109 -0
  31. package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
  32. package/templates/app/components/admin/resource-table.tsx +251 -0
  33. package/templates/app/components/admin/row-actions.tsx +111 -0
  34. package/templates/app/components/auth-form.tsx +120 -0
  35. package/templates/app/components/sign-out-button.tsx +18 -0
  36. package/templates/app/components/ui/alert-dialog.tsx +196 -0
  37. package/templates/app/components/ui/alert.tsx +65 -0
  38. package/templates/app/components/ui/avatar.tsx +108 -0
  39. package/templates/app/components/ui/badge.tsx +53 -0
  40. package/templates/app/components/ui/breadcrumb.tsx +110 -0
  41. package/templates/app/components/ui/button.tsx +65 -0
  42. package/templates/app/components/ui/calendar.tsx +220 -0
  43. package/templates/app/components/ui/card.tsx +92 -0
  44. package/templates/app/components/ui/command.tsx +184 -0
  45. package/templates/app/components/ui/dialog.tsx +158 -0
  46. package/templates/app/components/ui/dropdown-menu.tsx +256 -0
  47. package/templates/app/components/ui/empty.tsx +103 -0
  48. package/templates/app/components/ui/field.tsx +248 -0
  49. package/templates/app/components/ui/input-group.tsx +170 -0
  50. package/templates/app/components/ui/input.tsx +20 -0
  51. package/templates/app/components/ui/label.tsx +23 -0
  52. package/templates/app/components/ui/pagination.tsx +127 -0
  53. package/templates/app/components/ui/popover.tsx +88 -0
  54. package/templates/app/components/ui/progress.tsx +30 -0
  55. package/templates/app/components/ui/select.tsx +189 -0
  56. package/templates/app/components/ui/separator.tsx +27 -0
  57. package/templates/app/components/ui/sheet.tsx +142 -0
  58. package/templates/app/components/ui/sidebar.tsx +726 -0
  59. package/templates/app/components/ui/skeleton.tsx +13 -0
  60. package/templates/app/components/ui/sonner.tsx +38 -0
  61. package/templates/app/components/ui/spinner.tsx +15 -0
  62. package/templates/app/components/ui/switch.tsx +34 -0
  63. package/templates/app/components/ui/table.tsx +115 -0
  64. package/templates/app/components/ui/textarea.tsx +17 -0
  65. package/templates/app/components/ui/tooltip.tsx +56 -0
  66. package/templates/app/components.json +21 -0
  67. package/templates/app/db/auth-schema.ts +118 -0
  68. package/templates/app/db/flare-schema.ts +15 -0
  69. package/templates/app/db/index.ts +10 -0
  70. package/templates/app/db/schema.ts +12 -0
  71. package/templates/app/drizzle.config.ts +8 -0
  72. package/templates/app/hooks/use-mobile.ts +19 -0
  73. package/templates/app/lib/admin-nav.ts +15 -0
  74. package/templates/app/lib/admin.ts +103 -0
  75. package/templates/app/lib/api.ts +24 -0
  76. package/templates/app/lib/auth-client.ts +4 -0
  77. package/templates/app/lib/auth.ts +44 -0
  78. package/templates/app/lib/cache.ts +83 -0
  79. package/templates/app/lib/mail.ts +36 -0
  80. package/templates/app/lib/realtime.ts +45 -0
  81. package/templates/app/lib/security.ts +13 -0
  82. package/templates/app/lib/session.ts +24 -0
  83. package/templates/app/lib/storage.ts +26 -0
  84. package/templates/app/lib/utils.ts +6 -0
  85. package/templates/app/migrations/0000_init.sql +66 -0
  86. package/templates/app/migrations/meta/0000_snapshot.json +438 -0
  87. package/templates/app/migrations/meta/_journal.json +13 -0
  88. package/templates/app/next.config.ts +5 -0
  89. package/templates/app/policies/index.ts +4 -0
  90. package/templates/app/postcss.config.mjs +7 -0
  91. package/templates/app/proxy.ts +20 -0
  92. package/templates/app/resources/index.ts +4 -0
  93. package/templates/app/resources/server.ts +6 -0
  94. package/templates/app/tsconfig.json +22 -0
  95. package/templates/app/vite.config.ts +27 -0
  96. package/templates/app/worker/index.ts +40 -0
  97. package/templates/app/wrangler.jsonc +52 -0
package/dist/index.js ADDED
@@ -0,0 +1,3664 @@
1
+ // src/index.ts
2
+ import pc16 from "picocolors";
3
+
4
+ // src/cli.ts
5
+ import { cac } from "cac";
6
+ import { FLARE_VERSION as FLARE_VERSION2 } from "@flaredev/core";
7
+
8
+ // src/commands/create.ts
9
+ import { randomBytes } from "crypto";
10
+ import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
11
+ import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
12
+ import pc from "picocolors";
13
+ import { FLARE_VERSION } from "@flaredev/core";
14
+
15
+ // src/auth-providers.ts
16
+ var AUTH_PROVIDERS = {
17
+ google: { envPrefix: "GOOGLE", console: "https://console.cloud.google.com/apis/credentials" },
18
+ github: { envPrefix: "GITHUB", console: "https://github.com/settings/developers" }
19
+ };
20
+ function parseAuthProviders(input) {
21
+ if (!input) return [];
22
+ const names = input.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
23
+ const unknown = names.filter((name) => !(name in AUTH_PROVIDERS));
24
+ if (unknown.length > 0) {
25
+ throw new Error(
26
+ `Unknown auth provider${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Supported: ${Object.keys(AUTH_PROVIDERS).join(", ")}.`
27
+ );
28
+ }
29
+ return [...new Set(names)];
30
+ }
31
+ function socialProvidersCode(providers) {
32
+ if (providers.length === 0) return "{}";
33
+ const entries = providers.map((provider) => {
34
+ const id = `env.${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_ID`;
35
+ const secret = `env.${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_SECRET`;
36
+ return ` ...(${id} && ${secret} ? { ${provider}: { clientId: ${id}, clientSecret: ${secret} } } : {}),`;
37
+ });
38
+ return [
39
+ "{",
40
+ " // Each provider switches on once its credentials are set: .dev.vars locally,",
41
+ " // `wrangler secret put` in production.",
42
+ ...entries,
43
+ " }"
44
+ ].join("\n");
45
+ }
46
+ function devVarsEntries(providers) {
47
+ return providers.map((provider) => `${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_ID=
48
+ ${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_SECRET=
49
+ `).join("");
50
+ }
51
+ function devVarsExampleEntries(providers) {
52
+ return providers.map((provider) => {
53
+ const { envPrefix, console: console2 } = AUTH_PROVIDERS[provider];
54
+ return [
55
+ "",
56
+ `# ${provider} OAuth app: ${console2}`,
57
+ `# Callback URL: <your app URL>/api/auth/callback/${provider}`,
58
+ `${envPrefix}_CLIENT_ID=`,
59
+ `${envPrefix}_CLIENT_SECRET=`,
60
+ ""
61
+ ].join("\n");
62
+ }).join("");
63
+ }
64
+
65
+ // src/versions.ts
66
+ var APP_DEPENDENCIES = {
67
+ "@better-auth/drizzle-adapter": "1.7.5",
68
+ // Server components import Slot from here rather than the "radix-ui" umbrella (see components/ui/button.tsx).
69
+ "@radix-ui/react-slot": "1.3.3",
70
+ "@vinext/cloudflare": "1.0.0-beta.8",
71
+ "better-auth": "1.7.5",
72
+ // Admin UI (shadcn/ui primitives in components/ui).
73
+ "class-variance-authority": "^0.7.1",
74
+ clsx: "^2.1.1",
75
+ cmdk: "^1.1.1",
76
+ "date-fns": "^4.4.0",
77
+ "lucide-react": "^1.46.0",
78
+ "radix-ui": "^1.6.7",
79
+ "react-day-picker": "^10.0.1",
80
+ sonner: "^2.0.8",
81
+ "tailwind-merge": "^3.7.0",
82
+ "tw-animate-css": "^1.4.0",
83
+ "drizzle-orm": "^0.45.2",
84
+ react: "19.3.0",
85
+ "react-dom": "19.3.0",
86
+ "react-server-dom-webpack": "19.3.0",
87
+ vinext: "1.0.0-beta.10"
88
+ };
89
+ var APP_DEV_DEPENDENCIES = {
90
+ "@cloudflare/vite-plugin": "^1.54.10",
91
+ "@tailwindcss/postcss": "^4.3.3",
92
+ "@types/node": "^22.20.3",
93
+ "@types/react": "^19.3.0",
94
+ "@types/react-dom": "^19.3.0",
95
+ "@vitejs/plugin-react": "^6.1.1",
96
+ "@vitejs/plugin-rsc": "^0.5.35",
97
+ "drizzle-kit": "^0.31.10",
98
+ tailwindcss: "^4.3.3",
99
+ typescript: "^7.0.2",
100
+ vite: "^8.3.0",
101
+ wrangler: "^4.132.0"
102
+ };
103
+
104
+ // src/utils/fs.ts
105
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
106
+ import { dirname, join, resolve } from "path";
107
+ import { fileURLToPath } from "url";
108
+ var RENAMES = { _gitignore: ".gitignore" };
109
+ function copyTemplate(from, to, tokens) {
110
+ mkdirSync(to, { recursive: true });
111
+ for (const entry of readdirSync(from)) {
112
+ const src = join(from, entry);
113
+ const dest = join(to, RENAMES[entry] ?? entry);
114
+ if (statSync(src).isDirectory()) {
115
+ copyTemplate(src, dest, tokens);
116
+ continue;
117
+ }
118
+ const content = readFileSync(src, "utf8").replace(/__([A-Z_]+)__/g, (match, key) => tokens[key] ?? match);
119
+ writeFileSync(dest, content);
120
+ }
121
+ }
122
+ function writeJson(path, value) {
123
+ mkdirSync(dirname(path), { recursive: true });
124
+ writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
125
+ }
126
+ function findUp(file, start) {
127
+ let dir = resolve(start);
128
+ while (true) {
129
+ if (existsSync(join(dir, file))) return dir;
130
+ const parent = dirname(dir);
131
+ if (parent === dir) return void 0;
132
+ dir = parent;
133
+ }
134
+ }
135
+ var templatesDir = join(findUp("templates", dirname(fileURLToPath(import.meta.url))), "templates");
136
+
137
+ // src/utils/pm.ts
138
+ import { spawnSync } from "child_process";
139
+ var MANAGERS = ["pnpm", "npm", "yarn", "bun"];
140
+ function isPackageManager(value) {
141
+ return MANAGERS.includes(value);
142
+ }
143
+ function detectPackageManager(userAgent = process.env.npm_config_user_agent ?? "") {
144
+ const name = userAgent.split("/")[0];
145
+ return isPackageManager(name) ? name : "pnpm";
146
+ }
147
+ function run(command, args, cwd) {
148
+ const env = childEnv();
149
+ const result = process.platform === "win32" ? spawnSync([command, ...args].join(" "), { cwd, stdio: "inherit", shell: true, env }) : spawnSync(command, args, { cwd, stdio: "inherit", env });
150
+ return result.status ?? 1;
151
+ }
152
+ var EXEC_ONLY_CONFIG = ["npm_config_allow_scripts", "npm_config_yes", "npm_config_call", "npm_config_package"];
153
+ function childEnv(env = process.env) {
154
+ const next = { ...env };
155
+ for (const key of Object.keys(next)) {
156
+ if (EXEC_ONLY_CONFIG.includes(key.toLowerCase())) delete next[key];
157
+ }
158
+ return next;
159
+ }
160
+
161
+ // src/commands/create.ts
162
+ function toAppName(dir) {
163
+ const name = basename(resolve2(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
164
+ if (!name) throw new Error(`Cannot derive an app name from "${dir}". Use letters, digits, and dashes.`);
165
+ return name;
166
+ }
167
+ function flarePackageSpec(pkg, appDir, packageManager) {
168
+ const repoPackage = resolve2(templatesDir, "../..", pkg);
169
+ const repoRoot = resolve2(repoPackage, "../..");
170
+ const fromCheckout = existsSync2(join2(repoPackage, "package.json")) && existsSync2(join2(repoRoot, "pnpm-workspace.yaml")) && !repoPackage.split(/[\\/]/).includes("node_modules");
171
+ if (!fromCheckout) return `^${FLARE_VERSION}`;
172
+ const rel = relative(repoRoot, appDir);
173
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel)) return "workspace:*";
174
+ const protocol = packageManager === "pnpm" ? "link" : "file";
175
+ return `${protocol}:${repoPackage.replaceAll("\\", "/")}`;
176
+ }
177
+ function createApp(target, options2 = {}) {
178
+ const dir = resolve2(target);
179
+ const name = toAppName(dir);
180
+ if (existsSync2(dir) && readdirSync2(dir).length > 0) {
181
+ throw new Error(`Directory ${dir} already exists and is not empty.`);
182
+ }
183
+ if (options2.pm !== void 0 && !isPackageManager(options2.pm)) {
184
+ throw new Error(`Unknown package manager "${options2.pm}". Use pnpm, npm, yarn, or bun.`);
185
+ }
186
+ const packageManager = options2.pm ?? detectPackageManager();
187
+ const authProviders = parseAuthProviders(options2.authProviders);
188
+ const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
189
+ const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
190
+ copyTemplate(join2(templatesDir, "app"), dir, {
191
+ APP_NAME: name,
192
+ COMPAT_DATE: compatibilityDate,
193
+ PM: packageManager,
194
+ SOCIAL_PROVIDERS: socialProvidersCode(authProviders)
195
+ });
196
+ appendFileSync(join2(dir, ".dev.vars.example"), devVarsExampleEntries(authProviders));
197
+ writeJson(join2(dir, "package.json"), {
198
+ name,
199
+ version: "0.1.0",
200
+ private: true,
201
+ type: "module",
202
+ scripts: {
203
+ dev: "flare dev",
204
+ build: "flare build",
205
+ start: "flare start",
206
+ deploy: "flare deploy",
207
+ "cf-typegen": "wrangler types",
208
+ "db:generate": "drizzle-kit generate",
209
+ "db:migrate:local": "wrangler d1 migrations apply DB --local"
210
+ },
211
+ dependencies: { "@flaredev/core": flarePackageSpec("core", dir, packageManager), ...APP_DEPENDENCIES },
212
+ devDependencies: { "@flaredev/cli": flarePackageSpec("cli", dir, packageManager), ...APP_DEV_DEPENDENCIES }
213
+ });
214
+ writeFileSync2(
215
+ join2(dir, ".dev.vars"),
216
+ `BETTER_AUTH_SECRET=${randomBytes(32).toString("base64")}
217
+ RESEND_API_KEY=
218
+ MAIL_FROM=
219
+ ${devVarsEntries(authProviders)}`
220
+ );
221
+ if (packageManager === "pnpm" && !inWorkspace) {
222
+ writeFileSync2(join2(dir, "pnpm-workspace.yaml"), "allowBuilds:\n esbuild: true\n workerd: true\n sharp: false\n");
223
+ }
224
+ if (options2.install !== false) {
225
+ const code = run(packageManager, ["install"], dir);
226
+ if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
227
+ const typegen = run(packageManager, ["run", "cf-typegen"], dir);
228
+ if (typegen !== 0) throw new Error(`wrangler types failed (exit code ${typegen}).`);
229
+ }
230
+ return { dir, name, packageManager, inWorkspace };
231
+ }
232
+ function printNextSteps(result, installed) {
233
+ const rel = relative(process.cwd(), result.dir) || ".";
234
+ const pm = result.packageManager;
235
+ console.log(`
236
+ ${pc.green("\u2714")} Created ${pc.bold(result.name)} in ${rel}
237
+ `);
238
+ console.log("Next steps:");
239
+ console.log(` cd ${rel}`);
240
+ if (!installed) console.log(` ${pm} install`);
241
+ console.log(` ${pm} run dev`);
242
+ }
243
+
244
+ // src/commands/gen-billing.ts
245
+ import { appendFileSync as appendFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync4, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync5 } from "fs";
246
+ import { dirname as dirname6, join as join10 } from "path";
247
+ import pc4 from "picocolors";
248
+
249
+ // src/generator/grammar.ts
250
+ import { camelCase, FILE_CATEGORIES } from "@flaredev/core";
251
+ var SIMPLE_KINDS = ["string", "text", "int", "float", "boolean", "date", "datetime"];
252
+ var ALL_KINDS = [...SIMPLE_KINDS, "enum", "file", "belongsTo", "hasMany"];
253
+ var FieldGrammarError = class extends Error {
254
+ constructor(message) {
255
+ super(message);
256
+ this.name = "FieldGrammarError";
257
+ }
258
+ };
259
+ function splitTopLevel(input) {
260
+ const parts = [];
261
+ let depth = 0;
262
+ let current = "";
263
+ for (const char of input) {
264
+ if (char === "(" || char === "[") depth++;
265
+ if (char === ")" || char === "]") depth = Math.max(0, depth - 1);
266
+ if (char === "," && depth === 0) {
267
+ parts.push(current);
268
+ current = "";
269
+ } else {
270
+ current += char;
271
+ }
272
+ }
273
+ parts.push(current);
274
+ return parts.map((part) => part.trim()).filter(Boolean);
275
+ }
276
+ function editDistance(a, b) {
277
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
278
+ for (let j = 1; j <= b.length; j++) dp[0][j] = j;
279
+ for (let i = 1; i <= a.length; i++) {
280
+ for (let j = 1; j <= b.length; j++) {
281
+ dp[i][j] = Math.min(
282
+ dp[i - 1][j] + 1,
283
+ dp[i][j - 1] + 1,
284
+ dp[i - 1][j - 1] + (a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? 0 : 1)
285
+ );
286
+ }
287
+ }
288
+ return dp[a.length][b.length];
289
+ }
290
+ function suggest(value, candidates) {
291
+ const best = candidates.map((candidate) => ({ candidate, distance: editDistance(value, candidate) })).sort((x, y) => x.distance - y.distance)[0];
292
+ return best && best.distance <= 2 ? ` Did you mean "${best.candidate}"?` : "";
293
+ }
294
+ function list(raw, what, spec) {
295
+ const items = raw.split(",").map((item) => item.trim()).filter(Boolean);
296
+ if (items.length === 0) throw new FieldGrammarError(`"${spec}": ${what} list is empty.`);
297
+ return items;
298
+ }
299
+ var RESOURCE_NAME = /^[A-Z][A-Za-z0-9]*$/;
300
+ function parseField(spec) {
301
+ const colon = spec.indexOf(":");
302
+ if (colon <= 0) throw new FieldGrammarError(`"${spec}": expected name:type, e.g. "title:string".`);
303
+ const rawName = spec.slice(0, colon).trim();
304
+ let type = spec.slice(colon + 1).trim();
305
+ let required = true;
306
+ let unique = false;
307
+ while (/[?!]$/.test(type)) {
308
+ if (type.endsWith("?")) required = false;
309
+ else unique = true;
310
+ type = type.slice(0, -1).trimEnd();
311
+ }
312
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(rawName)) {
313
+ throw new FieldGrammarError(`"${spec}": field name "${rawName}" must start with a letter and use letters, digits, or _.`);
314
+ }
315
+ let key = camelCase(rawName);
316
+ const field = { key, kind: "string", required, unique };
317
+ let match;
318
+ if (SIMPLE_KINDS.includes(type)) {
319
+ field.kind = type;
320
+ } else if (match = /^enum\((.*)\)$/.exec(type)) {
321
+ field.kind = "enum";
322
+ const options2 = list(match[1], "enum option", spec);
323
+ for (const option of options2) {
324
+ if (!/^[A-Za-z0-9_-]+$/.test(option)) {
325
+ throw new FieldGrammarError(`"${spec}": enum option "${option}" may only use letters, digits, _ and -.`);
326
+ }
327
+ }
328
+ if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate enum options.`);
329
+ field.options = options2;
330
+ } else if (match = /^file(?::\[(.*)\])?$/.exec(type)) {
331
+ field.kind = "file";
332
+ if (match[1] === void 0) {
333
+ throw new FieldGrammarError(`"${spec}": file fields list accepted types, e.g. "file:[image,pdf]".`);
334
+ }
335
+ const categories = Object.keys(FILE_CATEGORIES);
336
+ const accept = list(match[1], "file type", spec).map((category) => category.toLowerCase());
337
+ for (const category of accept) {
338
+ if (!categories.includes(category)) {
339
+ throw new FieldGrammarError(
340
+ `"${spec}": unknown file type "${category}".${suggest(category, categories)} Supported: ${categories.join(", ")}.`
341
+ );
342
+ }
343
+ }
344
+ field.accept = [...new Set(accept)];
345
+ } else if (match = /^(belongsTo|hasMany)\((.*)\)$/i.exec(type)) {
346
+ const kind = match[1].toLowerCase() === "belongsto" ? "belongsTo" : "hasMany";
347
+ const target = match[2].trim();
348
+ if (!RESOURCE_NAME.test(target)) {
349
+ throw new FieldGrammarError(`"${spec}": relation target "${target}" must be a PascalCase resource name, e.g. "Company".`);
350
+ }
351
+ field.kind = kind;
352
+ field.target = target;
353
+ if (kind === "belongsTo" && !key.endsWith("Id")) key = `${key}Id`;
354
+ if (kind === "hasMany") {
355
+ if (!required || unique) throw new FieldGrammarError(`"${spec}": hasMany fields can't be optional or unique.`);
356
+ field.required = false;
357
+ }
358
+ field.key = key;
359
+ } else {
360
+ const base = type.replace(/[([].*$/, "").replace(/:$/, "");
361
+ throw new FieldGrammarError(`"${spec}": unknown type "${type}".${suggest(base, ALL_KINDS)} Types: ${ALL_KINDS.join(", ")}.`);
362
+ }
363
+ if (field.kind === "string") {
364
+ if (/^email$|Email$/.test(key)) field.format = "email";
365
+ else if (/^(url|website)$|(Url|Website)$/.test(key)) field.format = "url";
366
+ }
367
+ return field;
368
+ }
369
+ function parseFields(input) {
370
+ const specs = splitTopLevel(input);
371
+ if (specs.length === 0) throw new FieldGrammarError('No fields given. Example: --fields "name:string, email:string"');
372
+ const fields = specs.map(parseField);
373
+ const seen = /* @__PURE__ */ new Set();
374
+ for (const field of fields) {
375
+ if (seen.has(field.key)) throw new FieldGrammarError(`Field "${field.key}" is declared more than once.`);
376
+ seen.add(field.key);
377
+ }
378
+ return fields;
379
+ }
380
+
381
+ // src/generator/descriptor.ts
382
+ import { defineResource, field as builders, kebabCase, pascalCase } from "@flaredev/core";
383
+
384
+ // src/generator/markers.ts
385
+ import { createHash } from "crypto";
386
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
387
+ import { dirname as dirname3 } from "path";
388
+ var START = "// generated:start";
389
+ var END = "// generated:end";
390
+ var normalize = (content) => content.replace(/\r\n/g, "\n").replace(/[ \t]+$/gm, "");
391
+ function hashBlock(content) {
392
+ return createHash("sha256").update(normalize(content)).digest("hex").slice(0, 12);
393
+ }
394
+ function dedent(content, indent) {
395
+ if (!indent) return content;
396
+ return content.split("\n").map((line) => line.startsWith(indent) ? line.slice(indent.length) : line).join("\n");
397
+ }
398
+ function indentBlock(content, indent) {
399
+ if (!indent) return content;
400
+ return content.split("\n").map((line) => line ? indent + line : line).join("\n");
401
+ }
402
+ function splitMarkers(source) {
403
+ const text = source.replace(/\r\n/g, "\n");
404
+ const startCount = text.split(START).length - 1;
405
+ const endCount = text.split(END).length - 1;
406
+ if (startCount === 0 && endCount === 0) return null;
407
+ const start = text.indexOf(START);
408
+ const end = text.indexOf(END);
409
+ if (startCount !== 1 || endCount !== 1 || end < start) {
410
+ throw new Error("Expected exactly one // generated:start \u2026 // generated:end block.");
411
+ }
412
+ const lineStart = text.lastIndexOf("\n", start) + 1;
413
+ const indent = text.slice(lineStart, start);
414
+ const startLineEnd = text.indexOf("\n", start);
415
+ const startLine = text.slice(start, startLineEnd === -1 ? void 0 : startLineEnd);
416
+ const hash = /hash=([0-9a-f]{12})/.exec(startLine)?.[1];
417
+ const endLineStart = text.lastIndexOf("\n", end) + 1;
418
+ const endLineEnd = text.indexOf("\n", end);
419
+ return {
420
+ before: text.slice(0, lineStart),
421
+ block: dedent(text.slice(startLineEnd + 1, endLineStart), indent),
422
+ after: endLineEnd === -1 ? "" : text.slice(endLineEnd + 1),
423
+ indent,
424
+ hash
425
+ };
426
+ }
427
+ function joinMarkers({ before, block, after, indent, hash }) {
428
+ const body = block === "" || block.endsWith("\n") ? block : `${block}
429
+ `;
430
+ const startMarker = hash ? `${START} hash=${hash}` : START;
431
+ return `${before}${indent}${startMarker}
432
+ ${indentBlock(body, indent)}${indent}${END}
433
+ ${after}`;
434
+ }
435
+ function inspectGenerated(path, content) {
436
+ if (!existsSync3(path)) return "missing";
437
+ const parts = splitMarkers(readFileSync2(path, "utf8"));
438
+ if (!parts) return "unmarked";
439
+ if (parts.hash && hashBlock(parts.block) !== parts.hash) return "drift";
440
+ if (normalize(parts.block) !== normalize(content)) return "outdated";
441
+ return parts.hash === hashBlock(content) ? "identical" : "untracked";
442
+ }
443
+ var DriftError = class extends Error {
444
+ constructor(path) {
445
+ super(`${path}: the generated block was edited by hand. Move custom code outside the block, or pass --force to overwrite it.`);
446
+ this.path = path;
447
+ this.name = "DriftError";
448
+ }
449
+ path;
450
+ };
451
+ function writeGenerated(path, content, options2 = {}) {
452
+ const hash = hashBlock(content);
453
+ if (!existsSync3(path)) {
454
+ mkdirSync2(dirname3(path), { recursive: true });
455
+ writeFileSync3(path, joinMarkers({ before: options2.header ?? "", block: content, after: "", indent: "", hash }));
456
+ return "create";
457
+ }
458
+ const source = readFileSync2(path, "utf8");
459
+ const parts = splitMarkers(source);
460
+ if (!parts) throw new Error(`${path} exists but has no generated block; refusing to overwrite hand-written code.`);
461
+ if (parts.hash && hashBlock(parts.block) !== parts.hash && !options2.force) throw new DriftError(path);
462
+ const next = joinMarkers({ ...parts, block: content, hash });
463
+ if (next === source) return "identical";
464
+ writeFileSync3(path, next);
465
+ return "update";
466
+ }
467
+
468
+ // src/generator/descriptor.ts
469
+ function resourceName(input) {
470
+ const name = pascalCase(input);
471
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) throw new Error(`Invalid resource name "${input}". Use letters and digits, e.g. "Contact".`);
472
+ return name;
473
+ }
474
+ var descriptorPath = (name) => `resources/${kebabCase(name)}.resource.ts`;
475
+ function options(field) {
476
+ const opts = {};
477
+ if (field.kind === "hasMany") return opts;
478
+ if (!field.required) opts.required = false;
479
+ if (field.unique) opts.unique = true;
480
+ if (field.format) opts.format = field.format;
481
+ if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
482
+ return opts;
483
+ }
484
+ function toField(field) {
485
+ const opts = options(field);
486
+ switch (field.kind) {
487
+ case "enum":
488
+ return builders.enum(field.options, opts);
489
+ case "file":
490
+ return builders.file(field.accept, opts);
491
+ case "belongsTo":
492
+ return builders.belongsTo(field.target, opts);
493
+ case "hasMany":
494
+ return builders.hasMany(field.target);
495
+ default:
496
+ return builders[field.kind](opts);
497
+ }
498
+ }
499
+ var literal = (value) => JSON.stringify(value);
500
+ function renderOptions(opts) {
501
+ const entries = Object.entries(opts);
502
+ return entries.length ? `{ ${entries.map(([k, v]) => `${k}: ${literal(v)}`).join(", ")} }` : "";
503
+ }
504
+ function renderField(field) {
505
+ const opts = renderOptions(options(field));
506
+ const args = (...parts) => parts.filter(Boolean).join(", ");
507
+ switch (field.kind) {
508
+ case "enum":
509
+ return `${field.key}: field.enum(${args(literal(field.options), opts)}),`;
510
+ case "file":
511
+ return `${field.key}: field.file(${args(literal(field.accept), opts)}),`;
512
+ case "belongsTo":
513
+ return `${field.key}: field.belongsTo(${args(literal(field.target), opts)}),`;
514
+ case "hasMany":
515
+ return `${field.key}: field.hasMany(${literal(field.target)}),`;
516
+ default:
517
+ return `${field.key}: field.${field.kind}(${opts}),`;
518
+ }
519
+ }
520
+ function renderFieldsBlock(fields) {
521
+ return fields.map((f) => `${renderField(f)}
522
+ `).join("");
523
+ }
524
+ function renderDescriptor(name, fields) {
525
+ defineResource({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
526
+ const block = renderFieldsBlock(fields);
527
+ return joinMarkers({
528
+ before: `import { defineResource, field } from "@flaredev/core";
529
+
530
+ export default defineResource({
531
+ name: ${literal(name)},
532
+ fields: {
533
+ `,
534
+ block,
535
+ after: ` },
536
+ });
537
+ `,
538
+ indent: " ",
539
+ hash: hashBlock(block)
540
+ });
541
+ }
542
+
543
+ // src/generator/billing.ts
544
+ var BILLING_PROVIDER = "stripe";
545
+ var BILLING_MODES = ["subscriptions"];
546
+ function validateBillingOptions(provider, mode) {
547
+ if (provider && provider !== BILLING_PROVIDER) {
548
+ throw new Error(`--provider "${provider}" is not supported yet. Supported: ${BILLING_PROVIDER}.`);
549
+ }
550
+ if (mode && !BILLING_MODES.includes(mode)) {
551
+ throw new Error(`--mode "${mode}" is not supported. Supported: ${BILLING_MODES.join(", ")} (it includes one-time checkout too).`);
552
+ }
553
+ }
554
+ var PLAN_FIELDS = "name:string, slug:string!, description:text?, stripeProductId:string?, stripePriceId:string!, amount:int, currency:string, interval:enum(month,year)?, active:boolean?, sort:int?";
555
+ var CUSTOMER_FIELDS = "name:string, email:string!, userId:string!?, stripeCustomerId:string!?, stripeSubscriptionId:string?, subscriptionStatus:enum(none,incomplete,trialing,active,past_due,paused,canceled,unpaid)?, subscriptionEndsAt:datetime?, cancelAtPeriodEnd:boolean?, planId:belongsTo(Plan)?";
556
+ var PURCHASE_FIELDS = "customerId:belongsTo(Customer), planId:belongsTo(Plan)?, stripeCheckoutSessionId:string!, amount:int, currency:string, status:enum(pending,paid,failed)";
557
+ var BILLING_RESOURCES = {
558
+ Plan: PLAN_FIELDS,
559
+ Customer: CUSTOMER_FIELDS,
560
+ Purchase: PURCHASE_FIELDS
561
+ };
562
+ var billingFields = (name) => parseFields(BILLING_RESOURCES[name]);
563
+ var renderBillingDescriptor = (name) => renderDescriptor(name, billingFields(name));
564
+ function mergeBillingFields(name, block) {
565
+ const present = new Set([...block.matchAll(/^\s*(\w+)\s*:/gm)].map((match) => match[1]));
566
+ const missing = billingFields(name).filter((field) => !present.has(field.key));
567
+ if (missing.length === 0) return null;
568
+ const lines = missing.map((field) => `${renderField(field)}
569
+ `).join("");
570
+ return `${block.replace(/\s*$/, "\n")}${lines}`;
571
+ }
572
+ var BILLING_POLICIES = {
573
+ Plan: { read: ["*"], create: ["admin"], update: ["admin"], delete: ["admin"] },
574
+ Customer: { read: ["admin", "staff"], create: ["admin"], update: ["admin"], delete: ["admin"] },
575
+ Purchase: { read: ["admin", "staff"], create: ["admin"], update: ["admin"], delete: ["admin"] }
576
+ };
577
+ var billingHeader = () => `// Generated by \`flare gen billing\`. Code inside the generated block is
578
+ // rewritten on regeneration; add your own code outside it.
579
+ `;
580
+ var STRIPE_API_VERSION = "2026-08-26.dahlia";
581
+ var renderLibStripe = (appName) => `import Stripe from "stripe";
582
+ import { env } from "cloudflare:workers";
583
+ import { and, eq, isNull } from "drizzle-orm";
584
+ import { checkoutPaid, shouldApplySubscription, subscriptionState } from "@flaredev/core";
585
+ import { getDb } from "@/db";
586
+ import { customers, plans, purchases } from "@/db/schema";
587
+
588
+ export type CustomerRow = typeof customers.$inferSelect;
589
+
590
+ /** This app's tag in Stripe metadata (flare_app), so several apps can share one Stripe account. */
591
+ const FLARE_APP = ${JSON.stringify(appName)};
592
+
593
+ export function stripeSecrets(): { STRIPE_SECRET_KEY?: string; STRIPE_WEBHOOK_SECRET?: string; BETTER_AUTH_URL?: string } {
594
+ return env as { STRIPE_SECRET_KEY?: string; STRIPE_WEBHOOK_SECRET?: string; BETTER_AUTH_URL?: string };
595
+ }
596
+
597
+ /** The Stripe client. Throws a clear error when STRIPE_SECRET_KEY is unset. */
598
+ export function createStripe(): Stripe {
599
+ const { STRIPE_SECRET_KEY } = stripeSecrets();
600
+ if (!STRIPE_SECRET_KEY) {
601
+ throw new Error("STRIPE_SECRET_KEY is not set. Add it to .dev.vars locally and run \`wrangler secret put STRIPE_SECRET_KEY\` for production.");
602
+ }
603
+ return new Stripe(STRIPE_SECRET_KEY, { apiVersion: "${STRIPE_API_VERSION}", httpClient: Stripe.createFetchHttpClient() });
604
+ }
605
+
606
+ /**
607
+ * The app's own origin for Checkout and portal return URLs. Comes from
608
+ * BETTER_AUTH_URL when set, else the request URL, never from forwarded headers
609
+ * a client could spoof to send users elsewhere after paying.
610
+ */
611
+ export function appOrigin(request: Request): string {
612
+ const configured = stripeSecrets().BETTER_AUTH_URL;
613
+ return (configured ? new URL(configured) : new URL(request.url)).origin;
614
+ }
615
+
616
+ /**
617
+ * The Customer row that pays for a signed-in user, with a Stripe customer attached.
618
+ * Creates both on first use, links a row an admin created for the same email, and
619
+ * backfills a row that has no Stripe customer yet. The idempotency key makes a
620
+ * double click create one Stripe customer, not two.
621
+ */
622
+ export async function getOrCreateCustomer(user: { id: string; email: string; name: string | null }): Promise<CustomerRow & { stripeCustomerId: string }> {
623
+ const db = getDb();
624
+ let row: CustomerRow | undefined =
625
+ (await db.select().from(customers).where(eq(customers.userId, user.id)).get()) ??
626
+ (await db
627
+ .update(customers)
628
+ .set({ userId: user.id })
629
+ .where(and(eq(customers.email, user.email), isNull(customers.userId)))
630
+ .returning()
631
+ .get());
632
+ if (!row) {
633
+ row = await db
634
+ .insert(customers)
635
+ .values({ id: crypto.randomUUID(), name: user.name ?? user.email, email: user.email, userId: user.id, subscriptionStatus: "none", createdAt: new Date(), updatedAt: new Date() })
636
+ .onConflictDoNothing()
637
+ .returning()
638
+ .get();
639
+ row ??= await db.select().from(customers).where(eq(customers.userId, user.id)).get();
640
+ if (!row) throw new Error("Could not create the customer record.");
641
+ }
642
+ if (row.stripeCustomerId) return row as CustomerRow & { stripeCustomerId: string };
643
+
644
+ const stripeCustomer = await createStripe().customers.create(
645
+ { email: user.email, name: user.name ?? undefined, metadata: { flareCustomerId: row.id } },
646
+ { idempotencyKey: \`flare-customer-\${row.id}\` },
647
+ );
648
+ const linked = await db.update(customers).set({ stripeCustomerId: stripeCustomer.id, updatedAt: new Date() }).where(eq(customers.id, row.id)).returning().get();
649
+ return linked as CustomerRow & { stripeCustomerId: string };
650
+ }
651
+
652
+ let portalConfiguration: string | undefined;
653
+
654
+ /**
655
+ * The portal configuration \`flare billing:sync-plans\` maintains for this app (metadata flare_app),
656
+ * which allows switching between the synced plans. Undefined falls back to the
657
+ * account's default portal settings.
658
+ */
659
+ export async function billingPortalConfiguration(stripe: Stripe): Promise<string | undefined> {
660
+ if (portalConfiguration) return portalConfiguration;
661
+ for await (const configuration of stripe.billingPortal.configurations.list({ active: true, limit: 100 })) {
662
+ if (configuration.metadata?.flare_app === FLARE_APP) return (portalConfiguration = configuration.id);
663
+ }
664
+ return undefined;
665
+ }
666
+
667
+ function idOf(value: string | { id: string } | null | undefined): string | null {
668
+ if (!value) return null;
669
+ return typeof value === "string" ? value : value.id;
670
+ }
671
+
672
+ /**
673
+ * Write a subscription's CURRENT state to its Customer. Every event re-fetches the
674
+ * subscription from Stripe instead of trusting the event payload, so duplicate and
675
+ * out-of-order deliveries all converge on the same, latest state.
676
+ */
677
+ export async function syncSubscription(subscriptionId: string): Promise<void> {
678
+ const subscription = await createStripe().subscriptions.retrieve(subscriptionId);
679
+ const db = getDb();
680
+ const customer = await db.select().from(customers).where(eq(customers.stripeCustomerId, idOf(subscription.customer) ?? "")).get();
681
+ if (!customer) return;
682
+ if (!shouldApplySubscription(customer, subscription)) return;
683
+
684
+ const state = subscriptionState(subscription);
685
+ const plan = state.priceId ? await db.select({ id: plans.id }).from(plans).where(eq(plans.stripePriceId, state.priceId)).get() : undefined;
686
+ await db
687
+ .update(customers)
688
+ .set({
689
+ stripeSubscriptionId: state.stripeSubscriptionId,
690
+ subscriptionStatus: state.subscriptionStatus,
691
+ // datetime fields are stored as ISO text.
692
+ subscriptionEndsAt: state.subscriptionEndsAt?.toISOString() ?? null,
693
+ cancelAtPeriodEnd: state.cancelAtPeriodEnd,
694
+ planId: plan?.id ?? null,
695
+ updatedAt: new Date(),
696
+ })
697
+ .where(eq(customers.id, customer.id));
698
+ }
699
+
700
+ /**
701
+ * A Checkout Session finished. Subscriptions sync from Stripe; one-time payments
702
+ * record a Purchase, marked paid only once the money has settled.
703
+ */
704
+ export async function syncCheckoutSession(sessionId: string): Promise<void> {
705
+ const session = await createStripe().checkout.sessions.retrieve(sessionId, { expand: ["line_items"] });
706
+ if (session.mode === "subscription") {
707
+ const subscriptionId = idOf(session.subscription);
708
+ if (subscriptionId) await syncSubscription(subscriptionId);
709
+ return;
710
+ }
711
+ if (session.mode !== "payment") return;
712
+
713
+ const db = getDb();
714
+ const customer = await db.select().from(customers).where(eq(customers.stripeCustomerId, idOf(session.customer) ?? "")).get();
715
+ if (!customer) return;
716
+ const priceId = session.line_items?.data?.[0]?.price?.id ?? null;
717
+ const plan = priceId ? await db.select({ id: plans.id }).from(plans).where(eq(plans.stripePriceId, priceId)).get() : undefined;
718
+ const status = checkoutPaid(session) ? "paid" : session.status === "expired" ? "failed" : "pending";
719
+ await db
720
+ .insert(purchases)
721
+ .values({
722
+ id: crypto.randomUUID(),
723
+ customerId: customer.id,
724
+ planId: plan?.id ?? null,
725
+ stripeCheckoutSessionId: session.id,
726
+ amount: session.amount_total ?? 0,
727
+ currency: session.currency ?? "usd",
728
+ status,
729
+ createdAt: new Date(),
730
+ updatedAt: new Date(),
731
+ })
732
+ .onConflictDoUpdate({ target: purchases.stripeCheckoutSessionId, set: { status, updatedAt: new Date() } });
733
+ }
734
+
735
+ /** A delayed one-time payment failed after the session completed. */
736
+ export async function failCheckoutSession(sessionId: string): Promise<void> {
737
+ await getDb().update(purchases).set({ status: "failed", updatedAt: new Date() }).where(eq(purchases.stripeCheckoutSessionId, sessionId));
738
+ }
739
+
740
+ /** The subscription an invoice belongs to (its location moved in API 2025-03-31). */
741
+ export function invoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
742
+ const legacy = (invoice as unknown as { subscription?: string | { id: string } | null }).subscription;
743
+ return idOf(invoice.parent?.subscription_details?.subscription ?? legacy ?? null);
744
+ }
745
+ `;
746
+ var renderLibBilling = () => `import { asc, desc, eq } from "drizzle-orm";
747
+ import { grantsAccess, isLiveSubscription, type SubscriptionStatus } from "@flaredev/core";
748
+ import { getDb } from "@/db";
749
+ import { customers, plans, purchases } from "@/db/schema";
750
+
751
+ export interface BillingSnapshot {
752
+ status: SubscriptionStatus;
753
+ /** Has a subscription that must be changed through the portal, not bought again. */
754
+ live: boolean;
755
+ /** Paid features should be on. */
756
+ access: boolean;
757
+ planId: string | null;
758
+ endsAt: Date | null;
759
+ cancelAtPeriodEnd: boolean;
760
+ }
761
+
762
+ /** The subscription state of a user's Customer row. */
763
+ export async function billingSnapshot(userId: string): Promise<BillingSnapshot> {
764
+ const customer = await getDb().select().from(customers).where(eq(customers.userId, userId)).get();
765
+ const status = (customer?.subscriptionStatus ?? "none") as SubscriptionStatus;
766
+ return {
767
+ status,
768
+ live: isLiveSubscription(status),
769
+ access: grantsAccess(status),
770
+ planId: customer?.planId ?? null,
771
+ endsAt: customer?.subscriptionEndsAt ? new Date(customer.subscriptionEndsAt) : null,
772
+ cancelAtPeriodEnd: customer?.cancelAtPeriodEnd ?? false,
773
+ };
774
+ }
775
+
776
+ /** Active plans, cheapest first: subscriptions (with an interval) and one-time products. */
777
+ export async function availablePlans() {
778
+ const rows = await getDb()
779
+ .select({ id: plans.id, name: plans.name, slug: plans.slug, description: plans.description, amount: plans.amount, currency: plans.currency, interval: plans.interval, active: plans.active })
780
+ .from(plans)
781
+ .orderBy(asc(plans.sort), asc(plans.amount));
782
+ const active = rows.filter((plan) => plan.active !== false);
783
+ return { subscriptions: active.filter((plan) => plan.interval), oneTime: active.filter((plan) => !plan.interval) };
784
+ }
785
+
786
+ /** A user's one-time purchases, newest first. */
787
+ export async function userPurchases(userId: string) {
788
+ const customer = await getDb().select({ id: customers.id }).from(customers).where(eq(customers.userId, userId)).get();
789
+ if (!customer) return [];
790
+ return getDb()
791
+ .select({ id: purchases.id, status: purchases.status, amount: purchases.amount, currency: purchases.currency, createdAt: purchases.createdAt, plan: plans.name })
792
+ .from(purchases)
793
+ .leftJoin(plans, eq(purchases.planId, plans.id))
794
+ .where(eq(purchases.customerId, customer.id))
795
+ .orderBy(desc(purchases.createdAt));
796
+ }
797
+ `;
798
+ var renderCheckoutRoute = () => `import { NextRequest, NextResponse } from "next/server";
799
+ import { eq } from "drizzle-orm";
800
+ import { isLiveSubscription, randomLetters } from "@flaredev/core";
801
+ import { getDb } from "@/db";
802
+ import { plans } from "@/db/schema";
803
+ import { getSession } from "@/lib/session";
804
+ import { appOrigin, createStripe, getOrCreateCustomer } from "@/lib/stripe";
805
+
806
+ /**
807
+ * POST { planSlug }. The plan decides the mode: a recurring price starts a
808
+ * subscription, a one-time price a payment. Prices only ever come from the Plan
809
+ * table, so a client can't pay for an arbitrary price. A customer who already has
810
+ * a subscription changes plans through the portal instead of buying a second one.
811
+ */
812
+ export async function POST(request: NextRequest) {
813
+ const raw = await request.text();
814
+ const session = await getSession();
815
+ if (!session) return NextResponse.json({ error: "Sign in to check out." }, { status: 401 });
816
+
817
+ let planSlug = "";
818
+ try {
819
+ planSlug = String((JSON.parse(raw) as { planSlug?: unknown }).planSlug ?? "");
820
+ } catch {
821
+ return NextResponse.json({ error: "Send JSON like {\\"planSlug\\": \\"pro\\"}." }, { status: 400 });
822
+ }
823
+ const plan = planSlug ? await getDb().select().from(plans).where(eq(plans.slug, planSlug)).get() : undefined;
824
+ if (!plan || plan.active === false) return NextResponse.json({ error: "That plan isn't available." }, { status: 404 });
825
+
826
+ const mode = plan.interval ? "subscription" : "payment";
827
+ const customer = await getOrCreateCustomer(session.user);
828
+ if (mode === "subscription" && isLiveSubscription(customer.subscriptionStatus)) {
829
+ return NextResponse.json({ error: "You already have a subscription. Change plans from the billing page." }, { status: 409 });
830
+ }
831
+
832
+ const base = appOrigin(request);
833
+ const checkout = await createStripe().checkout.sessions.create({
834
+ mode,
835
+ line_items: [{ price: plan.stripePriceId, quantity: 1 }],
836
+ customer: customer.stripeCustomerId,
837
+ client_reference_id: customer.id,
838
+ integration_identifier: \`flare-billing-\${randomLetters()}\`,
839
+ success_url: \`\${base}/dashboard/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}\`,
840
+ cancel_url: \`\${base}/dashboard/billing?checkout=cancelled\`,
841
+ });
842
+ if (!checkout.url) return NextResponse.json({ error: "Stripe did not return a checkout URL." }, { status: 502 });
843
+ return NextResponse.json({ url: checkout.url });
844
+ }
845
+ `;
846
+ var renderPortalRoute = () => `import { NextRequest, NextResponse } from "next/server";
847
+ import { eq } from "drizzle-orm";
848
+ import { isLiveSubscription } from "@flaredev/core";
849
+ import { getDb } from "@/db";
850
+ import { plans } from "@/db/schema";
851
+ import { getSession } from "@/lib/session";
852
+ import { appOrigin, billingPortalConfiguration, createStripe, getOrCreateCustomer } from "@/lib/stripe";
853
+
854
+ /**
855
+ * POST {} opens the portal (payment methods, invoices, cancel). POST { planSlug }
856
+ * opens its plan-change confirmation for that plan: Stripe shows the prorated
857
+ * amount and applies the change to the existing subscription, so switching plans
858
+ * never starts a second one.
859
+ */
860
+ export async function POST(request: NextRequest) {
861
+ const raw = await request.text();
862
+ const session = await getSession();
863
+ if (!session) return NextResponse.json({ error: "Sign in to manage billing." }, { status: 401 });
864
+
865
+ let planSlug = "";
866
+ try {
867
+ planSlug = raw ? String((JSON.parse(raw) as { planSlug?: unknown }).planSlug ?? "") : "";
868
+ } catch {
869
+ return NextResponse.json({ error: "Send JSON like {\\"planSlug\\": \\"pro\\"}, or no body." }, { status: 400 });
870
+ }
871
+
872
+ const customer = await getOrCreateCustomer(session.user);
873
+ const stripe = createStripe();
874
+ const returnUrl = \`\${appOrigin(request)}/dashboard/billing\`;
875
+
876
+ const configuration = await billingPortalConfiguration(stripe);
877
+
878
+ if (!planSlug) {
879
+ const portal = await stripe.billingPortal.sessions.create({ customer: customer.stripeCustomerId, return_url: returnUrl, configuration });
880
+ return NextResponse.json({ url: portal.url });
881
+ }
882
+
883
+ const plan = await getDb().select().from(plans).where(eq(plans.slug, planSlug)).get();
884
+ if (!plan || plan.active === false || !plan.interval) return NextResponse.json({ error: "That plan isn't available." }, { status: 404 });
885
+ if (!customer.stripeSubscriptionId || !isLiveSubscription(customer.subscriptionStatus)) {
886
+ return NextResponse.json({ error: "You don't have a subscription to change yet. Subscribe first." }, { status: 409 });
887
+ }
888
+ const subscription = await stripe.subscriptions.retrieve(customer.stripeSubscriptionId);
889
+ const item = subscription.items.data[0];
890
+ if (!item) return NextResponse.json({ error: "The subscription has no items to change." }, { status: 409 });
891
+
892
+ const portal = await stripe.billingPortal.sessions.create({
893
+ customer: customer.stripeCustomerId,
894
+ return_url: returnUrl,
895
+ configuration,
896
+ flow_data: {
897
+ type: "subscription_update_confirm",
898
+ subscription_update_confirm: {
899
+ subscription: subscription.id,
900
+ items: [{ id: item.id, price: plan.stripePriceId, quantity: 1 }],
901
+ },
902
+ after_completion: { type: "redirect", redirect: { return_url: \`\${returnUrl}?changed=1\` } },
903
+ },
904
+ });
905
+ return NextResponse.json({ url: portal.url });
906
+ }
907
+ `;
908
+ var renderWebhookRoute = () => `import { NextRequest, NextResponse } from "next/server";
909
+ import Stripe from "stripe";
910
+ import { createStripe, failCheckoutSession, invoiceSubscriptionId, stripeSecrets, syncCheckoutSession, syncSubscription } from "@/lib/stripe";
911
+
912
+ /**
913
+ * Stripe calls this, so it has no session: the signature is the authentication.
914
+ * Point a webhook endpoint at /api/webhooks/stripe with these events:
915
+ * checkout.session.completed, checkout.session.async_payment_succeeded,
916
+ * checkout.session.async_payment_failed, customer.subscription.created,
917
+ * customer.subscription.updated, customer.subscription.deleted,
918
+ * customer.subscription.paused, customer.subscription.resumed,
919
+ * invoice.paid, invoice.payment_failed
920
+ */
921
+ export async function POST(request: NextRequest) {
922
+ // The raw body: the signature covers these exact bytes.
923
+ const payload = await request.text();
924
+ const { STRIPE_WEBHOOK_SECRET } = stripeSecrets();
925
+ if (!STRIPE_WEBHOOK_SECRET) {
926
+ return NextResponse.json({ error: "STRIPE_WEBHOOK_SECRET is not set." }, { status: 501 });
927
+ }
928
+ const signature = request.headers.get("stripe-signature");
929
+ if (!signature) return NextResponse.json({ error: "Missing stripe-signature header." }, { status: 400 });
930
+
931
+ let event: Stripe.Event;
932
+ try {
933
+ // Workers only offer async (SubtleCrypto) HMAC, so the async variant is required here.
934
+ event = await createStripe().webhooks.constructEventAsync(payload, signature, STRIPE_WEBHOOK_SECRET, undefined, Stripe.createSubtleCryptoProvider());
935
+ } catch {
936
+ return NextResponse.json({ error: "Invalid signature." }, { status: 400 });
937
+ }
938
+
939
+ // Each handler re-fetches the object from Stripe, so duplicates and
940
+ // out-of-order deliveries are harmless. A thrown error returns 500 and Stripe retries.
941
+ switch (event.type) {
942
+ case "checkout.session.completed":
943
+ case "checkout.session.async_payment_succeeded":
944
+ await syncCheckoutSession(event.data.object.id);
945
+ break;
946
+ case "checkout.session.async_payment_failed":
947
+ await failCheckoutSession(event.data.object.id);
948
+ break;
949
+ case "customer.subscription.created":
950
+ case "customer.subscription.updated":
951
+ case "customer.subscription.deleted":
952
+ case "customer.subscription.paused":
953
+ case "customer.subscription.resumed":
954
+ await syncSubscription(event.data.object.id);
955
+ break;
956
+ case "invoice.paid":
957
+ case "invoice.payment_failed": {
958
+ const subscriptionId = invoiceSubscriptionId(event.data.object);
959
+ if (subscriptionId) await syncSubscription(subscriptionId);
960
+ break;
961
+ }
962
+ }
963
+ return NextResponse.json({ received: true });
964
+ }
965
+ `;
966
+ var renderBillingButton = () => `"use client";
967
+
968
+ import { useState } from "react";
969
+ import { Button } from "@/components/ui/button";
970
+ import { Spinner } from "@/components/ui/spinner";
971
+
972
+ /**
973
+ * Starts a Stripe flow: Checkout (\`/api/billing/checkout\`) or the portal
974
+ * (\`/api/billing/portal\`), optionally for one plan. Errors show under the button.
975
+ */
976
+ export function BillingButton({
977
+ endpoint,
978
+ planSlug,
979
+ variant = "default",
980
+ children,
981
+ }: {
982
+ endpoint: "/api/billing/checkout" | "/api/billing/portal";
983
+ planSlug?: string;
984
+ variant?: "default" | "outline" | "secondary";
985
+ children: React.ReactNode;
986
+ }) {
987
+ const [busy, setBusy] = useState(false);
988
+ const [error, setError] = useState<string | null>(null);
989
+
990
+ async function start() {
991
+ setBusy(true);
992
+ setError(null);
993
+ try {
994
+ const response = await fetch(endpoint, {
995
+ method: "POST",
996
+ headers: { "Content-Type": "application/json" },
997
+ body: JSON.stringify(planSlug ? { planSlug } : {}),
998
+ });
999
+ const data = (await response.json().catch(() => null)) as { url?: string; error?: string } | null;
1000
+ if (!response.ok || !data?.url) {
1001
+ setError(data?.error ?? "Stripe could not be reached. Try again in a moment.");
1002
+ setBusy(false);
1003
+ return;
1004
+ }
1005
+ window.location.assign(data.url);
1006
+ } catch {
1007
+ setError("Stripe could not be reached. Try again in a moment.");
1008
+ setBusy(false);
1009
+ }
1010
+ }
1011
+
1012
+ return (
1013
+ <div className="flex flex-col items-start gap-2">
1014
+ <Button onClick={start} disabled={busy} variant={variant}>
1015
+ {busy && <Spinner data-icon="inline-start" />}
1016
+ {children}
1017
+ </Button>
1018
+ {error && (
1019
+ <p role="alert" className="text-sm text-destructive">
1020
+ {error}
1021
+ </p>
1022
+ )}
1023
+ </div>
1024
+ );
1025
+ }
1026
+ `;
1027
+ var renderBillingPortalButton = () => `"use client";
1028
+
1029
+ import { BillingButton } from "./billing-button";
1030
+
1031
+ /** Opens the Stripe Customer Portal: payment methods, invoices, and cancellation. */
1032
+ export function BillingPortalButton({ children = "Manage billing" }: { children?: React.ReactNode }) {
1033
+ return (
1034
+ <BillingButton endpoint="/api/billing/portal" variant="outline">
1035
+ {children}
1036
+ </BillingButton>
1037
+ );
1038
+ }
1039
+ `;
1040
+ var renderBillingPage = () => `import Link from "next/link";
1041
+ import { Badge } from "@/components/ui/badge";
1042
+ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
1043
+ import { Alert, AlertDescription } from "@/components/ui/alert";
1044
+ import { BillingButton } from "@/components/billing/billing-button";
1045
+ import { BillingPortalButton } from "@/components/billing/billing-portal-button";
1046
+ import { requireSession } from "@/lib/session";
1047
+ import { availablePlans, billingSnapshot, userPurchases } from "@/lib/billing";
1048
+
1049
+ const STATUS: Record<string, { label: string; tone: "secondary" | "success" | "warning" | "danger" }> = {
1050
+ none: { label: "No subscription", tone: "secondary" },
1051
+ incomplete: { label: "Payment pending", tone: "warning" },
1052
+ trialing: { label: "Trial", tone: "success" },
1053
+ active: { label: "Active", tone: "success" },
1054
+ past_due: { label: "Payment failed, retrying", tone: "warning" },
1055
+ paused: { label: "Paused", tone: "secondary" },
1056
+ canceled: { label: "Canceled", tone: "secondary" },
1057
+ unpaid: { label: "Unpaid", tone: "danger" },
1058
+ };
1059
+
1060
+ const money = (cents: number, currency: string) =>
1061
+ new Intl.NumberFormat(undefined, { style: "currency", currency: currency.toUpperCase() }).format(cents / 100);
1062
+ const day = (date: Date) => new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
1063
+
1064
+ export default async function BillingPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
1065
+ const session = await requireSession("/dashboard/billing");
1066
+ const [billing, { subscriptions, oneTime }, purchases, params] = await Promise.all([
1067
+ billingSnapshot(session.user.id),
1068
+ availablePlans(),
1069
+ userPurchases(session.user.id),
1070
+ searchParams,
1071
+ ]);
1072
+ const status = STATUS[billing.status] ?? STATUS.none!;
1073
+ const current = subscriptions.find((plan) => billing.live && plan.id === billing.planId);
1074
+
1075
+ return (
1076
+ <main className="mx-auto flex w-full max-w-3xl flex-col gap-8 px-4 py-10">
1077
+ <div className="flex flex-wrap items-start justify-between gap-4">
1078
+ <div className="flex flex-col gap-1">
1079
+ <h1 className="text-2xl font-semibold">Billing</h1>
1080
+ <p className="text-sm text-muted-foreground">
1081
+ {current
1082
+ ? billing.cancelAtPeriodEnd && billing.endsAt
1083
+ ? \`\${current.name} ends on \${day(billing.endsAt)}.\`
1084
+ : billing.endsAt
1085
+ ? \`\${current.name} renews on \${day(billing.endsAt)}.\`
1086
+ : \`You're on \${current.name}.\`
1087
+ : "Choose a plan to get started."}
1088
+ </p>
1089
+ </div>
1090
+ <div className="flex items-center gap-3">
1091
+ <Badge variant={status.tone}>{status.label}</Badge>
1092
+ {billing.live && <BillingPortalButton />}
1093
+ </div>
1094
+ </div>
1095
+
1096
+ {params.checkout === "success" && (
1097
+ <Alert>
1098
+ <AlertDescription>Payment received. Your plan updates here as soon as Stripe confirms it, usually within seconds.</AlertDescription>
1099
+ </Alert>
1100
+ )}
1101
+
1102
+ {subscriptions.length > 0 && (
1103
+ <section className="flex flex-col gap-4">
1104
+ <h2 className="text-sm font-medium">Plans</h2>
1105
+ <div className="grid gap-4 sm:grid-cols-2">
1106
+ {subscriptions.map((plan) => {
1107
+ const isCurrent = plan.id === current?.id;
1108
+ return (
1109
+ <Card key={plan.id} className={isCurrent ? "border-primary" : undefined}>
1110
+ <CardHeader>
1111
+ <CardTitle>{plan.name}</CardTitle>
1112
+ <CardDescription>
1113
+ {money(plan.amount, plan.currency)} per {plan.interval}
1114
+ </CardDescription>
1115
+ </CardHeader>
1116
+ {plan.description && <CardContent className="text-sm text-muted-foreground">{plan.description}</CardContent>}
1117
+ <CardFooter>
1118
+ {isCurrent ? (
1119
+ <span className="text-sm text-muted-foreground">Your current plan</span>
1120
+ ) : billing.live ? (
1121
+ <BillingButton endpoint="/api/billing/portal" planSlug={plan.slug} variant="outline">
1122
+ Switch to {plan.name}
1123
+ </BillingButton>
1124
+ ) : (
1125
+ <BillingButton endpoint="/api/billing/checkout" planSlug={plan.slug}>
1126
+ Subscribe
1127
+ </BillingButton>
1128
+ )}
1129
+ </CardFooter>
1130
+ </Card>
1131
+ );
1132
+ })}
1133
+ </div>
1134
+ </section>
1135
+ )}
1136
+
1137
+ {oneTime.length > 0 && (
1138
+ <section className="flex flex-col gap-4">
1139
+ <h2 className="text-sm font-medium">Buy once</h2>
1140
+ <div className="grid gap-4 sm:grid-cols-2">
1141
+ {oneTime.map((plan) => (
1142
+ <Card key={plan.id}>
1143
+ <CardHeader>
1144
+ <CardTitle>{plan.name}</CardTitle>
1145
+ <CardDescription>{money(plan.amount, plan.currency)}, one-time</CardDescription>
1146
+ </CardHeader>
1147
+ {plan.description && <CardContent className="text-sm text-muted-foreground">{plan.description}</CardContent>}
1148
+ <CardFooter>
1149
+ <BillingButton endpoint="/api/billing/checkout" planSlug={plan.slug} variant="outline">
1150
+ Buy {plan.name}
1151
+ </BillingButton>
1152
+ </CardFooter>
1153
+ </Card>
1154
+ ))}
1155
+ </div>
1156
+ </section>
1157
+ )}
1158
+
1159
+ {subscriptions.length === 0 && oneTime.length === 0 && (
1160
+ <p className="text-sm text-muted-foreground">
1161
+ No plans yet. Create products in Stripe, then run <code>flare billing:sync-plans</code>.
1162
+ </p>
1163
+ )}
1164
+
1165
+ {purchases.length > 0 && (
1166
+ <section className="flex flex-col gap-2">
1167
+ <h2 className="text-sm font-medium">Purchases</h2>
1168
+ <ul className="flex flex-col divide-y rounded-lg border text-sm">
1169
+ {purchases.map((purchase) => (
1170
+ <li key={purchase.id} className="flex items-center justify-between gap-4 px-4 py-3">
1171
+ <span className="truncate">{purchase.plan ?? "One-time purchase"}</span>
1172
+ <span className="flex items-center gap-3 tabular-nums text-muted-foreground">
1173
+ {money(purchase.amount, purchase.currency)}
1174
+ <Badge variant={purchase.status === "paid" ? "success" : purchase.status === "failed" ? "danger" : "warning"}>
1175
+ {purchase.status === "paid" ? "Paid" : purchase.status === "failed" ? "Failed" : "Pending"}
1176
+ </Badge>
1177
+ </span>
1178
+ </li>
1179
+ ))}
1180
+ </ul>
1181
+ </section>
1182
+ )}
1183
+
1184
+ <Link href="/dashboard" className="text-sm text-muted-foreground underline underline-offset-4">
1185
+ Back to the dashboard
1186
+ </Link>
1187
+ </main>
1188
+ );
1189
+ }
1190
+ `;
1191
+ var billingDevVarsEntries = () => "STRIPE_SECRET_KEY=\nSTRIPE_WEBHOOK_SECRET=\n";
1192
+ var billingDevVarsExampleEntries = () => [
1193
+ "",
1194
+ "# Stripe. Use a restricted key (rk_test_ / rk_live_) with write access to",
1195
+ "# Customers, Checkout Sessions and Customer portal, and read access to",
1196
+ "# Subscriptions, Products and Prices. Test in a Stripe sandbox, never with live keys.",
1197
+ "# Production: wrangler secret put STRIPE_SECRET_KEY",
1198
+ "STRIPE_SECRET_KEY=",
1199
+ "# billing:sync-plans imports Products whose metadata has flare_app=<this app's name>.",
1200
+ "# The signing secret of the webhook endpoint pointing at <app URL>/api/webhooks/stripe.",
1201
+ "# Locally: stripe listen --forward-to localhost:8787/api/webhooks/stripe prints one.",
1202
+ "STRIPE_WEBHOOK_SECRET=",
1203
+ ""
1204
+ ].join("\n");
1205
+
1206
+ // src/generator/load.ts
1207
+ import { existsSync as existsSync4, readdirSync as readdirSync3 } from "fs";
1208
+ import { join as join3, resolve as resolve3 } from "path";
1209
+ import { createJiti } from "jiti";
1210
+ async function loadResources(root) {
1211
+ const appRoot = resolve3(root);
1212
+ const dir = join3(appRoot, "resources");
1213
+ if (!existsSync4(dir)) return [];
1214
+ const jiti = createJiti(join3(appRoot, "package.json"), { moduleCache: false, fsCache: false });
1215
+ const loaded = [];
1216
+ for (const file of readdirSync3(dir).filter((name) => name.endsWith(".resource.ts")).sort()) {
1217
+ const path = join3(dir, file);
1218
+ const mod = await jiti.import(path);
1219
+ const resource = mod.default;
1220
+ if (!resource || typeof resource !== "object" || !("fields" in resource) || !("table" in resource)) {
1221
+ throw new Error(`resources/${file} must \`export default defineResource({ ... })\`.`);
1222
+ }
1223
+ loaded.push({ resource, file: `resources/${file}`, stem: file.replace(/\.resource\.ts$/, "") });
1224
+ }
1225
+ const byName = /* @__PURE__ */ new Map();
1226
+ for (const { resource, file } of loaded) {
1227
+ if (byName.has(resource.name)) throw new Error(`Resource "${resource.name}" is defined twice: ${byName.get(resource.name)} and ${file}.`);
1228
+ byName.set(resource.name, file);
1229
+ }
1230
+ return loaded;
1231
+ }
1232
+
1233
+ // src/generator/migrations.ts
1234
+ import { spawn as spawn3 } from "child_process";
1235
+ import { existsSync as existsSync8, readdirSync as readdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1236
+ import { join as join7 } from "path";
1237
+
1238
+ // src/commands/run.ts
1239
+ import { spawn as spawn2 } from "child_process";
1240
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
1241
+ import { dirname as dirname4, join as join6, resolve as resolve4 } from "path";
1242
+
1243
+ // src/commands/deploy.ts
1244
+ import { randomBytes as randomBytes2 } from "crypto";
1245
+ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "fs";
1246
+ import { join as join5 } from "path";
1247
+ import { createZoneClient } from "@flaredev/core/security";
1248
+ import { createJiti as createJiti2 } from "jiti";
1249
+ import pc2 from "picocolors";
1250
+
1251
+ // src/utils/wrangler.ts
1252
+ import { spawn } from "child_process";
1253
+ import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
1254
+ import { join as join4 } from "path";
1255
+ import { parse as parseJsonc } from "jsonc-parser";
1256
+ function runWrangler(wranglerBin, args, cwd, options2 = {}) {
1257
+ return new Promise((resolve6, reject) => {
1258
+ const child = spawn(process.execPath, [wranglerBin, ...args], {
1259
+ cwd,
1260
+ stdio: [options2.stdin !== void 0 ? "pipe" : "ignore", options2.capture ? "pipe" : "inherit", options2.capture ? "pipe" : "inherit"]
1261
+ });
1262
+ let output = "";
1263
+ child.stdout?.on("data", (chunk) => output += chunk);
1264
+ child.stderr?.on("data", (chunk) => output += chunk);
1265
+ if (options2.stdin !== void 0) child.stdin.end(options2.stdin);
1266
+ child.on("error", reject);
1267
+ child.on("exit", (code) => resolve6({ code: code ?? 1, output }));
1268
+ });
1269
+ }
1270
+ function extractJson(output, open = "[") {
1271
+ const close = open === "[" ? "]" : "}";
1272
+ const start = output.indexOf(open);
1273
+ const end = output.lastIndexOf(close);
1274
+ if (start === -1 || end < start) throw new Error(`Expected JSON from wrangler, got:
1275
+ ${output.trim()}`);
1276
+ return JSON.parse(output.slice(start, end + 1));
1277
+ }
1278
+ function readD1Databases(appRoot) {
1279
+ const path = join4(appRoot, "wrangler.jsonc");
1280
+ if (!existsSync5(path)) return [];
1281
+ const config = parseJsonc(readFileSync3(path, "utf8"));
1282
+ return (config?.d1_databases ?? []).filter((db) => db.binding && db.database_name);
1283
+ }
1284
+
1285
+ // src/commands/deploy.ts
1286
+ var REQUIRED_SECRET = "BETTER_AUTH_SECRET";
1287
+ var MISSING_DATABASE = /Couldn't find a D1 DB/i;
1288
+ function parseDeployArgs(args) {
1289
+ const forwarded = [];
1290
+ let skipMigrations = false;
1291
+ let skipSecrets = false;
1292
+ let skipSecurity = false;
1293
+ let env;
1294
+ for (let i = 0; i < args.length; i++) {
1295
+ const arg = args[i];
1296
+ if (arg === "--skip-migrations") skipMigrations = true;
1297
+ else if (arg === "--skip-secrets") skipSecrets = true;
1298
+ else if (arg === "--skip-security") skipSecurity = true;
1299
+ else {
1300
+ forwarded.push(arg);
1301
+ if (arg === "--env" && args[i + 1]) env = args[i + 1];
1302
+ else if (arg.startsWith("--env=")) env = arg.slice("--env=".length);
1303
+ else if (arg === "--preview") env = "preview";
1304
+ }
1305
+ }
1306
+ const passthroughOnly = forwarded.some((arg) => ["--dry-run", "--help", "-h"].includes(arg));
1307
+ return { forwarded, skipMigrations, skipSecrets, skipSecurity, env, passthroughOnly };
1308
+ }
1309
+ function parseSecretNames(output) {
1310
+ const start = output.indexOf("[");
1311
+ const end = output.lastIndexOf("]");
1312
+ if (start === -1 || end < start) throw new Error(`Unexpected output from wrangler secret list:
1313
+ ${output}`);
1314
+ return JSON.parse(output.slice(start, end + 1)).map((secret) => secret.name);
1315
+ }
1316
+ function declaredSecretNames(devVarsExample) {
1317
+ return devVarsExample.split(/\r?\n/).map((line) => /^([A-Z][A-Z0-9_]*)=/.exec(line.trim())?.[1]).filter((name) => Boolean(name));
1318
+ }
1319
+ async function loadSecurityConfig(appRoot) {
1320
+ const path = join5(appRoot, "security.config.ts");
1321
+ if (!existsSync6(path)) return null;
1322
+ const jiti = createJiti2(join5(appRoot, "package.json"), { moduleCache: false, fsCache: false });
1323
+ const mod = await jiti.import(path);
1324
+ if (!mod.default) throw new Error("security.config.ts must `export default defineSecurity({ ... })`.");
1325
+ return mod.default;
1326
+ }
1327
+ async function provisionZoneSecurity(appRoot, config, credentials, options2) {
1328
+ const app = JSON.parse(readFileSync4(join5(appRoot, "package.json"), "utf8")).name ?? "app";
1329
+ const zone = createZoneClient({ ...credentials, app, fetch: options2.fetch });
1330
+ if (config.zone?.rateLimits) {
1331
+ const result = await zone.syncRateLimits(config.zone.rateLimits);
1332
+ options2.log(` rate limiting: ${result.written} Flare rule(s), ${result.kept} other rule(s) kept`);
1333
+ }
1334
+ if (config.zone?.customRules) {
1335
+ const result = await zone.syncCustomRules(config.zone.customRules);
1336
+ options2.log(` custom rules: ${result.written} Flare rule(s), ${result.kept} other rule(s) kept`);
1337
+ }
1338
+ }
1339
+ async function runDeploy(args, context3) {
1340
+ const plan = parseDeployArgs(args);
1341
+ if (plan.passthroughOnly) return context3.deploy(plan.forwarded);
1342
+ const { appRoot, wranglerBin } = context3;
1343
+ const shellEnv = context3.env ?? process.env;
1344
+ const log = context3.log ?? ((message) => console.log(message));
1345
+ const envArgs = plan.env ? ["--env", plan.env] : [];
1346
+ const step = (message) => log(`
1347
+ ${pc2.bold(pc2.cyan("flare"))} ${message}`);
1348
+ const databases = plan.skipMigrations ? [] : readD1Databases(appRoot).filter((db) => existsSync6(join5(appRoot, db.migrations_dir ?? "migrations")));
1349
+ const migrateAfterDeploy = [];
1350
+ const migrate2 = async (db) => {
1351
+ step(`Applying migrations to ${db.database_name} (remote)`);
1352
+ const result = await runWrangler(wranglerBin, ["d1", "migrations", "apply", db.binding, "--remote", ...envArgs], appRoot);
1353
+ if (result.code !== 0) throw new Error(`Migrations failed for ${db.database_name}.`);
1354
+ };
1355
+ for (const db of databases) {
1356
+ const info = await runWrangler(wranglerBin, ["d1", "info", db.database_name, "--json", ...envArgs], appRoot, {
1357
+ capture: true
1358
+ });
1359
+ if (info.code === 0) {
1360
+ await migrate2(db);
1361
+ } else if (MISSING_DATABASE.test(info.output)) {
1362
+ step(`${db.database_name} doesn't exist yet: wrangler will create it during deploy, then migrations run.`);
1363
+ migrateAfterDeploy.push(db);
1364
+ } else {
1365
+ throw new Error(`Could not look up D1 database ${db.database_name}:
1366
+ ${info.output.trim()}`);
1367
+ }
1368
+ }
1369
+ step("Deploying");
1370
+ const code = await context3.deploy(plan.forwarded);
1371
+ if (code !== 0) return code;
1372
+ for (const db of migrateAfterDeploy) await migrate2(db);
1373
+ const security = plan.skipSecurity ? null : await loadSecurityConfig(appRoot);
1374
+ const zoneId = shellEnv.FLARE_SECURITY_ZONE_ID;
1375
+ const apiToken = shellEnv.FLARE_SECURITY_API_TOKEN;
1376
+ if (security && zoneId && apiToken) {
1377
+ step("Provisioning zone security rules");
1378
+ await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log, fetch: context3.fetch });
1379
+ } else if (security) {
1380
+ 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.");
1381
+ }
1382
+ if (!plan.skipSecrets) {
1383
+ const listed = await runWrangler(wranglerBin, ["secret", "list", ...envArgs], appRoot, { capture: true });
1384
+ if (listed.code !== 0) throw new Error(`wrangler secret list failed:
1385
+ ${listed.output.trim()}`);
1386
+ const existing = new Set(parseSecretNames(listed.output));
1387
+ if (!existing.has(REQUIRED_SECRET)) {
1388
+ step(`Generating ${REQUIRED_SECRET} for production`);
1389
+ const put = await runWrangler(wranglerBin, ["secret", "put", REQUIRED_SECRET, ...envArgs], appRoot, {
1390
+ capture: true,
1391
+ stdin: randomBytes2(32).toString("base64")
1392
+ });
1393
+ if (put.code !== 0) throw new Error(`Uploading ${REQUIRED_SECRET} failed:
1394
+ ${put.output.trim()}`);
1395
+ existing.add(REQUIRED_SECRET);
1396
+ }
1397
+ if (security && zoneId && apiToken) {
1398
+ for (const [name, value] of [["FLARE_SECURITY_ZONE_ID", zoneId], ["FLARE_SECURITY_API_TOKEN", apiToken]]) {
1399
+ if (existing.has(name)) continue;
1400
+ step(`Uploading ${name}`);
1401
+ const put = await runWrangler(wranglerBin, ["secret", "put", name, ...envArgs], appRoot, { capture: true, stdin: value });
1402
+ if (put.code !== 0) throw new Error(`Uploading ${name} failed:
1403
+ ${put.output.trim()}`);
1404
+ existing.add(name);
1405
+ }
1406
+ }
1407
+ const examplePath = join5(appRoot, ".dev.vars.example");
1408
+ const optional = existsSync6(examplePath) ? declaredSecretNames(readFileSync4(examplePath, "utf8")).filter((name) => !existing.has(name)) : [];
1409
+ if (optional.length > 0) {
1410
+ step("Optional secrets not set in production (features using them stay off):");
1411
+ for (const name of optional) log(` wrangler secret put ${name}${plan.env ? ` --env ${plan.env}` : ""}`);
1412
+ }
1413
+ }
1414
+ return 0;
1415
+ }
1416
+
1417
+ // src/commands/run.ts
1418
+ var DELEGATED_COMMANDS = {
1419
+ dev: { description: "Start the vinext dev server", pkg: "vinext", bin: "vinext", args: ["dev"] },
1420
+ build: { description: "Build for production (vinext build)", pkg: "vinext", bin: "vinext", args: ["build"] },
1421
+ start: {
1422
+ description: "Serve the production build locally in workerd (builds first if needed)",
1423
+ pkg: "wrangler",
1424
+ bin: "wrangler",
1425
+ // --persist-to shares local D1/R2 state with `flare dev` and local migrations.
1426
+ args: ["dev", "--config", "dist/server/wrangler.json", "--persist-to", ".wrangler/state"]
1427
+ },
1428
+ deploy: {
1429
+ description: "Migrate D1, build and deploy to Cloudflare Workers (vinext-cloudflare deploy), then ensure secrets and push security.config.ts zone rules. Flags: --skip-migrations, --skip-secrets, --skip-security",
1430
+ pkg: "@vinext/cloudflare",
1431
+ bin: "vinext-cloudflare",
1432
+ args: ["deploy", "--config", "dist/server/wrangler.json"]
1433
+ }
1434
+ };
1435
+ function isDelegatedCommand(value) {
1436
+ return value !== void 0 && Object.hasOwn(DELEGATED_COMMANDS, value);
1437
+ }
1438
+ function findAppRoot(cwd) {
1439
+ let dir = resolve4(cwd);
1440
+ while (dir) {
1441
+ const root = findUp("package.json", dir);
1442
+ if (!root) break;
1443
+ const pkg = JSON.parse(readFileSync5(join6(root, "package.json"), "utf8"));
1444
+ if (pkg.dependencies?.vinext || pkg.devDependencies?.vinext) return root;
1445
+ const parent = dirname4(root);
1446
+ dir = parent === root ? void 0 : parent;
1447
+ }
1448
+ throw new Error("Not inside a Flare app (no package.json depending on vinext found). Run this from your app directory.");
1449
+ }
1450
+ function resolveBin(appRoot, pkgName, binName) {
1451
+ const pkgDir = findUp(join6("node_modules", pkgName, "package.json"), appRoot);
1452
+ if (!pkgDir) {
1453
+ throw new Error(`Cannot find ${pkgName} in ${appRoot}. Install dependencies first (e.g. pnpm install).`);
1454
+ }
1455
+ const manifestPath = join6(pkgDir, "node_modules", pkgName, "package.json");
1456
+ const { bin } = JSON.parse(readFileSync5(manifestPath, "utf8"));
1457
+ const relativeBin = typeof bin === "string" ? bin : bin?.[binName];
1458
+ if (!relativeBin) throw new Error(`${pkgName} does not provide a "${binName}" executable.`);
1459
+ return join6(dirname4(manifestPath), relativeBin);
1460
+ }
1461
+ function delegatedArgv(appRoot, command, forwarded) {
1462
+ const spec = DELEGATED_COMMANDS[command];
1463
+ return [resolveBin(appRoot, spec.pkg, spec.bin), ...spec.args, ...forwarded];
1464
+ }
1465
+ function runNode(argv, cwd) {
1466
+ return new Promise((resolvePromise, reject) => {
1467
+ const child = spawn2(process.execPath, argv, { cwd, stdio: "inherit" });
1468
+ child.on("error", reject);
1469
+ child.on("exit", (code, signal) => resolvePromise(code ?? (signal ? 1 : 0)));
1470
+ });
1471
+ }
1472
+ async function runDelegated(command, forwarded, cwd = process.cwd()) {
1473
+ const appRoot = findAppRoot(cwd);
1474
+ const wantsHelp = forwarded.includes("--help") || forwarded.includes("-h");
1475
+ if (command === "start" && !wantsHelp && !existsSync7(join6(appRoot, "dist/server/wrangler.json"))) {
1476
+ console.log("No production build found; running `flare build` first.\n");
1477
+ const code = await runNode(delegatedArgv(appRoot, "build", []), appRoot);
1478
+ if (code !== 0) return code;
1479
+ }
1480
+ if (command === "deploy") {
1481
+ return runDeploy(forwarded, {
1482
+ appRoot,
1483
+ wranglerBin: resolveBin(appRoot, "wrangler", "wrangler"),
1484
+ deploy: (args) => runNode(delegatedArgv(appRoot, "deploy", args), appRoot)
1485
+ });
1486
+ }
1487
+ return runNode(delegatedArgv(appRoot, command, forwarded), appRoot);
1488
+ }
1489
+
1490
+ // src/generator/repair.ts
1491
+ var COPY = /INSERT INTO `__new_(\w+)`\(([^)]*)\) SELECT ([^;]*?) FROM `\1`;/g;
1492
+ var columnsOf = (list2) => list2.split(",").map((column) => column.trim().replace(/^"|"$/g, ""));
1493
+ function repairRebuildMigration(sql2, previousColumns) {
1494
+ const changes = [];
1495
+ let repaired = sql2.replace(COPY, (statement, table, insertList, selectList) => {
1496
+ const existing = previousColumns[table];
1497
+ if (!existing) return statement;
1498
+ const insertColumns = columnsOf(insertList);
1499
+ const selectColumns = columnsOf(selectList);
1500
+ if (insertColumns.join() !== selectColumns.join()) return statement;
1501
+ const kept = insertColumns.filter((column) => existing.includes(column));
1502
+ if (kept.length === insertColumns.length) return statement;
1503
+ const dropped = insertColumns.filter((column) => !kept.includes(column));
1504
+ changes.push(`${table}: don't copy new column(s) ${dropped.join(", ")} from the old table`);
1505
+ const list2 = kept.map((column) => `"${column}"`).join(", ");
1506
+ return `INSERT INTO \`__new_${table}\`(${list2}) SELECT ${list2} FROM \`${table}\`;`;
1507
+ });
1508
+ if (/PRAGMA foreign_keys=OFF;/i.test(repaired)) {
1509
+ repaired = repaired.replace(/PRAGMA foreign_keys=OFF;/gi, "PRAGMA defer_foreign_keys = on;").replace(/PRAGMA foreign_keys=ON;/gi, "PRAGMA defer_foreign_keys = off;");
1510
+ changes.push("use PRAGMA defer_foreign_keys (D1) instead of PRAGMA foreign_keys");
1511
+ }
1512
+ return { sql: repaired, changes };
1513
+ }
1514
+ function snapshotColumns(snapshot) {
1515
+ return Object.fromEntries(Object.entries(snapshot.tables ?? {}).map(([name, table]) => [name, Object.keys(table.columns ?? {})]));
1516
+ }
1517
+
1518
+ // src/generator/migrations.ts
1519
+ var listSql = (dir) => existsSync8(dir) ? readdirSync4(dir).filter((file) => file.endsWith(".sql")) : [];
1520
+ async function generateSchemaMigration(appRoot, name, options2 = {}) {
1521
+ const dir = join7(appRoot, "migrations");
1522
+ const before = new Set(listSql(dir));
1523
+ const bin = resolveBin(appRoot, "drizzle-kit", "drizzle-kit");
1524
+ const { code, output } = await new Promise((resolve6, reject) => {
1525
+ const args = ["generate", "--name", name, ...options2.custom ? ["--custom"] : []];
1526
+ const child = spawn3(process.execPath, [bin, ...args], {
1527
+ cwd: appRoot,
1528
+ stdio: ["ignore", "pipe", "pipe"]
1529
+ });
1530
+ let text = "";
1531
+ child.stdout.on("data", (chunk) => text += chunk);
1532
+ child.stderr.on("data", (chunk) => text += chunk);
1533
+ child.on("error", reject);
1534
+ child.on("exit", (exitCode) => resolve6({ code: exitCode ?? 1, output: text }));
1535
+ });
1536
+ if (code !== 0) {
1537
+ throw new Error(
1538
+ `drizzle-kit generate failed:
1539
+ ${output.trim()}
1540
+
1541
+ If it asked whether a column was renamed, run \`pnpm exec drizzle-kit generate\` in a terminal to answer interactively.`
1542
+ );
1543
+ }
1544
+ const created = listSql(dir).filter((file) => !before.has(file)).sort();
1545
+ const repairs = [];
1546
+ for (const file of created) {
1547
+ const previous = previousSnapshot(dir, file);
1548
+ if (!previous) continue;
1549
+ const path = join7(dir, file);
1550
+ const { sql: sql2, changes } = repairRebuildMigration(readFileSync6(path, "utf8"), snapshotColumns(previous));
1551
+ if (changes.length) {
1552
+ writeFileSync4(path, sql2);
1553
+ repairs.push(...changes.map((change) => `${file}: ${change}`));
1554
+ }
1555
+ }
1556
+ return { files: created.map((file) => `migrations/${file}`), output, repairs };
1557
+ }
1558
+ function previousSnapshot(dir, migrationFile) {
1559
+ const prefix = /^(\d+)_/.exec(migrationFile)?.[1];
1560
+ const metaDir = join7(dir, "meta");
1561
+ if (!prefix || !existsSync8(metaDir)) return void 0;
1562
+ const earlier = readdirSync4(metaDir).filter((file) => /^\d+_snapshot\.json$/.test(file) && file < `${prefix}_snapshot.json`).sort().pop();
1563
+ return earlier ? JSON.parse(readFileSync6(join7(metaDir, earlier), "utf8")) : void 0;
1564
+ }
1565
+
1566
+ // src/generator/plan.ts
1567
+ import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync3, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
1568
+ import { dirname as dirname5, join as join9 } from "path";
1569
+ import pc3 from "picocolors";
1570
+
1571
+ // src/generator/policy.ts
1572
+ import { existsSync as existsSync9, readdirSync as readdirSync5 } from "fs";
1573
+ import { join as join8, resolve as resolve5 } from "path";
1574
+ import { camelCase as camelCase2, kebabCase as kebabCase2 } from "@flaredev/core";
1575
+ import { createJiti as createJiti3 } from "jiti";
1576
+ var policyPath = (name) => `policies/${kebabCase2(name)}.policy.ts`;
1577
+ var policyLocal = (stem) => `${camelCase2(stem)}Policy`;
1578
+ async function loadPolicies(root) {
1579
+ const appRoot = resolve5(root);
1580
+ const dir = join8(appRoot, "policies");
1581
+ if (!existsSync9(dir)) return [];
1582
+ const jiti = createJiti3(join8(appRoot, "package.json"), { moduleCache: false, fsCache: false });
1583
+ const loaded = [];
1584
+ for (const file of readdirSync5(dir).filter((name) => name.endsWith(".policy.ts")).sort()) {
1585
+ const mod = await jiti.import(join8(dir, file));
1586
+ const policy = mod.default;
1587
+ if (!policy || typeof policy !== "object" || !("resource" in policy) || !Array.isArray(policy.read)) {
1588
+ throw new Error(`policies/${file} must \`export default definePolicy({ ... })\`.`);
1589
+ }
1590
+ loaded.push({ policy, file: `policies/${file}`, stem: file.replace(/\.policy\.ts$/, "") });
1591
+ }
1592
+ return loaded;
1593
+ }
1594
+ function renderPolicyBlock(roles) {
1595
+ const list2 = (values) => `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
1596
+ return ["read", "create", "update", "delete"].map((action) => `${action}: ${list2(roles[action])},`).join("\n") + "\n";
1597
+ }
1598
+ function renderPolicy(resource, roles) {
1599
+ const block = renderPolicyBlock(roles);
1600
+ return joinMarkers({
1601
+ before: `import { definePolicy } from "@flaredev/core";
1602
+
1603
+ /**
1604
+ * Who may do what with ${resource} records. Roles come from \`flare role:add\`;
1605
+ * "*" means any signed-in user. The API layer and the admin UI both read this.
1606
+ */
1607
+ export default definePolicy({
1608
+ resource: ${JSON.stringify(resource)},
1609
+ `,
1610
+ block,
1611
+ after: `});
1612
+ `,
1613
+ indent: " ",
1614
+ hash: hashBlock(block)
1615
+ });
1616
+ }
1617
+ function renderPolicyRegistry(all) {
1618
+ if (all.length === 0) return "export const policies = {} as const;\n";
1619
+ const sorted = [...all].sort((a, b) => a.policy.resource.localeCompare(b.policy.resource));
1620
+ return [
1621
+ ...sorted.map(({ stem }) => `import ${policyLocal(stem)} from "./${stem}.policy";`),
1622
+ "",
1623
+ "export const policies = {",
1624
+ ...sorted.map(({ policy, stem }) => ` ${policy.resource}: ${policyLocal(stem)},`),
1625
+ "} as const;",
1626
+ ""
1627
+ ].join("\n");
1628
+ }
1629
+
1630
+ // src/generator/render.ts
1631
+ import { camelCase as camelCase3, columnName, relationGraph, storedFields } from "@flaredev/core";
1632
+ var resourceLocal = (stem) => `${camelCase3(stem)}Resource`;
1633
+ var tableExport = (resource) => camelCase3(resource.table);
1634
+ var q = (value) => JSON.stringify(value);
1635
+ var sqlString = (value) => `'${value.replace(/'/g, "''")}'`;
1636
+ var TIMESTAMP_DEFAULT = "sql`(cast(unixepoch('subsecond') * 1000 as integer))`";
1637
+ function columnExpression(key, def, resources, selfName) {
1638
+ const col = q(columnName(key));
1639
+ let expr;
1640
+ switch (def.kind) {
1641
+ case "int":
1642
+ expr = `integer(${col})`;
1643
+ break;
1644
+ case "float":
1645
+ expr = `real(${col})`;
1646
+ break;
1647
+ case "boolean":
1648
+ expr = `integer(${col}, { mode: "boolean" })`;
1649
+ break;
1650
+ case "enum":
1651
+ expr = `text(${col}, { enum: ${q(def.options)} })`;
1652
+ break;
1653
+ case "belongsTo": {
1654
+ const target = resources.get(def.target);
1655
+ if (!target) {
1656
+ throw new Error(`${selfName}.${key} belongs to "${def.target}", but there is no ${def.target} resource. Generate it first.`);
1657
+ }
1658
+ const onDelete = def.onDelete ?? "restrict";
1659
+ expr = `text(${col}).references(() => ${tableExport(target)}.id, { onDelete: ${q(onDelete)} })`;
1660
+ break;
1661
+ }
1662
+ default:
1663
+ expr = `text(${col})`;
1664
+ }
1665
+ if (def.required) expr += ".notNull()";
1666
+ if (def.unique) expr += ".unique()";
1667
+ if ("default" in def && def.default !== void 0) expr += `.default(${q(def.default)})`;
1668
+ return expr;
1669
+ }
1670
+ function renderTableModule(entry, all) {
1671
+ const { resource } = entry;
1672
+ const resources = new Map(all.map((item) => [item.resource.name, item.resource]));
1673
+ const fields = storedFields(resource);
1674
+ const name = tableExport(resource);
1675
+ const columns = fields.map(([key, def]) => ` ${key}: ${columnExpression(key, def, resources, resource.name)},`);
1676
+ const extras = [];
1677
+ for (const [key, def] of fields) {
1678
+ if (def.kind === "enum") {
1679
+ const options2 = def.options.map(sqlString).join(", ");
1680
+ extras.push(` check(${q(`${resource.table}_${columnName(key)}_check`)}, sql\`\${table.${key}} in (${options2})\`),`);
1681
+ }
1682
+ if (def.kind === "belongsTo") extras.push(` index(${q(`${resource.table}_${columnName(key)}_idx`)}).on(table.${key}),`);
1683
+ }
1684
+ const targets = [...new Set(fields.flatMap(([, def]) => def.kind === "belongsTo" ? [resources.get(def.target)] : []))].filter((target) => target.name !== resource.name).sort((a, b) => a.table.localeCompare(b.table));
1685
+ const coreImports = ["integer", "sqliteTable", "text"];
1686
+ if (fields.some(([, def]) => def.kind === "float")) coreImports.push("real");
1687
+ if (extras.some((line) => line.includes("check("))) coreImports.push("check");
1688
+ if (extras.some((line) => line.includes("index("))) coreImports.push("index");
1689
+ const selfReference = fields.some(([, def]) => def.kind === "belongsTo" && def.target === resource.name);
1690
+ return [
1691
+ `import { sql } from "drizzle-orm";`,
1692
+ `import { ${coreImports.sort().join(", ")} } from "drizzle-orm/sqlite-core";`,
1693
+ ...selfReference ? [`import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";`] : [],
1694
+ ...targets.map((target) => `import { ${tableExport(target)} } from "./${target.table}";`),
1695
+ "",
1696
+ `export const ${name} = sqliteTable(`,
1697
+ ` ${q(resource.table)},`,
1698
+ " {",
1699
+ ` id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),`,
1700
+ ...columns.map(
1701
+ (line) => selfReference ? line.replace(`() => ${name}.id`, `(): AnySQLiteColumn => ${name}.id`) : line
1702
+ ),
1703
+ ` createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().default(${TIMESTAMP_DEFAULT}),`,
1704
+ ` updatedAt: integer("updated_at", { mode: "timestamp_ms" })`,
1705
+ ` .notNull()`,
1706
+ ` .default(${TIMESTAMP_DEFAULT})`,
1707
+ ` .$onUpdate(() => new Date()),`,
1708
+ " },",
1709
+ ...extras.length ? [" (table) => [", ...extras, " ],"] : [],
1710
+ ");",
1711
+ ""
1712
+ ].join("\n");
1713
+ }
1714
+ function renderSchemaIndex(all) {
1715
+ if (all.length === 0) return "";
1716
+ const tables = all.map(({ resource }) => resource.table).sort().map((table) => `export * from "./schema/${table}";
1717
+ `).join("");
1718
+ return `${tables}export * from "./relations";
1719
+ `;
1720
+ }
1721
+ function renderRelations(all) {
1722
+ const resources = all.map(({ resource }) => resource);
1723
+ const byName = new Map(resources.map((resource) => [resource.name, resource]));
1724
+ const graph = relationGraph(resources);
1725
+ const blocks = [];
1726
+ const used = /* @__PURE__ */ new Set();
1727
+ for (const resource of [...resources].sort((a, b) => a.table.localeCompare(b.table))) {
1728
+ const { belongsTo, hasMany } = graph.byResource[resource.name];
1729
+ if (belongsTo.length === 0 && hasMany.length === 0) continue;
1730
+ const self = tableExport(resource);
1731
+ used.add(resource);
1732
+ const lines = [
1733
+ ...belongsTo.map((relation) => {
1734
+ const target = byName.get(relation.target);
1735
+ used.add(target);
1736
+ const other = tableExport(target);
1737
+ return ` ${relation.name}: one(${other}, { fields: [${self}.${relation.key}], references: [${other}.id], relationName: ${q(relation.relationName)} }),`;
1738
+ }),
1739
+ ...hasMany.map((relation) => {
1740
+ const target = byName.get(relation.target);
1741
+ used.add(target);
1742
+ return ` ${relation.key}: many(${tableExport(target)}, { relationName: ${q(relation.relationName)} }),`;
1743
+ })
1744
+ ];
1745
+ const helpers = [belongsTo.length ? "one" : "", hasMany.length ? "many" : ""].filter(Boolean).join(", ");
1746
+ blocks.push(`export const ${self}Relations = relations(${self}, ({ ${helpers} }) => ({
1747
+ ${lines.join("\n")}
1748
+ }));
1749
+ `);
1750
+ }
1751
+ if (blocks.length === 0) return "export {};\n";
1752
+ const imports = [...used].sort((a, b) => a.table.localeCompare(b.table)).map((resource) => `import { ${tableExport(resource)} } from "./schema/${resource.table}";`);
1753
+ return [`import { relations } from "drizzle-orm";`, ...imports, "", blocks.join("\n")].join("\n");
1754
+ }
1755
+ var handlerImports = (entry) => [
1756
+ `import { createResourceHandlers } from "@flaredev/core/server";`,
1757
+ `import { getDb } from "@/db";`,
1758
+ `import { ${tableExport(entry.resource)} } from "@/db/schema";`,
1759
+ `import { authorize } from "@/lib/api";`,
1760
+ `import { revalidateResource } from "@/lib/cache";`,
1761
+ `import ${resourceLocal(entry.stem)} from "@/resources/${entry.stem}.resource";`,
1762
+ "",
1763
+ `const handlers = createResourceHandlers({`,
1764
+ ` resource: ${resourceLocal(entry.stem)},`,
1765
+ ` table: ${tableExport(entry.resource)},`,
1766
+ ` getDb,`,
1767
+ ` authorize,`,
1768
+ ` onChange: revalidateResource,`,
1769
+ `});`,
1770
+ ""
1771
+ ];
1772
+ function renderCollectionRoute(entry) {
1773
+ return [...handlerImports(entry), "export const GET = handlers.collection.GET;", "export const POST = handlers.collection.POST;", ""].join(
1774
+ "\n"
1775
+ );
1776
+ }
1777
+ function renderItemRoute(entry) {
1778
+ return [
1779
+ ...handlerImports(entry),
1780
+ "export const GET = handlers.item.GET;",
1781
+ "export const PATCH = handlers.item.PATCH;",
1782
+ "export const PUT = handlers.item.PUT;",
1783
+ "export const DELETE = handlers.item.DELETE;",
1784
+ ""
1785
+ ].join("\n");
1786
+ }
1787
+ var adminImports = (entry) => [
1788
+ `import ${resourceLocal(entry.stem)} from "@/resources/${entry.stem}.resource";`
1789
+ ];
1790
+ function renderAdminListPage(entry) {
1791
+ const local = resourceLocal(entry.stem);
1792
+ return [
1793
+ `import { ResourceTable } from "@/components/admin/resource-table";`,
1794
+ `import type { SearchParams } from "@/components/admin/query";`,
1795
+ ...adminImports(entry),
1796
+ "",
1797
+ `export default async function ${entry.resource.name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {`,
1798
+ " return (",
1799
+ ` <div className="flex flex-col gap-6">`,
1800
+ ` <h1 className="text-2xl font-semibold">{${local}.pluralLabel}</h1>`,
1801
+ ` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
1802
+ " </div>",
1803
+ " );",
1804
+ "}",
1805
+ ""
1806
+ ].join("\n");
1807
+ }
1808
+ function renderAdminNewPage(entry) {
1809
+ return [
1810
+ `import { ResourceFormPage } from "@/components/admin/resource-form-page";`,
1811
+ ...adminImports(entry),
1812
+ "",
1813
+ `export default function New${entry.resource.name}Page() {`,
1814
+ ` return <ResourceFormPage resource={${resourceLocal(entry.stem)}} />;`,
1815
+ "}",
1816
+ ""
1817
+ ].join("\n");
1818
+ }
1819
+ function renderAdminEditPage(entry) {
1820
+ return [
1821
+ `import { ResourceFormPage } from "@/components/admin/resource-form-page";`,
1822
+ ...adminImports(entry),
1823
+ "",
1824
+ `export default async function Edit${entry.resource.name}Page({ params }: { params: Promise<{ id: string }> }) {`,
1825
+ ` return <ResourceFormPage resource={${resourceLocal(entry.stem)}} id={(await params).id} />;`,
1826
+ "}",
1827
+ ""
1828
+ ].join("\n");
1829
+ }
1830
+ function renderClient(entry) {
1831
+ const { resource, stem } = entry;
1832
+ const local = resourceLocal(stem);
1833
+ return [
1834
+ `import { createResourceClient } from "@flaredev/core/client";`,
1835
+ `import type ${local} from "./${stem}.resource";`,
1836
+ "",
1837
+ `export const ${camelCase3(stem)}Client = createResourceClient<typeof ${local}>(${q(`/api/${resource.slug}`)});`,
1838
+ "",
1839
+ `export type ${resource.name} = typeof ${local}.$types.record;`,
1840
+ `export type ${resource.name}Create = typeof ${local}.$types.create;`,
1841
+ `export type ${resource.name}Update = typeof ${local}.$types.update;`,
1842
+ ""
1843
+ ].join("\n");
1844
+ }
1845
+ function renderValidators(entry) {
1846
+ const local = resourceLocal(entry.stem);
1847
+ return [
1848
+ `import { createValidators } from "@flaredev/core";`,
1849
+ `import ${local} from "./${entry.stem}.resource";`,
1850
+ "",
1851
+ `export const ${camelCase3(entry.stem)}Validators = createValidators(${local});`,
1852
+ ""
1853
+ ].join("\n");
1854
+ }
1855
+ function renderRegistry(all) {
1856
+ if (all.length === 0) return "export const resources = [] as const;\n";
1857
+ const locals = all.map(({ stem }) => ({ stem, local: resourceLocal(stem) }));
1858
+ return [
1859
+ ...locals.map(({ stem, local }) => `import ${local} from "./${stem}.resource";`),
1860
+ "",
1861
+ `export { ${locals.map(({ local }) => local).join(", ")} };`,
1862
+ `export const resources = [${locals.map(({ local }) => local).join(", ")}] as const;`,
1863
+ ""
1864
+ ].join("\n");
1865
+ }
1866
+ function renderServerRegistry(all) {
1867
+ if (all.length === 0) return "export const resourceTables = {} as const;\n";
1868
+ const sorted = [...all].sort((a, b) => a.resource.name.localeCompare(b.resource.name));
1869
+ return [
1870
+ `import { ${sorted.map(({ resource }) => tableExport(resource)).join(", ")} } from "@/db/schema";`,
1871
+ `import { ${sorted.map(({ stem }) => resourceLocal(stem)).join(", ")} } from "./index";`,
1872
+ "",
1873
+ "export const resourceTables = {",
1874
+ ...sorted.map(({ resource, stem }) => ` ${resource.name}: { resource: ${resourceLocal(stem)}, table: ${tableExport(resource)} },`),
1875
+ "} as const;",
1876
+ "",
1877
+ "export type ResourceName = keyof typeof resourceTables;",
1878
+ ""
1879
+ ].join("\n");
1880
+ }
1881
+ function resourceFiles(entry, all) {
1882
+ const { resource, stem } = entry;
1883
+ return [
1884
+ { path: `db/schema/${resource.table}.ts`, content: renderTableModule(entry, all) },
1885
+ { path: `app/api/${resource.slug}/route.ts`, content: renderCollectionRoute(entry) },
1886
+ { path: `app/api/${resource.slug}/[id]/route.ts`, content: renderItemRoute(entry) },
1887
+ { path: `resources/${stem}.client.ts`, content: renderClient(entry) },
1888
+ { path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
1889
+ { path: `app/admin/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
1890
+ { path: `app/admin/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
1891
+ { path: `app/admin/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
1892
+ ];
1893
+ }
1894
+
1895
+ // src/generator/plan.ts
1896
+ var resourceHeader = (name) => `// Generated by \`flare gen resource ${name}\`. Code inside the generated block is
1897
+ // rewritten on regeneration; add your own code outside it.
1898
+ `;
1899
+ var HEADER_PATTERN = /^\/\/ Generated by `flare gen resource (\w+)`/;
1900
+ function planFiles(all, policies) {
1901
+ const files = [];
1902
+ for (const entry of all) {
1903
+ for (const file of resourceFiles(entry, all)) files.push({ ...file, header: resourceHeader(entry.resource.name) });
1904
+ }
1905
+ files.push(
1906
+ { path: "db/relations.ts", content: renderRelations(all), header: "// Drizzle relations for every resource (maintained by flare gen).\n" },
1907
+ { path: "resources/index.ts", content: renderRegistry(all), header: "// Registry of every resource descriptor (maintained by flare gen).\n" },
1908
+ {
1909
+ path: "resources/server.ts",
1910
+ content: renderServerRegistry(all),
1911
+ header: "// Server-only: resource name \u2192 descriptor and table (maintained by flare gen).\n"
1912
+ },
1913
+ { path: "db/schema.ts", content: renderSchemaIndex(all) }
1914
+ );
1915
+ if (policies) {
1916
+ files.push({
1917
+ path: "policies/index.ts",
1918
+ content: renderPolicyRegistry(policies),
1919
+ header: "// Registry of every resource policy (maintained by flare gen policy).\n"
1920
+ });
1921
+ }
1922
+ return files;
1923
+ }
1924
+ function applyPlan(appRoot, plan, options2 = {}) {
1925
+ return plan.map(({ path, content, header }) => {
1926
+ const absolute = join9(appRoot, path);
1927
+ const status = inspectGenerated(absolute, content);
1928
+ if (status === "unmarked") return { path, status, outcome: "skipped-unmarked" };
1929
+ if (status === "drift" && !options2.force) return { path, status, outcome: "skipped-drift" };
1930
+ if (options2.dryRun) {
1931
+ const outcome = status === "missing" ? "create" : status === "identical" ? "identical" : "update";
1932
+ return { path, status, outcome };
1933
+ }
1934
+ try {
1935
+ return { path, status, outcome: writeGenerated(absolute, content, { header, force: options2.force }) };
1936
+ } catch (error) {
1937
+ if (error instanceof DriftError) return { path, status: "drift", outcome: "skipped-drift" };
1938
+ throw error;
1939
+ }
1940
+ });
1941
+ }
1942
+ function findOrphans(appRoot, all) {
1943
+ const planned = new Set(planFiles(all).map((file) => file.path));
1944
+ const candidates = [];
1945
+ const list2 = (dir, match) => existsSync10(join9(appRoot, dir)) ? readdirSync6(join9(appRoot, dir)).filter(match).map((name) => `${dir}/${name}`) : [];
1946
+ candidates.push(...list2("db/schema", (name) => name.endsWith(".ts")));
1947
+ candidates.push(...list2("resources", (name) => /\.(client|validators)\.ts$/.test(name)));
1948
+ for (const dir of list2("app/api", () => true)) {
1949
+ for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync10(join9(appRoot, file))) candidates.push(file);
1950
+ }
1951
+ for (const dir of list2("app/admin", () => true)) {
1952
+ for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
1953
+ if (existsSync10(join9(appRoot, file))) candidates.push(file);
1954
+ }
1955
+ }
1956
+ const orphans = [];
1957
+ for (const path of candidates) {
1958
+ const match = HEADER_PATTERN.exec(readFileSync7(join9(appRoot, path), "utf8"));
1959
+ if (match && !planned.has(path)) orphans.push({ path, resource: match[1] });
1960
+ }
1961
+ return orphans;
1962
+ }
1963
+ function ensureSupportFiles(appRoot, log) {
1964
+ for (const path of ["lib/api.ts", "lib/cache.ts"]) {
1965
+ const target = join9(appRoot, path);
1966
+ if (existsSync10(target)) continue;
1967
+ mkdirSync3(dirname5(target), { recursive: true });
1968
+ copyFileSync(join9(templatesDir, "app", path), target);
1969
+ log(`${pc3.green("create".padEnd(9))} ${path}`);
1970
+ }
1971
+ }
1972
+ function logResults(results, log, options2 = {}) {
1973
+ for (const { path, outcome } of results) {
1974
+ if (outcome === "identical" && !options2.showIdentical) continue;
1975
+ const label = outcome === "create" ? pc3.green("create") : outcome === "update" ? pc3.yellow("update") : outcome === "identical" ? pc3.dim("identical") : pc3.red(outcome === "skipped-drift" ? "drift" : "hand-written");
1976
+ log(`${label.padEnd(18)} ${path}`);
1977
+ }
1978
+ }
1979
+
1980
+ // src/commands/gen-billing.ts
1981
+ var STRIPE_DEP = "22.6.0";
1982
+ function billingFiles(appName) {
1983
+ return [
1984
+ { path: "lib/stripe.ts", content: renderLibStripe(appName) },
1985
+ { path: "lib/billing.ts", content: renderLibBilling() },
1986
+ { path: "app/api/billing/checkout/route.ts", content: renderCheckoutRoute() },
1987
+ { path: "app/api/billing/portal/route.ts", content: renderPortalRoute() },
1988
+ { path: "app/api/webhooks/stripe/route.ts", content: renderWebhookRoute() },
1989
+ { path: "components/billing/billing-button.tsx", content: renderBillingButton() },
1990
+ { path: "components/billing/billing-portal-button.tsx", content: renderBillingPortalButton() },
1991
+ { path: "app/dashboard/billing/page.tsx", content: renderBillingPage() }
1992
+ ];
1993
+ }
1994
+ var RETIRED_FILES = ["app/api/billing/status/route.ts", "components/billing/subscribe-button.tsx"];
1995
+ function appendVars(appRoot, target) {
1996
+ const path = join10(appRoot, target);
1997
+ const text = existsSync11(path) ? readFileSync8(path, "utf8") : "";
1998
+ if (/STRIPE_SECRET_KEY/.test(text)) return false;
1999
+ appendFileSync2(path, target === ".dev.vars" ? billingDevVarsEntries() : billingDevVarsExampleEntries());
2000
+ return true;
2001
+ }
2002
+ function addStripeDependency(appRoot) {
2003
+ const path = join10(appRoot, "package.json");
2004
+ const pkg = JSON.parse(readFileSync8(path, "utf8"));
2005
+ pkg.dependencies ??= {};
2006
+ if (pkg.dependencies.stripe) return false;
2007
+ pkg.dependencies.stripe = STRIPE_DEP;
2008
+ writeJson(path, pkg);
2009
+ return true;
2010
+ }
2011
+ function writeBillingResource(appRoot, name, force, log) {
2012
+ const relative2 = descriptorPath(name);
2013
+ const path = join10(appRoot, relative2);
2014
+ if (!existsSync11(path)) {
2015
+ mkdirSync4(dirname6(path), { recursive: true });
2016
+ writeFileSync5(path, renderBillingDescriptor(name));
2017
+ log(`${pc4.green("create".padEnd(9))} ${relative2}`);
2018
+ return;
2019
+ }
2020
+ const parts = splitMarkers(readFileSync8(path, "utf8"));
2021
+ 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]}`);
2022
+ const merged = mergeBillingFields(name, parts.block);
2023
+ if (!merged) {
2024
+ log(`${pc4.dim("identical".padEnd(9))} ${relative2} (has every billing field)`);
2025
+ return;
2026
+ }
2027
+ try {
2028
+ writeGenerated(path, merged, { force });
2029
+ } catch (error) {
2030
+ if (error instanceof DriftError) {
2031
+ throw new Error(`${relative2}: its fields block was edited by hand, so the billing fields weren't merged. Pass --force, or add them yourself: ${BILLING_RESOURCES[name]}`);
2032
+ }
2033
+ throw error;
2034
+ }
2035
+ log(`${pc4.yellow("update".padEnd(9))} ${relative2} (added billing fields, kept yours)`);
2036
+ }
2037
+ async function genBilling(options2 = {}) {
2038
+ const log = options2.log ?? ((message) => console.log(message));
2039
+ validateBillingOptions(options2.provider, options2.mode);
2040
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2041
+ for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force, log);
2042
+ for (const [name, roles] of Object.entries(BILLING_POLICIES)) {
2043
+ const relative2 = policyPath(name);
2044
+ if (existsSync11(join10(appRoot, relative2))) {
2045
+ log(`${pc4.dim("kept".padEnd(9))} ${relative2} (already exists)`);
2046
+ continue;
2047
+ }
2048
+ mkdirSync4(join10(appRoot, "policies"), { recursive: true });
2049
+ writeFileSync5(join10(appRoot, relative2), renderPolicy(name, roles));
2050
+ log(`${pc4.green("create".padEnd(9))} ${relative2}`);
2051
+ }
2052
+ const files = [];
2053
+ const appName = JSON.parse(readFileSync8(join10(appRoot, "package.json"), "utf8")).name ?? "app";
2054
+ for (const { path, content } of billingFiles(appName)) {
2055
+ const outcome = writeGenerated(join10(appRoot, path), content, { header: billingHeader(), force: options2.force });
2056
+ files.push({ path, status: "missing", outcome });
2057
+ const label = outcome === "create" ? pc4.green("create") : outcome === "update" ? pc4.yellow("update") : pc4.dim("identical");
2058
+ log(`${label.padEnd(18)} ${path}`);
2059
+ }
2060
+ for (const path of RETIRED_FILES) {
2061
+ const absolute = join10(appRoot, path);
2062
+ if (!existsSync11(absolute)) continue;
2063
+ const parts = splitMarkers(readFileSync8(absolute, "utf8"));
2064
+ const untouched = parts && parts.before.includes("flare gen billing") && !parts.after.trim() && parts.hash !== void 0 && hashBlock(parts.block) === parts.hash;
2065
+ if (untouched) {
2066
+ unlinkSync(absolute);
2067
+ log(`${pc4.red("remove".padEnd(9))} ${path} (no longer generated)`);
2068
+ } else {
2069
+ log(pc4.yellow(`${path} is no longer generated but has your changes; remove it when you're done with it.`));
2070
+ }
2071
+ }
2072
+ if (appendVars(appRoot, ".dev.vars.example")) log(`${pc4.green("append".padEnd(9))} .dev.vars.example`);
2073
+ if (appendVars(appRoot, ".dev.vars")) log(`${pc4.green("append".padEnd(9))} .dev.vars`);
2074
+ const addedStripe = addStripeDependency(appRoot);
2075
+ if (addedStripe) log(`${pc4.yellow("update".padEnd(9))} package.json (stripe@${STRIPE_DEP})`);
2076
+ if (!options2.skipInstall) {
2077
+ const pm = detectPackageManager();
2078
+ const code = run(pm, ["install"], appRoot);
2079
+ if (code !== 0) throw new Error(`${pm} install failed (exit code ${code}).`);
2080
+ run(pm, ["run", "cf-typegen"], appRoot);
2081
+ }
2082
+ const all = await loadResources(appRoot);
2083
+ ensureSupportFiles(appRoot, log);
2084
+ logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log);
2085
+ let migrations = [];
2086
+ if (!options2.skipMigration) {
2087
+ const result = await generateSchemaMigration(appRoot, "billing");
2088
+ migrations = result.files;
2089
+ for (const file of migrations) log(`${pc4.green("create".padEnd(9))} ${file}`);
2090
+ for (const repair of result.repairs) log(pc4.dim(`repaired ${repair}`));
2091
+ }
2092
+ log(
2093
+ [
2094
+ "",
2095
+ `Next:`,
2096
+ ` 1. Put a Stripe test key in .dev.vars (STRIPE_SECRET_KEY, ideally a restricted rk_test_ key).`,
2097
+ ` 2. ${pc4.bold("flare migrate")} to create the billing tables.`,
2098
+ ` 3. Create one Product per plan in Stripe with metadata flare_app=${appName}, then ${pc4.bold("flare billing:sync-plans")}.`,
2099
+ ` 4. ${pc4.bold("stripe listen --forward-to localhost:8787/api/webhooks/stripe")} and put its signing secret in STRIPE_WEBHOOK_SECRET.`,
2100
+ ` 5. ${pc4.bold("flare start")} and open ${pc4.bold("/dashboard/billing")}.`
2101
+ ].join("\n")
2102
+ );
2103
+ return { files, migrations, addedStripe };
2104
+ }
2105
+
2106
+ // src/commands/gen-security.ts
2107
+ import { existsSync as existsSync12, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
2108
+ import { dirname as dirname7, join as join11 } from "path";
2109
+ import pc5 from "picocolors";
2110
+
2111
+ // src/generator/security.ts
2112
+ import { applyEdits, modify, parse as parseJsonc2 } from "jsonc-parser";
2113
+ var SECURITY_EVENT_FIELDS = "kind:string, severity:enum(low,medium,high), ip:string, path:string, userAgent:text?, count:int, banned:boolean?, detail:text?";
2114
+ var renderSecurityDescriptor = () => renderDescriptor("SecurityEvent", parseFields(SECURITY_EVENT_FIELDS));
2115
+ var SECURITY_EVENT_POLICY = { read: ["admin", "staff"], create: ["admin"], update: ["admin"], delete: ["admin"] };
2116
+ var securityHeader = () => `// Generated by \`flare gen security\`. Code inside the generated block is
2117
+ // rewritten on regeneration; add your own code outside it.
2118
+ `;
2119
+ var renderSecurityConfig = () => `import { DEFAULT_DETECTORS, defineSecurity } from "@flaredev/core/security";
2120
+
2121
+ /**
2122
+ * What Flare watches for and blocks. The Worker layer applies this on every request
2123
+ * (any plan, workers.dev included). The \`zone\` rules are pushed to your zone by
2124
+ * \`flare deploy\` when FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN are set.
2125
+ */
2126
+ export default defineSecurity({
2127
+ // IPs never banned or rate-limited: your office, uptime monitors.
2128
+ allow: [],
2129
+
2130
+ // Failed-login bursts, checkout abuse and API scraping. Each trip is logged as a
2131
+ // SecurityEvent; detectors with \`ban\` also block the IP.
2132
+ detectors: DEFAULT_DETECTORS,
2133
+
2134
+ // Worker-level rate limit for the API (limit and period are set on the
2135
+ // FLARE_RATE_LIMIT binding in wrangler.jsonc).
2136
+ rateLimit: { binding: "FLARE_RATE_LIMIT", paths: /^\\/api\\// },
2137
+
2138
+ zone: {
2139
+ // The Free plan allows one rate-limiting rule, with a 10-second period.
2140
+ rateLimits: [
2141
+ {
2142
+ name: "sign-in",
2143
+ expression: 'http.request.uri.path eq "/api/auth/sign-in/email"',
2144
+ requests: 10,
2145
+ periodSeconds: 10,
2146
+ mitigationSeconds: 10,
2147
+ },
2148
+ ],
2149
+ customRules: [
2150
+ {
2151
+ name: "block-scanners",
2152
+ expression:
2153
+ '(http.request.uri.path contains "/wp-login.php") or (http.request.uri.path contains "/xmlrpc.php") or (http.request.uri.path contains "/.env") or (http.request.uri.path contains "/.git/")',
2154
+ action: "block",
2155
+ },
2156
+ ],
2157
+ },
2158
+ });
2159
+ `;
2160
+ var renderLibSecurity = (app) => `import { env as runtimeEnv } from "cloudflare:workers";
2161
+ import { createZoneClient } from "@flaredev/core/security";
2162
+ import { createSecurity, type BanStore, type MonitorNamespace, type RateLimiter, type Security } from "@flaredev/core/security/server";
2163
+ import { getDb } from "../db";
2164
+ import { securityEvents } from "../db/schema";
2165
+ import config from "../security.config";
2166
+
2167
+ /** Minimal ExecutionContext surface worker/index.ts passes in. */
2168
+ export interface SecurityContext {
2169
+ waitUntil(promise: Promise<unknown>): void;
2170
+ }
2171
+
2172
+ interface SecurityEnv {
2173
+ FLARE_SECURITY: BanStore;
2174
+ FLARE_SECURITY_MONITOR: MonitorNamespace;
2175
+ FLARE_RATE_LIMIT?: RateLimiter;
2176
+ /** With both set (wrangler secrets), bans also become zone IP Access Rules. */
2177
+ FLARE_SECURITY_ZONE_ID?: string;
2178
+ FLARE_SECURITY_API_TOKEN?: string;
2179
+ }
2180
+
2181
+ /** This app's tag on zone rules, so Flare only ever changes its own. */
2182
+ const APP = ${JSON.stringify(app)};
2183
+
2184
+ const bindings = () => runtimeEnv as unknown as SecurityEnv;
2185
+ let instance: Security | undefined;
2186
+
2187
+ /** The app's security layer, one per isolate. */
2188
+ export function security(): Security {
2189
+ const env = bindings();
2190
+ instance ??= createSecurity({
2191
+ config,
2192
+ bans: env.FLARE_SECURITY,
2193
+ monitors: env.FLARE_SECURITY_MONITOR,
2194
+ rateLimiter: env.FLARE_RATE_LIMIT,
2195
+ zone: zoneClient(),
2196
+ // Every tripped detector becomes a SecurityEvent row.
2197
+ onTrip: (trip) =>
2198
+ logSecurityEvent({
2199
+ kind: trip.detector.name,
2200
+ severity: trip.detector.severity ?? "medium",
2201
+ ip: trip.ip,
2202
+ path: trip.path,
2203
+ userAgent: trip.userAgent,
2204
+ count: trip.count,
2205
+ banned: trip.banned,
2206
+ detail: trip.detector.description ?? null,
2207
+ }),
2208
+ });
2209
+ return instance;
2210
+ }
2211
+
2212
+ /** The zone layer, when this app has a zone and a scoped API token configured. */
2213
+ export function zoneClient() {
2214
+ const env = bindings();
2215
+ return env.FLARE_SECURITY_ZONE_ID && env.FLARE_SECURITY_API_TOKEN
2216
+ ? createZoneClient({ zoneId: env.FLARE_SECURITY_ZONE_ID, apiToken: env.FLARE_SECURITY_API_TOKEN, app: APP })
2217
+ : null;
2218
+ }
2219
+
2220
+ export async function logSecurityEvent(event: typeof securityEvents.$inferInsert): Promise<void> {
2221
+ await getDb().insert(securityEvents).values(event);
2222
+ }
2223
+
2224
+ /** Called by worker/index.ts for every request. */
2225
+ export function protect(request: Request, _env: unknown, ctx: SecurityContext, next: () => Promise<Response>): Promise<Response> {
2226
+ return security().protect(request, ctx, next);
2227
+ }
2228
+ `;
2229
+ var renderSecurityActions = () => `"use server";
2230
+
2231
+ import { revalidatePath } from "next/cache";
2232
+ import { can } from "@flaredev/core";
2233
+ import { isIp } from "@flaredev/core/security";
2234
+ import { adminSession, policyFor } from "@/lib/admin";
2235
+ import { logSecurityEvent, security } from "@/lib/security";
2236
+
2237
+ /** On failure the submitted values come back, so the form (which React resets) keeps them. */
2238
+ export type BanResult = { ok: true } | { ok: false; error: string; ip?: string; reason?: string };
2239
+
2240
+ const DURATIONS: Record<string, number | null> = { "1h": 3600, "24h": 86_400, "7d": 604_800, permanent: null };
2241
+
2242
+ async function adminOnly(): Promise<{ email: string } | { error: string }> {
2243
+ const { session, role } = await adminSession();
2244
+ if (!session) return { error: "Sign in required." };
2245
+ if (!can(policyFor("SecurityEvent"), role, "create")) return { error: "Only admins can ban or unban." };
2246
+ return { email: session.user.email };
2247
+ }
2248
+
2249
+ /** Ban an IP at every layer this app has (Worker, and the zone when configured). */
2250
+ export async function banAction(_previous: BanResult | null, form: FormData): Promise<BanResult> {
2251
+ const admin = await adminOnly();
2252
+ if ("error" in admin) return { ok: false, error: admin.error };
2253
+ const ip = String(form.get("ip") ?? "").trim();
2254
+ const reason = String(form.get("reason") ?? "").trim() || "Banned manually";
2255
+ const duration = String(form.get("duration") ?? "24h");
2256
+ if (!isIp(ip)) return { ok: false, error: \`"\${ip}" isn't an IP address.\`, ip, reason };
2257
+ if (!(duration in DURATIONS)) return { ok: false, error: "Choose how long to ban for.", ip, reason };
2258
+
2259
+ try {
2260
+ await security().ban(ip, \`\${reason} (by \${admin.email})\`, DURATIONS[duration]!, null);
2261
+ } catch (error) {
2262
+ return { ok: false, error: \`The ban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\`, ip, reason };
2263
+ }
2264
+ await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/admin/security", userAgent: null, count: 0, banned: true, detail: \`\${reason} (by \${admin.email})\` });
2265
+ revalidatePath("/admin/security");
2266
+ return { ok: true };
2267
+ }
2268
+
2269
+ export async function unbanAction(ip: string): Promise<BanResult> {
2270
+ const admin = await adminOnly();
2271
+ if ("error" in admin) return { ok: false, error: admin.error };
2272
+ if (!isIp(ip)) return { ok: false, error: \`"\${ip}" isn't an IP address.\` };
2273
+ try {
2274
+ await security().unban(ip);
2275
+ } catch (error) {
2276
+ return { ok: false, error: \`The unban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\` };
2277
+ }
2278
+ await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/admin/security", userAgent: null, count: 0, banned: false, detail: \`by \${admin.email}\` });
2279
+ revalidatePath("/admin/security");
2280
+ return { ok: true };
2281
+ }
2282
+ `;
2283
+ var renderBanControls = () => `"use client";
2284
+
2285
+ import { useActionState, useState, useTransition } from "react";
2286
+ import { toast } from "sonner";
2287
+ import { banAction, unbanAction, type BanResult } from "./actions";
2288
+ import { Button } from "@/components/ui/button";
2289
+ import { Input } from "@/components/ui/input";
2290
+ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
2291
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
2292
+ import { Spinner } from "@/components/ui/spinner";
2293
+
2294
+ const DURATIONS = { "1h": "1 hour", "24h": "24 hours", "7d": "7 days", permanent: "Until unbanned" } as const;
2295
+
2296
+ export function BanForm() {
2297
+ const [state, action, pending] = useActionState<BanResult | null, FormData>(banAction, null);
2298
+ const [duration, setDuration] = useState<keyof typeof DURATIONS>("24h");
2299
+ return (
2300
+ <form action={action}>
2301
+ <FieldGroup className="gap-4">
2302
+ <div className="grid gap-4 sm:grid-cols-[1fr_12rem_auto] sm:items-end">
2303
+ <Field data-invalid={state && !state.ok ? true : undefined}>
2304
+ <FieldLabel htmlFor="ban-ip">IP address</FieldLabel>
2305
+ <Input id="ban-ip" name="ip" defaultValue={state && !state.ok ? state.ip : undefined} placeholder="203.0.113.7" required autoComplete="off" spellCheck={false} aria-invalid={state && !state.ok ? true : undefined} />
2306
+ </Field>
2307
+ <Field>
2308
+ <FieldLabel htmlFor="ban-duration">For</FieldLabel>
2309
+ <Select name="duration" value={duration} onValueChange={(value) => setDuration(value as keyof typeof DURATIONS)}>
2310
+ <SelectTrigger id="ban-duration" className="w-full">
2311
+ {/* Radix fills SelectValue only once the list has mounted; give it the label up front. */}
2312
+ <SelectValue>{DURATIONS[duration]}</SelectValue>
2313
+ </SelectTrigger>
2314
+ <SelectContent>
2315
+ <SelectGroup>
2316
+ {Object.entries(DURATIONS).map(([value, label]) => (
2317
+ <SelectItem key={value} value={value}>
2318
+ {label}
2319
+ </SelectItem>
2320
+ ))}
2321
+ </SelectGroup>
2322
+ </SelectContent>
2323
+ </Select>
2324
+ </Field>
2325
+ <Button type="submit" disabled={pending}>
2326
+ {pending && <Spinner data-icon="inline-start" />}
2327
+ Ban IP
2328
+ </Button>
2329
+ </div>
2330
+ <Field>
2331
+ <FieldLabel htmlFor="ban-reason">Reason</FieldLabel>
2332
+ <Input id="ban-reason" name="reason" defaultValue={state && !state.ok ? state.reason : undefined} placeholder="Credential stuffing from this address\u2026" autoComplete="off" />
2333
+ </Field>
2334
+ {state && !state.ok && <FieldError role="alert">{state.error}</FieldError>}
2335
+ {state?.ok && (
2336
+ <FieldDescription role="status">Banned.</FieldDescription>
2337
+ )}
2338
+ </FieldGroup>
2339
+ </form>
2340
+ );
2341
+ }
2342
+
2343
+ export function UnbanButton({ ip }: { ip: string }) {
2344
+ const [pending, start] = useTransition();
2345
+ return (
2346
+ <Button
2347
+ variant="outline"
2348
+ size="sm"
2349
+ disabled={pending}
2350
+ onClick={() =>
2351
+ start(async () => {
2352
+ const result = await unbanAction(ip);
2353
+ if (result.ok) toast.success(\`Unbanned \${ip}.\`);
2354
+ else toast.error(result.error);
2355
+ })
2356
+ }
2357
+ >
2358
+ {pending && <Spinner data-icon="inline-start" />}
2359
+ Unban<span className="sr-only"> {ip}</span>
2360
+ </Button>
2361
+ );
2362
+ }
2363
+ `;
2364
+ var renderSecurityPage = () => `import Link from "next/link";
2365
+ import { can } from "@flaredev/core";
2366
+ import { Alert, AlertDescription } from "@/components/ui/alert";
2367
+ import { Badge } from "@/components/ui/badge";
2368
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
2369
+ import { Separator } from "@/components/ui/separator";
2370
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
2371
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
2372
+ import { adminStore, policyFor, requireAdmin } from "@/lib/admin";
2373
+ import { security, zoneClient } from "@/lib/security";
2374
+ import { BanForm, UnbanButton } from "./ban-controls";
2375
+
2376
+ const TONE = { high: "danger", medium: "warning", low: "secondary" } as const;
2377
+ const when = (value: string | number | Date | null) =>
2378
+ value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Until unbanned";
2379
+
2380
+ export default async function SecurityPage() {
2381
+ const { role } = await requireAdmin("/admin/security");
2382
+ if (!can(policyFor("SecurityEvent"), role, "read")) {
2383
+ return (
2384
+ <Alert>
2385
+ <AlertDescription>Your role can't view security activity.</AlertDescription>
2386
+ </Alert>
2387
+ );
2388
+ }
2389
+ const canBan = can(policyFor("SecurityEvent"), role, "create");
2390
+ const zone = zoneClient();
2391
+
2392
+ const [bans, events, edge] = await Promise.all([
2393
+ security().bans(),
2394
+ adminStore("SecurityEvent").list(new URLSearchParams({ sort: "-createdAt", perPage: "20" })),
2395
+ zone
2396
+ ? Promise.all([zone.bans(), zone.firewallEvents(new Date(Date.now() - 24 * 3600 * 1000))]).then(
2397
+ ([zoneBans, firewall]) => ({ ok: true as const, zoneBans, firewall }),
2398
+ (error: unknown) => ({ ok: false as const, error: error instanceof Error ? error.message : String(error) }),
2399
+ )
2400
+ : Promise.resolve(null),
2401
+ ]);
2402
+ const recent = events.ok ? events.data.data : [];
2403
+ const zoneIps = new Set(edge?.ok ? edge.zoneBans.map((ban) => ban.ip) : []);
2404
+ const blockedTotal = bans.reduce((sum, ban) => sum + ban.blocked, 0);
2405
+
2406
+ return (
2407
+ <div className="flex flex-col gap-6">
2408
+ <div className="flex flex-wrap items-end justify-between gap-4">
2409
+ <div className="flex flex-col gap-1">
2410
+ <h1 className="text-2xl font-semibold">Security</h1>
2411
+ <p className="text-sm text-muted-foreground">
2412
+ Worker layer on{zone ? ", zone rules on" : "; zone rules off (set FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN)"}.
2413
+ </p>
2414
+ </div>
2415
+ </div>
2416
+
2417
+ <div className="grid gap-4 sm:grid-cols-3">
2418
+ <Card>
2419
+ <CardHeader>
2420
+ <CardDescription>Active bans</CardDescription>
2421
+ <CardTitle className="text-3xl font-bold tabular-nums">{bans.length}</CardTitle>
2422
+ </CardHeader>
2423
+ </Card>
2424
+ <Card>
2425
+ <CardHeader>
2426
+ <CardDescription>Requests blocked by bans</CardDescription>
2427
+ <CardTitle className="text-3xl font-bold tabular-nums">{blockedTotal}</CardTitle>
2428
+ </CardHeader>
2429
+ </Card>
2430
+ <Card>
2431
+ <CardHeader>
2432
+ <CardDescription>Zone firewall events, 24h</CardDescription>
2433
+ <CardTitle className="text-3xl font-bold tabular-nums">
2434
+ {edge?.ok ? edge.firewall.reduce((sum, row) => sum + row.count, 0) : "\u2014"}
2435
+ </CardTitle>
2436
+ </CardHeader>
2437
+ </Card>
2438
+ </div>
2439
+
2440
+ {edge && !edge.ok && (
2441
+ <Alert>
2442
+ <AlertDescription>The zone layer couldn't be read: {edge.error}</AlertDescription>
2443
+ </Alert>
2444
+ )}
2445
+
2446
+ <Card>
2447
+ <CardHeader>
2448
+ <CardTitle>Banned IPs</CardTitle>
2449
+ <CardDescription>Refused before the app runs{zone ? ", and blocked at the zone edge" : ""}.</CardDescription>
2450
+ </CardHeader>
2451
+ <CardContent className="flex flex-col gap-6">
2452
+ {bans.length === 0 ? (
2453
+ <Empty className="border border-dashed">
2454
+ <EmptyHeader>
2455
+ <EmptyTitle>No active bans</EmptyTitle>
2456
+ <EmptyDescription>IPs that trip a detector with a ban, or that you ban here, appear in this list.</EmptyDescription>
2457
+ </EmptyHeader>
2458
+ </Empty>
2459
+ ) : (
2460
+ <Table>
2461
+ <TableHeader>
2462
+ <TableRow>
2463
+ <TableHead>IP</TableHead>
2464
+ <TableHead>Reason</TableHead>
2465
+ <TableHead>Blocked</TableHead>
2466
+ <TableHead>Until</TableHead>
2467
+ {canBan && <TableHead className="w-0" />}
2468
+ </TableRow>
2469
+ </TableHeader>
2470
+ <TableBody>
2471
+ {bans.map((ban) => (
2472
+ <TableRow key={ban.ip}>
2473
+ <TableCell className="font-medium tabular-nums">
2474
+ <span className="flex items-center gap-2">
2475
+ {ban.ip}
2476
+ {zoneIps.has(ban.ip) && <Badge variant="secondary">zone</Badge>}
2477
+ </span>
2478
+ </TableCell>
2479
+ <TableCell className="max-w-80 truncate text-muted-foreground">{ban.reason}</TableCell>
2480
+ <TableCell className="tabular-nums">{ban.blocked}</TableCell>
2481
+ <TableCell className="text-muted-foreground">{when(ban.until)}</TableCell>
2482
+ {canBan && (
2483
+ <TableCell>
2484
+ <UnbanButton ip={ban.ip} />
2485
+ </TableCell>
2486
+ )}
2487
+ </TableRow>
2488
+ ))}
2489
+ </TableBody>
2490
+ </Table>
2491
+ )}
2492
+ {canBan && (
2493
+ <>
2494
+ <Separator />
2495
+ <BanForm />
2496
+ </>
2497
+ )}
2498
+ </CardContent>
2499
+ </Card>
2500
+
2501
+ <Card>
2502
+ <CardHeader>
2503
+ <CardTitle>Recent events</CardTitle>
2504
+ <CardDescription>
2505
+ Detectors that tripped, and manual bans. <Link href="/admin/security-events" className="underline underline-offset-4">All events</Link>
2506
+ </CardDescription>
2507
+ </CardHeader>
2508
+ <CardContent>
2509
+ {recent.length === 0 ? (
2510
+ <p className="text-sm text-muted-foreground">Nothing yet.</p>
2511
+ ) : (
2512
+ <Table>
2513
+ <TableHeader>
2514
+ <TableRow>
2515
+ <TableHead>When</TableHead>
2516
+ <TableHead>Event</TableHead>
2517
+ <TableHead>IP</TableHead>
2518
+ <TableHead>Count</TableHead>
2519
+ <TableHead>Action</TableHead>
2520
+ </TableRow>
2521
+ </TableHeader>
2522
+ <TableBody>
2523
+ {recent.map((event) => (
2524
+ <TableRow key={String(event.id)}>
2525
+ <TableCell className="text-muted-foreground">{when(event.createdAt as Date)}</TableCell>
2526
+ <TableCell>
2527
+ <span className="flex items-center gap-2">
2528
+ <Badge variant={TONE[event.severity as keyof typeof TONE] ?? "secondary"}>{String(event.severity)}</Badge>
2529
+ {String(event.kind)}
2530
+ </span>
2531
+ </TableCell>
2532
+ <TableCell className="tabular-nums">{String(event.ip)}</TableCell>
2533
+ <TableCell className="tabular-nums">{Number(event.count) || "\u2014"}</TableCell>
2534
+ <TableCell className="text-muted-foreground">{event.banned ? "Banned" : "Logged"}</TableCell>
2535
+ </TableRow>
2536
+ ))}
2537
+ </TableBody>
2538
+ </Table>
2539
+ )}
2540
+ </CardContent>
2541
+ </Card>
2542
+
2543
+ {edge?.ok && (
2544
+ <Card>
2545
+ <CardHeader>
2546
+ <CardTitle>Zone firewall, last 24 hours</CardTitle>
2547
+ <CardDescription>What Cloudflare's edge did before requests reached the Worker.</CardDescription>
2548
+ </CardHeader>
2549
+ <CardContent>
2550
+ {edge.firewall.length === 0 ? (
2551
+ <p className="text-sm text-muted-foreground">No firewall events.</p>
2552
+ ) : (
2553
+ <Table>
2554
+ <TableHeader>
2555
+ <TableRow>
2556
+ <TableHead>Action</TableHead>
2557
+ <TableHead>Source</TableHead>
2558
+ <TableHead>Rule</TableHead>
2559
+ <TableHead>Events</TableHead>
2560
+ </TableRow>
2561
+ </TableHeader>
2562
+ <TableBody>
2563
+ {edge.firewall.map((row, index) => (
2564
+ <TableRow key={index}>
2565
+ <TableCell>{row.action}</TableCell>
2566
+ <TableCell className="text-muted-foreground">{row.source}</TableCell>
2567
+ <TableCell className="text-muted-foreground">{row.description ?? "\u2014"}</TableCell>
2568
+ <TableCell className="tabular-nums">{row.count}</TableCell>
2569
+ </TableRow>
2570
+ ))}
2571
+ </TableBody>
2572
+ </Table>
2573
+ )}
2574
+ </CardContent>
2575
+ </Card>
2576
+ )}
2577
+ </div>
2578
+ );
2579
+ }
2580
+ `;
2581
+ function addSecurityBindings(source) {
2582
+ const config = parseJsonc2(source) ?? {};
2583
+ let text = source;
2584
+ const added = [];
2585
+ const format = { formattingOptions: { insertSpaces: true, tabSize: 2 } };
2586
+ const append = (path, value) => {
2587
+ text = applyEdits(text, modify(text, path, value, { ...format, isArrayInsertion: true }));
2588
+ };
2589
+ if (!config.kv_namespaces?.some((entry) => entry.binding === "FLARE_SECURITY")) {
2590
+ if (config.kv_namespaces) append(["kv_namespaces", config.kv_namespaces.length], { binding: "FLARE_SECURITY" });
2591
+ else text = applyEdits(text, modify(text, ["kv_namespaces"], [{ binding: "FLARE_SECURITY" }], format));
2592
+ added.push("kv_namespaces: FLARE_SECURITY (ban list)");
2593
+ }
2594
+ if (!config.durable_objects?.bindings?.some((entry) => entry.name === "FLARE_SECURITY_MONITOR")) {
2595
+ const binding = { name: "FLARE_SECURITY_MONITOR", class_name: "SecurityMonitor" };
2596
+ if (config.durable_objects?.bindings) append(["durable_objects", "bindings", config.durable_objects.bindings.length], binding);
2597
+ else text = applyEdits(text, modify(text, ["durable_objects", "bindings"], [binding], format));
2598
+ added.push("durable_objects: FLARE_SECURITY_MONITOR (per-IP counters)");
2599
+ }
2600
+ const classes = (config.migrations ?? []).flatMap((migration) => [...migration.new_sqlite_classes ?? [], ...migration.new_classes ?? []]);
2601
+ if (!classes.includes("SecurityMonitor")) {
2602
+ const migration = { tag: "flare-security-v1", new_sqlite_classes: ["SecurityMonitor"] };
2603
+ if (config.migrations) append(["migrations", config.migrations.length], migration);
2604
+ else text = applyEdits(text, modify(text, ["migrations"], [migration], format));
2605
+ added.push("migrations: SecurityMonitor");
2606
+ }
2607
+ if (!config.ratelimits?.some((entry) => entry.name === "FLARE_RATE_LIMIT")) {
2608
+ const binding = { name: "FLARE_RATE_LIMIT", namespace_id: String(1e3 + Math.floor(Math.random() * 9e3)), simple: { limit: 120, period: 60 } };
2609
+ if (config.ratelimits) append(["ratelimits", config.ratelimits.length], binding);
2610
+ else text = applyEdits(text, modify(text, ["ratelimits"], [binding], format));
2611
+ added.push("ratelimits: FLARE_RATE_LIMIT (120 requests/minute per IP on /api)");
2612
+ }
2613
+ return { text, added };
2614
+ }
2615
+ var renderAdminNavBlock = () => `export interface AdminLink {
2616
+ label: string;
2617
+ href: string;
2618
+ /** A resource icon name, e.g. "shield" (see components/admin/resource-icon.tsx). */
2619
+ icon: string;
2620
+ }
2621
+
2622
+ export const generatedAdminLinks: AdminLink[] = [{ label: "Security", href: "/admin/security", icon: "shield" }];
2623
+ `;
2624
+
2625
+ // src/commands/gen-security.ts
2626
+ function securityFiles(appName) {
2627
+ return [
2628
+ { path: "lib/security.ts", content: renderLibSecurity(appName) },
2629
+ { path: "app/admin/security/page.tsx", content: renderSecurityPage() },
2630
+ { path: "app/admin/security/actions.ts", content: renderSecurityActions() },
2631
+ { path: "app/admin/security/ban-controls.tsx", content: renderBanControls() }
2632
+ ];
2633
+ }
2634
+ var PASS_THROUGH_HEADER = /^\/\/ Request protection for worker\/index\.ts\.[\s\S]*?\n(?=\/\/ generated:start)/;
2635
+ function writeOnce(appRoot, relative2, content, log) {
2636
+ const path = join11(appRoot, relative2);
2637
+ if (existsSync12(path)) {
2638
+ log(`${pc5.dim("kept".padEnd(9))} ${relative2} (already exists)`);
2639
+ return;
2640
+ }
2641
+ mkdirSync5(dirname7(path), { recursive: true });
2642
+ writeFileSync6(path, content);
2643
+ log(`${pc5.green("create".padEnd(9))} ${relative2}`);
2644
+ }
2645
+ async function genSecurity(options2 = {}) {
2646
+ const log = options2.log ?? ((message) => console.log(message));
2647
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2648
+ const appName = JSON.parse(readFileSync9(join11(appRoot, "package.json"), "utf8")).name ?? "app";
2649
+ writeOnce(appRoot, "security.config.ts", renderSecurityConfig(), log);
2650
+ writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(), log);
2651
+ writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY), log);
2652
+ const files = [];
2653
+ const write = (path, content, header) => {
2654
+ const absolute = join11(appRoot, path);
2655
+ if (path === "lib/security.ts" && existsSync12(absolute)) {
2656
+ const source = readFileSync9(absolute, "utf8");
2657
+ if (PASS_THROUGH_HEADER.test(source)) writeFileSync6(absolute, source.replace(PASS_THROUGH_HEADER, securityHeader()));
2658
+ }
2659
+ const outcome = writeGenerated(absolute, content, { header, force: options2.force });
2660
+ files.push({ path, status: "missing", outcome });
2661
+ const label = outcome === "create" ? pc5.green("create") : outcome === "update" ? pc5.yellow("update") : pc5.dim("identical");
2662
+ log(`${label.padEnd(18)} ${path}`);
2663
+ };
2664
+ for (const { path, content } of securityFiles(appName)) write(path, content, securityHeader());
2665
+ const navPath = join11(appRoot, "lib/admin-nav.ts");
2666
+ if (existsSync12(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/admin-nav.ts", renderAdminNavBlock());
2667
+ else log(pc5.yellow(`lib/admin-nav.ts not found: link to /admin/security from your admin sidebar yourself.`));
2668
+ const wranglerPath = join11(appRoot, "wrangler.jsonc");
2669
+ let bindings = [];
2670
+ if (existsSync12(wranglerPath)) {
2671
+ const { text, added } = addSecurityBindings(readFileSync9(wranglerPath, "utf8"));
2672
+ bindings = added;
2673
+ if (added.length) {
2674
+ writeFileSync6(wranglerPath, text);
2675
+ for (const line of added) log(`${pc5.yellow("update".padEnd(9))} wrangler.jsonc (${line})`);
2676
+ }
2677
+ } else {
2678
+ log(pc5.yellow("wrangler.jsonc not found: add the FLARE_SECURITY, FLARE_SECURITY_MONITOR and FLARE_RATE_LIMIT bindings yourself."));
2679
+ }
2680
+ const all = await loadResources(appRoot);
2681
+ ensureSupportFiles(appRoot, log);
2682
+ logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log);
2683
+ let migrations = [];
2684
+ if (!options2.skipMigration) {
2685
+ const result = await generateSchemaMigration(appRoot, "security");
2686
+ migrations = result.files;
2687
+ for (const file of migrations) log(`${pc5.green("create".padEnd(9))} ${file}`);
2688
+ for (const repair of result.repairs) log(pc5.dim(`repaired ${repair}`));
2689
+ }
2690
+ log(
2691
+ [
2692
+ "",
2693
+ `Next:`,
2694
+ ` 1. ${pc5.bold("flare migrate")} to create the security_events table.`,
2695
+ ` 2. ${pc5.bold("flare dev")}, then open ${pc5.bold("/admin/security")}. Tune detectors in security.config.ts.`,
2696
+ ` 3. Optional zone layer: deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set (a token scoped to that`,
2697
+ ` one zone with Zone WAF Edit, Firewall Services Edit, Analytics Read, Zone Read). ${pc5.bold("flare deploy")} pushes the`,
2698
+ ` zone rules and uploads both as secrets.`
2699
+ ].join("\n")
2700
+ );
2701
+ return { files, migrations, bindings };
2702
+ }
2703
+
2704
+ // src/commands/gen.ts
2705
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
2706
+ import { dirname as dirname8, join as join12 } from "path";
2707
+ import { camelCase as camelCase4, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
2708
+ import pc6 from "picocolors";
2709
+ function validateFields(name, fields, others, log) {
2710
+ const known = /* @__PURE__ */ new Set([name, ...others.map((resource) => resource.name)]);
2711
+ for (const field of fields) {
2712
+ if (field.kind === "belongsTo" && !known.has(field.target)) {
2713
+ throw new Error(`${name}.${field.key} belongs to "${field.target}", which doesn't exist yet. Generate ${field.target} first.`);
2714
+ }
2715
+ }
2716
+ const candidate = defineResource2({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
2717
+ const graph = relationGraph2([...others, candidate]);
2718
+ for (const pending of graph.pending.filter((p) => p.resource === name)) {
2719
+ log(pc6.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
2720
+ }
2721
+ }
2722
+ async function genResource(rawName, options2) {
2723
+ const log = options2.log ?? ((message) => console.log(message));
2724
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2725
+ const name = resourceName(rawName);
2726
+ const relativePath = descriptorPath(name);
2727
+ const path = join12(appRoot, relativePath);
2728
+ const exists = existsSync13(path);
2729
+ if (!exists && !options2.fields) {
2730
+ throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
2731
+ }
2732
+ const existing = await loadResources(appRoot);
2733
+ const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
2734
+ if (options2.fields) {
2735
+ const fields = parseFields(options2.fields);
2736
+ validateFields(name, fields, others, log);
2737
+ if (!exists) {
2738
+ mkdirSync6(dirname8(path), { recursive: true });
2739
+ writeFileSync7(path, renderDescriptor(name, fields));
2740
+ log(`${pc6.green("create".padEnd(9))} ${relativePath}`);
2741
+ } else {
2742
+ try {
2743
+ const outcome = writeGenerated(path, renderFieldsBlock(fields), { force: options2.force });
2744
+ log(`${(outcome === "identical" ? pc6.dim : pc6.yellow)(outcome.padEnd(9))} ${relativePath}`);
2745
+ } catch (error) {
2746
+ if (error instanceof DriftError) {
2747
+ throw new Error(
2748
+ `The fields in ${relativePath} were edited by hand, so --fields won't overwrite them. Edit the descriptor directly and run \`flare gen resource ${name}\` (or \`flare sync-types\`), or pass --force.`
2749
+ );
2750
+ }
2751
+ throw error;
2752
+ }
2753
+ }
2754
+ }
2755
+ let written;
2756
+ let table;
2757
+ try {
2758
+ const all = await loadResources(appRoot);
2759
+ table = all.find(({ resource }) => resource.name === name).resource.table;
2760
+ ensureSupportFiles(appRoot, log);
2761
+ written = applyPlan(appRoot, planFiles(all), { force: options2.force });
2762
+ logResults(written, log);
2763
+ } catch (error) {
2764
+ const hint = exists ? "" : `
2765
+ (${relativePath} was written; fix it and run \`flare sync-types\`.)`;
2766
+ throw new Error(`${error instanceof Error ? error.message : String(error)}${hint}`);
2767
+ }
2768
+ const skipped = written.filter((r) => r.outcome.startsWith("skipped"));
2769
+ if (skipped.length) {
2770
+ log(pc6.yellow(`
2771
+ Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for details.`));
2772
+ }
2773
+ let migrations = [];
2774
+ if (!options2.skipMigration) {
2775
+ const result = await generateSchemaMigration(appRoot, `${exists ? "update" : "create"}_${table}`);
2776
+ migrations = result.files;
2777
+ for (const file of migrations) log(`${pc6.green("create".padEnd(9))} ${file}`);
2778
+ for (const repair of result.repairs) log(pc6.dim(`repaired ${repair}`));
2779
+ if (migrations.length) log(`
2780
+ Next: ${pc6.bold("flare migrate")} to apply it locally.`);
2781
+ else if (exists) log(pc6.dim("\nNo table changes, so no migration."));
2782
+ }
2783
+ const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
2784
+ return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
2785
+ }
2786
+
2787
+ // src/commands/gen-migration.ts
2788
+ import { existsSync as existsSync14, mkdirSync as mkdirSync7, writeFileSync as writeFileSync8 } from "fs";
2789
+ import { basename as basename2, join as join13 } from "path";
2790
+ import { snakeCase } from "@flaredev/core";
2791
+ import pc7 from "picocolors";
2792
+ var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
2793
+ -- Write SQL that undoes the up migration, e.g.:
2794
+ -- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
2795
+ -- While this file has no statements, rollback of ${up} is refused.
2796
+ `;
2797
+ async function genMigration(rawName, options2 = {}) {
2798
+ const log = options2.log ?? ((message) => console.log(message));
2799
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2800
+ const name = snakeCase(rawName);
2801
+ if (!name) throw new Error(`Invalid migration name "${rawName}". Use letters, digits, and underscores.`);
2802
+ const { files, repairs } = await generateSchemaMigration(appRoot, name, { custom: !options2.fromSchema });
2803
+ if (files.length === 0) {
2804
+ log(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
2805
+ return [];
2806
+ }
2807
+ for (const file of files) log(`${pc7.green("create".padEnd(9))} ${file}`);
2808
+ for (const repair of repairs) log(pc7.dim(`repaired ${repair}`));
2809
+ if (!options2.fromSchema) {
2810
+ const downDir = join13(appRoot, "migrations", "down");
2811
+ mkdirSync7(downDir, { recursive: true });
2812
+ for (const file of files) {
2813
+ const down = join13(downDir, basename2(file));
2814
+ if (existsSync14(down)) continue;
2815
+ writeFileSync8(down, DOWN_TEMPLATE(basename2(file)));
2816
+ log(`${pc7.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
2817
+ }
2818
+ log(`
2819
+ Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc7.bold("flare migrate")}.`);
2820
+ } else {
2821
+ log(`
2822
+ Next: ${pc7.bold("flare migrate")}.`);
2823
+ }
2824
+ return files;
2825
+ }
2826
+
2827
+ // src/commands/gen-policy.ts
2828
+ import { existsSync as existsSync15, mkdirSync as mkdirSync8, writeFileSync as writeFileSync9 } from "fs";
2829
+ import { dirname as dirname9, join as join14 } from "path";
2830
+ import { definePolicy } from "@flaredev/core";
2831
+ import pc8 from "picocolors";
2832
+ function parseRoles(input, what = "--roles") {
2833
+ const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
2834
+ if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
2835
+ return [...new Set(roles)];
2836
+ }
2837
+ async function genPolicy(rawName, options2) {
2838
+ const log = options2.log ?? ((message) => console.log(message));
2839
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2840
+ const name = resourceName(rawName);
2841
+ const resources = await loadResources(appRoot);
2842
+ if (!resources.some(({ resource }) => resource.name === name)) {
2843
+ const known = resources.map(({ resource }) => resource.name).join(", ") || "none";
2844
+ throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
2845
+ }
2846
+ const relative2 = policyPath(name);
2847
+ const path = join14(appRoot, relative2);
2848
+ const exists = existsSync15(path);
2849
+ let roles;
2850
+ let deleteRoles;
2851
+ if (options2.roles || !exists) {
2852
+ roles = parseRoles(options2.roles);
2853
+ deleteRoles = options2.deleteRoles ? parseRoles(options2.deleteRoles, "--delete-roles") : [roles[0]];
2854
+ definePolicy({ resource: name, read: roles, create: roles, update: roles, delete: deleteRoles });
2855
+ }
2856
+ if (roles && deleteRoles) {
2857
+ const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
2858
+ if (!exists) {
2859
+ mkdirSync8(dirname9(path), { recursive: true });
2860
+ writeFileSync9(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
2861
+ log(`${pc8.green("create".padEnd(9))} ${relative2}`);
2862
+ } else {
2863
+ try {
2864
+ const outcome = writeGenerated(path, block, { force: options2.force });
2865
+ log(`${(outcome === "identical" ? pc8.dim : pc8.yellow)(outcome.padEnd(9))} ${relative2}`);
2866
+ } catch (error) {
2867
+ if (error instanceof DriftError) {
2868
+ throw new Error(`The roles in ${relative2} were edited by hand, so --roles won't overwrite them. Edit the file directly, or pass --force.`);
2869
+ }
2870
+ throw error;
2871
+ }
2872
+ }
2873
+ }
2874
+ const policies = await loadPolicies(appRoot);
2875
+ logResults(applyPlan(appRoot, planFiles(resources, policies)), log);
2876
+ log(`
2877
+ ${pc8.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
2878
+ return { name, path: relative2 };
2879
+ }
2880
+
2881
+ // src/commands/migrate.ts
2882
+ import { existsSync as existsSync16, mkdtempSync, readFileSync as readFileSync10, rmSync, writeFileSync as writeFileSync10 } from "fs";
2883
+ import { tmpdir } from "os";
2884
+ import { join as join15 } from "path";
2885
+ import pc9 from "picocolors";
2886
+
2887
+ // src/generator/down.ts
2888
+ var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
2889
+ function splitStatements(sql2) {
2890
+ const statements = [];
2891
+ for (const chunk of sql2.split("--> statement-breakpoint")) {
2892
+ let current = "";
2893
+ let quote = null;
2894
+ for (let i = 0; i < chunk.length; i++) {
2895
+ const char = chunk[i];
2896
+ if (quote) {
2897
+ current += char;
2898
+ if (char === quote) quote = null;
2899
+ continue;
2900
+ }
2901
+ if (char === "-" && chunk[i + 1] === "-") {
2902
+ const end = chunk.indexOf("\n", i);
2903
+ i = end === -1 ? chunk.length : end;
2904
+ current += "\n";
2905
+ continue;
2906
+ }
2907
+ if (char === "'" || char === '"' || char === "`") quote = char;
2908
+ if (char === ";") {
2909
+ statements.push(current);
2910
+ current = "";
2911
+ continue;
2912
+ }
2913
+ current += char;
2914
+ }
2915
+ statements.push(current);
2916
+ }
2917
+ return statements.map((statement) => statement.trim()).filter(Boolean);
2918
+ }
2919
+ function inverse(statement) {
2920
+ const s = statement.replace(/\s+/g, " ").trim();
2921
+ let m;
2922
+ if (m = new RegExp(`^CREATE TABLE (?:IF NOT EXISTS )?${IDENT}`, "i").exec(s)) return `DROP TABLE IF EXISTS ${m[1]};`;
2923
+ if (m = new RegExp(`^CREATE (?:UNIQUE )?INDEX (?:IF NOT EXISTS )?${IDENT}`, "i").exec(s)) return `DROP INDEX IF EXISTS ${m[1]};`;
2924
+ if (m = new RegExp(`^CREATE VIEW (?:IF NOT EXISTS )?${IDENT}`, "i").exec(s)) return `DROP VIEW IF EXISTS ${m[1]};`;
2925
+ if (m = new RegExp(`^CREATE TRIGGER (?:IF NOT EXISTS )?${IDENT}`, "i").exec(s)) return `DROP TRIGGER IF EXISTS ${m[1]};`;
2926
+ if (m = new RegExp(`^ALTER TABLE ${IDENT} RENAME TO ${IDENT}$`, "i").exec(s)) return `ALTER TABLE ${m[2]} RENAME TO ${m[1]};`;
2927
+ if (m = new RegExp(`^ALTER TABLE ${IDENT} RENAME COLUMN ${IDENT} TO ${IDENT}$`, "i").exec(s)) {
2928
+ return `ALTER TABLE ${m[1]} RENAME COLUMN ${m[3]} TO ${m[2]};`;
2929
+ }
2930
+ if (m = new RegExp(`^ALTER TABLE ${IDENT} ADD (?:COLUMN )?${IDENT}(.*)$`, "i").exec(s)) {
2931
+ if (/\b(REFERENCES|UNIQUE|PRIMARY KEY)\b/i.test(m[3])) return null;
2932
+ return `ALTER TABLE ${m[1]} DROP COLUMN ${m[2]};`;
2933
+ }
2934
+ return null;
2935
+ }
2936
+ function deriveDown(upSql) {
2937
+ const statements = splitStatements(upSql);
2938
+ if (statements.length === 0) return { reversible: false, reason: "the migration is empty" };
2939
+ const down = [];
2940
+ for (const statement of statements) {
2941
+ const reversed = inverse(statement);
2942
+ if (!reversed) {
2943
+ const preview = statement.replace(/\s+/g, " ").slice(0, 80);
2944
+ return { reversible: false, reason: `can't automatically reverse: ${preview}${statement.length > 80 ? "\u2026" : ""}` };
2945
+ }
2946
+ down.unshift(reversed);
2947
+ }
2948
+ return { reversible: true, sql: `${down.join("\n")}
2949
+ ` };
2950
+ }
2951
+
2952
+ // src/commands/migrate.ts
2953
+ function context(options2) {
2954
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
2955
+ const databases = readD1Databases(appRoot);
2956
+ if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
2957
+ let db;
2958
+ if (options2.database) {
2959
+ const match = databases.find((candidate) => candidate.binding === options2.database || candidate.database_name === options2.database);
2960
+ if (!match) throw new Error(`No D1 database "${options2.database}". Available: ${databases.map((d) => d.binding).join(", ")}.`);
2961
+ db = match;
2962
+ } else if (databases.length === 1) {
2963
+ db = databases[0];
2964
+ } else {
2965
+ throw new Error(`Several D1 databases; pick one with --database (${databases.map((d) => d.binding).join(", ")}).`);
2966
+ }
2967
+ const target = [options2.remote ? "--remote" : "--local", ...options2.env ? ["--env", options2.env] : []];
2968
+ return {
2969
+ appRoot,
2970
+ db,
2971
+ target,
2972
+ where: options2.remote ? "remote" : "local",
2973
+ wrangler: resolveBin(appRoot, "wrangler", "wrangler"),
2974
+ log: options2.log ?? ((message) => console.log(message))
2975
+ };
2976
+ }
2977
+ async function migrate(options2 = {}) {
2978
+ const { appRoot, db, target, wrangler } = context(options2);
2979
+ const result = await runWrangler(wrangler, ["d1", "migrations", "apply", db.binding, ...target], appRoot);
2980
+ return result.code;
2981
+ }
2982
+ async function appliedMigrations(options2, limit) {
2983
+ const { appRoot, db, target, wrangler } = context(options2);
2984
+ const result = await runWrangler(
2985
+ wrangler,
2986
+ ["d1", "execute", db.binding, ...target, "--json", "--command", `SELECT id, name FROM d1_migrations ORDER BY id DESC LIMIT ${limit}`],
2987
+ appRoot,
2988
+ { capture: true }
2989
+ );
2990
+ if (result.code !== 0) {
2991
+ if (/no such table: d1_migrations/i.test(result.output)) return [];
2992
+ throw new Error(`Could not read applied migrations:
2993
+ ${result.output.trim()}`);
2994
+ }
2995
+ const [first] = extractJson(result.output);
2996
+ return first?.results ?? [];
2997
+ }
2998
+ async function rollback(options2 = {}) {
2999
+ const steps = options2.steps ?? 1;
3000
+ if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
3001
+ const { appRoot, db, target, where, wrangler, log } = context(options2);
3002
+ const migrationsDir = join15(appRoot, db.migrations_dir ?? "migrations");
3003
+ const applied = await appliedMigrations(options2, steps);
3004
+ if (applied.length === 0) {
3005
+ log("Nothing to roll back.");
3006
+ return 0;
3007
+ }
3008
+ const plan = [];
3009
+ for (const { name } of applied) {
3010
+ if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
3011
+ const handWritten = join15(migrationsDir, "down", name);
3012
+ if (existsSync16(handWritten)) {
3013
+ const sql2 = readFileSync10(handWritten, "utf8");
3014
+ if (splitStatements(sql2).length === 0) {
3015
+ throw new Error(`Can't roll back ${name}: migrations/down/${name} has no SQL statements yet. Write the rollback SQL, then run the rollback again. Nothing was changed.`);
3016
+ }
3017
+ plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
3018
+ continue;
3019
+ }
3020
+ const upPath = join15(migrationsDir, name);
3021
+ if (!existsSync16(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
3022
+ const down = deriveDown(readFileSync10(upPath, "utf8"));
3023
+ if (!down.reversible) {
3024
+ throw new Error(
3025
+ `Can't roll back ${name}: ${down.reason}
3026
+ Write the reverse SQL to migrations/down/${name} and run the rollback again. Nothing was changed.`
3027
+ );
3028
+ }
3029
+ plan.push({ name, sql: down.sql, source: "derived" });
3030
+ }
3031
+ log(`${pc9.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
3032
+ for (const step of plan) {
3033
+ log(` ${step.name} ${pc9.dim(`(${step.source})`)}`);
3034
+ for (const line of step.sql.trim().split("\n")) log(pc9.dim(` ${line}`));
3035
+ }
3036
+ if (options2.remote && !options2.yes) {
3037
+ log(pc9.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
3038
+ return 1;
3039
+ }
3040
+ const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
3041
+ DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
3042
+ const dir = mkdtempSync(join15(tmpdir(), "flare-rollback-"));
3043
+ try {
3044
+ const file = join15(dir, "rollback.sql");
3045
+ writeFileSync10(file, script);
3046
+ const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
3047
+ if (result.code !== 0) throw new Error(`Rollback failed:
3048
+ ${result.output.trim()}`);
3049
+ } finally {
3050
+ rmSync(dir, { recursive: true, force: true });
3051
+ }
3052
+ log(pc9.green(`
3053
+ Rolled back ${plan.map((step) => step.name).join(", ")}.`));
3054
+ return 0;
3055
+ }
3056
+
3057
+ // src/commands/rm.ts
3058
+ import { existsSync as existsSync17, readdirSync as readdirSync7, readFileSync as readFileSync11, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
3059
+ import { dirname as dirname10, join as join16 } from "path";
3060
+ import { relationGraph as relationGraph3 } from "@flaredev/core";
3061
+ import pc10 from "picocolors";
3062
+ var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
3063
+ function handWrittenReason(source, header, options2 = {}) {
3064
+ const parts = splitMarkers(source);
3065
+ if (!parts) return "has no generated block (entirely hand-written)";
3066
+ if (options2.descriptorWrapper) {
3067
+ if (squash(parts.before) !== squash(options2.descriptorWrapper.before) || squash(parts.after) !== squash(options2.descriptorWrapper.after)) {
3068
+ return "has hand-written changes outside the fields block";
3069
+ }
3070
+ return null;
3071
+ }
3072
+ const outside = squash(parts.before.replace(header, "") + parts.after);
3073
+ if (outside) return "has hand-written code outside the generated block";
3074
+ if (parts.hash && hashBlock(parts.block) !== parts.hash) return "has hand edits inside the generated block";
3075
+ return null;
3076
+ }
3077
+ function removeEmptyDirs(appRoot, start) {
3078
+ let dir = dirname10(join16(appRoot, start));
3079
+ const roots = [join16(appRoot, "app", "api"), join16(appRoot, "app", "admin")];
3080
+ const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
3081
+ while (inside() && existsSync17(dir) && readdirSync7(dir).length === 0) {
3082
+ rmdirSync(dir);
3083
+ dir = dirname10(dir);
3084
+ }
3085
+ }
3086
+ async function rmResource(rawName, options2 = {}) {
3087
+ const log = options2.log ?? ((message) => console.log(message));
3088
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3089
+ const name = resourceName(rawName);
3090
+ const all = await loadResources(appRoot);
3091
+ const entry = all.find(({ resource }) => resource.name === name);
3092
+ const remaining = all.filter(({ resource }) => resource.name !== name);
3093
+ if (entry) {
3094
+ const graph = relationGraph3(all.map(({ resource }) => resource));
3095
+ const dependents = [];
3096
+ for (const other of remaining) {
3097
+ const relations = graph.byResource[other.resource.name];
3098
+ for (const relation of relations.belongsTo) if (relation.target === name) dependents.push(`${other.resource.name}.${relation.key}`);
3099
+ for (const relation of relations.hasMany) if (relation.target === name) dependents.push(`${other.resource.name}.${relation.key}`);
3100
+ }
3101
+ for (const pending of graph.pending) if (pending.target === name) dependents.push(`${pending.resource}.${pending.key}`);
3102
+ if (dependents.length) {
3103
+ throw new Error(`Can't remove ${name}: ${dependents.join(", ")} still reference${dependents.length === 1 ? "s" : ""} it. Remove those fields first.`);
3104
+ }
3105
+ }
3106
+ const owned = /* @__PURE__ */ new Set();
3107
+ if (entry) {
3108
+ owned.add(entry.file);
3109
+ for (const file of planFiles(all)) {
3110
+ if (existsSync17(join16(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
3111
+ }
3112
+ }
3113
+ for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
3114
+ const policy = (await loadPolicies(appRoot)).find(({ policy: policy2 }) => policy2.resource === name);
3115
+ if (policy) owned.add(policy.file);
3116
+ if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
3117
+ const findings = [];
3118
+ for (const path of owned) {
3119
+ const source = readFileSync11(join16(appRoot, path), "utf8");
3120
+ let wrapper;
3121
+ if (entry && path === entry.file) {
3122
+ const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
3123
+ wrapper = { before: standard.before, after: standard.after };
3124
+ }
3125
+ if (policy && path === policy.file) {
3126
+ const standard = splitMarkers(renderPolicy(name, { read: [], create: [], update: [], delete: [] }));
3127
+ wrapper = { before: standard.before, after: standard.after };
3128
+ }
3129
+ const reason = handWrittenReason(source, resourceHeader(name), { descriptorWrapper: wrapper });
3130
+ if (reason) findings.push({ path, reason });
3131
+ }
3132
+ if (findings.length && !options2.force) {
3133
+ const list2 = findings.map((finding) => ` ${finding.path}: ${finding.reason}`).join("\n");
3134
+ throw new Error(`Refusing to remove ${name}; these files contain hand-written code:
3135
+ ${list2}
3136
+ Move what you want to keep, or pass --force to delete anyway.`);
3137
+ }
3138
+ const removed = [...owned].sort();
3139
+ for (const path of removed) {
3140
+ unlinkSync2(join16(appRoot, path));
3141
+ removeEmptyDirs(appRoot, path);
3142
+ log(`${pc10.red("remove".padEnd(9))} ${path}`);
3143
+ }
3144
+ logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log);
3145
+ const table = entry ? tableExport(entry.resource) : void 0;
3146
+ if (table && existsSync17(join16(appRoot, "seeds"))) {
3147
+ const seeds = readdirSync7(join16(appRoot, "seeds")).filter(
3148
+ (file) => new RegExp(`\\b${table}\\b`).test(readFileSync11(join16(appRoot, "seeds", file), "utf8"))
3149
+ );
3150
+ if (seeds.length) log(pc10.yellow(`
3151
+ These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
3152
+ }
3153
+ let migrations = [];
3154
+ if (!options2.skipMigration && entry) {
3155
+ const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
3156
+ migrations = result.files;
3157
+ for (const file of migrations) log(`${pc10.green("create".padEnd(9))} ${file}`);
3158
+ for (const repair of result.repairs) log(pc10.dim(`repaired ${repair}`));
3159
+ if (migrations.length) log(`
3160
+ Next: ${pc10.bold("flare migrate")} to drop the table locally.`);
3161
+ }
3162
+ return { removed, migrations };
3163
+ }
3164
+
3165
+ // src/commands/role.ts
3166
+ import { humanize } from "@flaredev/core";
3167
+ import pc11 from "picocolors";
3168
+ var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
3169
+ function roleInsertSql(name, label) {
3170
+ if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
3171
+ if (/['"\\]/.test(label) || label.length > 60) throw new Error(`Invalid label "${label}". Keep it short and avoid quotes.`);
3172
+ return `INSERT INTO "role" (name, label) VALUES ('${name}', '${label}') ON CONFLICT(name) DO UPDATE SET label = excluded.label RETURNING name, label`;
3173
+ }
3174
+ function context2(options2) {
3175
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3176
+ const db = readD1Databases(appRoot)[0];
3177
+ if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
3178
+ return {
3179
+ appRoot,
3180
+ binding: db.binding,
3181
+ wrangler: resolveBin(appRoot, "wrangler", "wrangler"),
3182
+ target: [options2.remote ? "--remote" : "--local", ...options2.env ? ["--env", options2.env] : []],
3183
+ log: options2.log ?? ((message) => console.log(message))
3184
+ };
3185
+ }
3186
+ async function listRoles(options2 = {}) {
3187
+ const { appRoot, binding, wrangler, target } = context2(options2);
3188
+ const result = await runWrangler(wrangler, ["d1", "execute", binding, ...target, "--json", "--command", "SELECT name FROM role ORDER BY name"], appRoot, {
3189
+ capture: true
3190
+ });
3191
+ if (result.code !== 0) {
3192
+ if (/no such table: role/i.test(result.output)) return null;
3193
+ throw new Error(`Could not read roles:
3194
+ ${result.output.trim()}`);
3195
+ }
3196
+ const [first] = extractJson(result.output);
3197
+ return (first?.results ?? []).map((row) => row.name);
3198
+ }
3199
+ async function addRole(name, options2 = {}) {
3200
+ const { appRoot, binding, wrangler, target, log } = context2(options2);
3201
+ const sql2 = roleInsertSql(name, options2.label ?? humanize(name));
3202
+ const result = await runWrangler(wrangler, ["d1", "execute", binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
3203
+ if (result.code !== 0) {
3204
+ if (/no such table: role/i.test(result.output)) {
3205
+ throw new Error("No role table yet. Run `flare migrate` first (it's part of the initial migration).");
3206
+ }
3207
+ throw new Error(`Could not add the role:
3208
+ ${result.output.trim()}`);
3209
+ }
3210
+ const [first] = extractJson(result.output);
3211
+ const added = first?.results?.[0];
3212
+ log(pc11.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
3213
+ log(pc11.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
3214
+ return 0;
3215
+ }
3216
+
3217
+ // src/commands/seed.ts
3218
+ import { existsSync as existsSync18, mkdirSync as mkdirSync9, readdirSync as readdirSync8, writeFileSync as writeFileSync11 } from "fs";
3219
+ import { createRequire } from "module";
3220
+ import { join as join17 } from "path";
3221
+ import { pathToFileURL } from "url";
3222
+ import { kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
3223
+ import { createJiti as createJiti4 } from "jiti";
3224
+ import pc12 from "picocolors";
3225
+ var SEEDS_DIR = "seeds";
3226
+ var LOCAL_STATE = ".wrangler/state/v3";
3227
+ function seedFiles(appRoot, names) {
3228
+ const dir = join17(appRoot, SEEDS_DIR);
3229
+ const all = existsSync18(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
3230
+ if (!names?.length) return all;
3231
+ return names.map((name) => {
3232
+ const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
3233
+ if (!all.includes(file)) {
3234
+ throw new Error(`No seed "${name}" in ${SEEDS_DIR}/. Available: ${all.map((f) => f.replace(".seed.ts", "")).join(", ") || "none"}.`);
3235
+ }
3236
+ return file;
3237
+ });
3238
+ }
3239
+ async function runSeeds(options2 = {}) {
3240
+ const log = options2.log ?? ((message) => console.log(message));
3241
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3242
+ const files = seedFiles(appRoot, options2.names);
3243
+ if (files.length === 0) {
3244
+ log(`No seeds found. Create one with ${pc12.bold("flare seed:make <name>")}.`);
3245
+ return;
3246
+ }
3247
+ const jiti = createJiti4(join17(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
3248
+ const appRequire = createRequire(join17(appRoot, "package.json"));
3249
+ const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
3250
+ const { drizzle } = await jiti.import("drizzle-orm/d1");
3251
+ const schema = await jiti.import(join17(appRoot, "db/schema.ts"));
3252
+ const { env, dispose } = await wrangler.getPlatformProxy({
3253
+ configPath: join17(appRoot, "wrangler.jsonc"),
3254
+ persist: { path: join17(appRoot, LOCAL_STATE) }
3255
+ });
3256
+ try {
3257
+ const bindings = env;
3258
+ if (!bindings.DB) throw new Error("No DB binding found in wrangler.jsonc.");
3259
+ const db = drizzle(bindings.DB, { schema });
3260
+ for (const file of files) {
3261
+ const mod = await jiti.import(join17(appRoot, SEEDS_DIR, file));
3262
+ if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
3263
+ log(`${pc12.cyan("seed")} ${file}`);
3264
+ await mod.default({ db, env: bindings, log: (message) => log(` ${message}`) });
3265
+ }
3266
+ log(pc12.green(`
3267
+ Ran ${files.length} seed(s) against the local database.`));
3268
+ } finally {
3269
+ await dispose();
3270
+ }
3271
+ }
3272
+ function sampleValue(key, def, row) {
3273
+ switch (def.kind) {
3274
+ case "string":
3275
+ if (def.format === "email") return `\`${kebabCase3(key).replace(/-?email$/, "") || "user"}\${i}@example.com\``;
3276
+ if (def.format === "url") return `\`https://example.com/\${i}\``;
3277
+ return `\`${def.label} \${i}\``;
3278
+ case "text":
3279
+ return `"Lorem ipsum dolor sit amet."`;
3280
+ case "int":
3281
+ return "i";
3282
+ case "float":
3283
+ return "i * 1.5";
3284
+ case "boolean":
3285
+ return "i % 2 === 0";
3286
+ case "date":
3287
+ return "`2026-01-0${i}`";
3288
+ case "datetime":
3289
+ return "new Date().toISOString()";
3290
+ case "enum":
3291
+ return JSON.stringify(def.options[row % def.options.length]);
3292
+ case "file":
3293
+ return void 0;
3294
+ case "belongsTo":
3295
+ return void 0;
3296
+ }
3297
+ }
3298
+ function renderSeed(resource) {
3299
+ if (!resource) {
3300
+ return `import { defineSeed } from "@flaredev/core";
3301
+
3302
+ export default defineSeed(async ({ db, log }) => {
3303
+ // Insert rows with Drizzle, e.g.:
3304
+ // await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
3305
+ log("nothing to seed yet");
3306
+ });
3307
+ `;
3308
+ }
3309
+ const table = tableExport(resource);
3310
+ const lines = [];
3311
+ const todos = [];
3312
+ for (const [key, def] of storedFields2(resource)) {
3313
+ const value = sampleValue(key, def, 0);
3314
+ if (value !== void 0) lines.push(` ${key}: ${value},`);
3315
+ else if (def.required) todos.push(` // ${key}: TODO (${def.kind === "belongsTo" ? `id of an existing ${def.target}` : "R2 object key"}),`);
3316
+ }
3317
+ return `import { defineSeed } from "@flaredev/core";
3318
+ import { ${table} } from "@/db/schema";
3319
+
3320
+ export default defineSeed(async ({ db, log }) => {
3321
+ const rows = [1, 2, 3].map((i) => ({
3322
+ ${[...lines, ...todos].join("\n")}
3323
+ }));
3324
+ await db.insert(${table}).values(rows);
3325
+ log(\`inserted \${rows.length} ${resource.pluralLabel.toLowerCase()}\`);
3326
+ });
3327
+ `;
3328
+ }
3329
+ async function makeSeed(rawName, options2 = {}) {
3330
+ const log = options2.log ?? ((message) => console.log(message));
3331
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3332
+ const name = kebabCase3(rawName);
3333
+ if (!name) throw new Error(`Invalid seed name "${rawName}".`);
3334
+ const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
3335
+ const path = join17(appRoot, relative2);
3336
+ if (existsSync18(path)) throw new Error(`${relative2} already exists.`);
3337
+ const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
3338
+ let resource;
3339
+ if (options2.resource) {
3340
+ resource = resources.find((r) => r.name.toLowerCase() === options2.resource.toLowerCase());
3341
+ if (!resource) throw new Error(`No resource "${options2.resource}". Available: ${resources.map((r) => r.name).join(", ") || "none"}.`);
3342
+ } else {
3343
+ resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
3344
+ }
3345
+ mkdirSync9(join17(appRoot, SEEDS_DIR), { recursive: true });
3346
+ writeFileSync11(path, renderSeed(resource));
3347
+ log(`${pc12.green("create".padEnd(9))} ${relative2}`);
3348
+ log(`
3349
+ Run it with ${pc12.bold(`flare seed ${name}`)}.`);
3350
+ return relative2;
3351
+ }
3352
+
3353
+ // src/commands/billing-sync.ts
3354
+ import { createRequire as createRequire2 } from "module";
3355
+ import { existsSync as existsSync19, mkdtempSync as mkdtempSync2, readFileSync as readFileSync12, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
3356
+ import { tmpdir as tmpdir2 } from "os";
3357
+ import { join as join18 } from "path";
3358
+ import { pathToFileURL as pathToFileURL2 } from "url";
3359
+ import { kebabCase as kebabCase4 } from "@flaredev/core";
3360
+ import pc13 from "picocolors";
3361
+ function stripeKeys(appRoot) {
3362
+ const map = {};
3363
+ const path = join18(appRoot, ".dev.vars");
3364
+ if (existsSync19(path)) {
3365
+ for (const line of readFileSync12(path, "utf8").split(/\r?\n/)) {
3366
+ const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
3367
+ if (match) map[match[1]] = match[2].trim();
3368
+ }
3369
+ }
3370
+ for (const key of ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET"]) {
3371
+ if (process.env[key]) map[key] = process.env[key];
3372
+ }
3373
+ return map;
3374
+ }
3375
+ function planRows(catalog) {
3376
+ const rows = [];
3377
+ const skipped = [];
3378
+ for (const { product, prices } of catalog) {
3379
+ const usable = prices.filter((price) => {
3380
+ const ok = price.unit_amount !== null && (price.type === "one_time" || price.recurring !== null && ["month", "year"].includes(price.recurring.interval) && (price.recurring.interval_count ?? 1) === 1);
3381
+ if (!ok) skipped.push(`${product.name} (${price.id})`);
3382
+ return ok;
3383
+ });
3384
+ const currencies = new Set(usable.map((price) => price.currency));
3385
+ const base = product.metadata?.slug || kebabCase4(product.name);
3386
+ for (const price of usable) {
3387
+ const interval = price.recurring?.interval ?? null;
3388
+ const parts = [base];
3389
+ if (usable.length > 1) parts.push(interval ?? "once");
3390
+ if (currencies.size > 1) parts.push(price.currency);
3391
+ const label = usable.length > 1 ? ` (${interval ? `${interval}ly` : "one-time"}${currencies.size > 1 ? `, ${price.currency.toUpperCase()}` : ""})` : "";
3392
+ rows.push({
3393
+ name: `${product.name}${label}`,
3394
+ slug: parts.join("-"),
3395
+ description: product.description ?? null,
3396
+ stripeProductId: product.id,
3397
+ stripePriceId: price.id,
3398
+ amount: price.unit_amount ?? 0,
3399
+ currency: price.currency,
3400
+ interval,
3401
+ sort: Number(product.metadata?.sort ?? 0) || 0
3402
+ });
3403
+ }
3404
+ }
3405
+ return { rows, skipped };
3406
+ }
3407
+ var sql = (value) => value === null ? "NULL" : typeof value === "number" ? String(Math.trunc(value)) : `'${value.replace(/'/g, "''")}'`;
3408
+ function renderSyncSql(rows, newId = () => crypto.randomUUID()) {
3409
+ const live = rows.map((row) => sql(row.stripePriceId)).join(", ");
3410
+ const slugs = rows.map((row) => sql(row.slug)).join(", ");
3411
+ const retire = `UPDATE plans SET active = 0` + (slugs ? `, slug = CASE WHEN slug IN (${slugs}) THEN slug || '-' || substr(coalesce(stripe_price_id, id), -8) ELSE slug END` : "") + ` WHERE stripe_price_id IS NOT NULL${live ? ` AND stripe_price_id NOT IN (${live})` : ""};`;
3412
+ const upserts = rows.map(
3413
+ (row) => `INSERT INTO plans (id, name, slug, description, stripe_product_id, stripe_price_id, amount, currency, \`interval\`, active, sort) VALUES (${[newId(), row.name, row.slug, row.description, row.stripeProductId, row.stripePriceId, row.amount, row.currency, row.interval].map(sql).join(", ")}, 1, ${sql(row.sort)}) ON CONFLICT(stripe_price_id) DO UPDATE SET name = excluded.name, slug = excluded.slug, description = excluded.description, stripe_product_id = excluded.stripe_product_id, amount = excluded.amount, currency = excluded.currency, \`interval\` = excluded.\`interval\`, active = 1, sort = excluded.sort;`
3414
+ );
3415
+ return [retire, ...upserts].join("\n") + "\n";
3416
+ }
3417
+ async function syncPortalConfiguration(stripe, rows, appName) {
3418
+ const byProduct = /* @__PURE__ */ new Map();
3419
+ for (const row of rows) if (row.interval) byProduct.set(row.stripeProductId, [...byProduct.get(row.stripeProductId) ?? [], row.stripePriceId]);
3420
+ if (byProduct.size === 0) return null;
3421
+ const settings = {
3422
+ business_profile: { headline: "Manage your subscription" },
3423
+ features: {
3424
+ subscription_update: {
3425
+ enabled: true,
3426
+ default_allowed_updates: ["price"],
3427
+ proration_behavior: "create_prorations",
3428
+ products: [...byProduct].map(([product, prices]) => ({ product, prices }))
3429
+ },
3430
+ subscription_cancel: { enabled: true, mode: "at_period_end" },
3431
+ payment_method_update: { enabled: true },
3432
+ invoice_history: { enabled: true }
3433
+ },
3434
+ metadata: { flare_app: appName }
3435
+ };
3436
+ const existing = (await stripe.billingPortal.configurations.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e3 })).find(
3437
+ (configuration) => configuration.metadata?.flare_app === appName
3438
+ );
3439
+ return existing ? (await stripe.billingPortal.configurations.update(existing.id, settings)).id : (await stripe.billingPortal.configurations.create(settings)).id;
3440
+ }
3441
+ async function syncPlans(options2 = {}) {
3442
+ const log = options2.log ?? ((message) => console.log(message));
3443
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3444
+ const key = stripeKeys(appRoot).STRIPE_SECRET_KEY;
3445
+ if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
3446
+ const db = readD1Databases(appRoot)[0];
3447
+ if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
3448
+ const appRequire = createRequire2(join18(appRoot, "package.json"));
3449
+ const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
3450
+ const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
3451
+ const appName = JSON.parse(readFileSync12(join18(appRoot, "package.json"), "utf8")).name ?? "app";
3452
+ const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
3453
+ const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
3454
+ if (products.length === 0) {
3455
+ throw new Error(
3456
+ `No active Products in Stripe have metadata flare_app=${appName} (${everything.length} other active Products were ignored). Tag this app's Products, or pass --all to import every Product.`
3457
+ );
3458
+ }
3459
+ const catalog = await Promise.all(
3460
+ products.map(async (product) => ({ product, prices: await stripe.prices.list({ product: product.id, active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 }) }))
3461
+ );
3462
+ const { rows, skipped } = planRows(catalog);
3463
+ const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
3464
+ const target = [options2.remote ? "--remote" : "--local", ...options2.env ? ["--env", options2.env] : []];
3465
+ const existingResult = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--json", "--command", "SELECT stripe_price_id FROM plans"], appRoot, { capture: true });
3466
+ if (existingResult.code !== 0) {
3467
+ if (/no such table: plans/i.test(existingResult.output)) throw new Error(`The plans table doesn't exist yet. Run \`flare migrate${options2.remote ? " --remote" : ""}\` first.`);
3468
+ throw new Error(`Could not read plans:
3469
+ ${existingResult.output.trim()}`);
3470
+ }
3471
+ const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
3472
+ const dir = mkdtempSync2(join18(tmpdir2(), "flare-sync-"));
3473
+ try {
3474
+ const file = join18(dir, "sync-plans.sql");
3475
+ writeFileSync12(file, renderSyncSql(rows));
3476
+ const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
3477
+ if (result.code !== 0) throw new Error(`Could not write plans:
3478
+ ${result.output.trim()}`);
3479
+ } finally {
3480
+ rmSync2(dir, { recursive: true, force: true });
3481
+ }
3482
+ let created = 0;
3483
+ for (const row of rows) {
3484
+ const isNew = !known.has(row.stripePriceId);
3485
+ if (isNew) created++;
3486
+ const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
3487
+ log(`${(isNew ? pc13.green("create") : pc13.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc13.dim(price)}`);
3488
+ }
3489
+ for (const name of skipped) log(pc13.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
3490
+ const configuration = await syncPortalConfiguration(stripe, rows, appName);
3491
+ if (configuration) log(`${pc13.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
3492
+ log(pc13.green(`
3493
+ ${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
3494
+ return { created, updated: rows.length - created, skipped };
3495
+ }
3496
+
3497
+ // src/commands/sync.ts
3498
+ import { existsSync as existsSync20 } from "fs";
3499
+ import { join as join19 } from "path";
3500
+ import pc14 from "picocolors";
3501
+ async function syncTypes(options2 = {}) {
3502
+ const log = options2.log ?? ((message) => console.log(message));
3503
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3504
+ const all = await loadResources(appRoot);
3505
+ const policies = await loadPolicies(appRoot);
3506
+ const tracksPolicies = policies.length > 0 || existsSync20(join19(appRoot, "policies/index.ts"));
3507
+ const plan = planFiles(all, tracksPolicies ? policies : void 0);
3508
+ if (!options2.check) ensureSupportFiles(appRoot, log);
3509
+ const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
3510
+ logResults(results, log);
3511
+ const drift = results.filter((r) => r.outcome === "skipped-drift");
3512
+ const unmarked = results.filter((r) => r.outcome === "skipped-unmarked");
3513
+ const changed = results.filter((r) => r.outcome === "create" || r.outcome === "update");
3514
+ const orphans = findOrphans(appRoot, all);
3515
+ if (drift.length) {
3516
+ log(
3517
+ `
3518
+ ${pc14.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
3519
+ Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
3520
+ );
3521
+ }
3522
+ if (unmarked.length) {
3523
+ log(`
3524
+ ${pc14.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
3525
+ }
3526
+ if (orphans.length) {
3527
+ const resources = [...new Set(orphans.map((o) => o.resource))];
3528
+ log(`
3529
+ ${pc14.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
3530
+ for (const orphan of orphans) log(` ${orphan.path}`);
3531
+ log(
3532
+ `If the resource was deleted, remove them with ${pc14.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
3533
+ );
3534
+ }
3535
+ const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
3536
+ if (tablesChanged && !options2.check) {
3537
+ log(`
3538
+ ${pc14.cyan("Tables changed.")} Create a migration with ${pc14.bold("flare gen migration <name> --from-schema")}, then ${pc14.bold("flare migrate")}.`);
3539
+ }
3540
+ const problems = drift.length + unmarked.length + orphans.length;
3541
+ if (options2.check) {
3542
+ const stale = changed.length;
3543
+ if (stale === 0 && problems === 0) log(pc14.green("Generated files are in sync with the resource descriptors."));
3544
+ else log(`
3545
+ ${pc14.red("Out of sync:")} ${stale} file(s) would change. Run ${pc14.bold("flare sync-types")}.`);
3546
+ return stale + problems > 0 ? 1 : 0;
3547
+ }
3548
+ if (changed.length === 0 && problems === 0) log(pc14.green("Everything is in sync."));
3549
+ return problems > 0 ? 1 : 0;
3550
+ }
3551
+
3552
+ // src/commands/user-role.ts
3553
+ import pc15 from "picocolors";
3554
+ var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
3555
+ var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
3556
+ function userRoleSql(email, role) {
3557
+ if (!EMAIL.test(email)) throw new Error(`"${email}" doesn't look like an email address.`);
3558
+ if (!ROLE.test(role)) throw new Error(`Invalid role "${role}". Use lowercase letters, digits, _ or -, e.g. admin or staff.`);
3559
+ return `UPDATE "user" SET role = '${role}', updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE lower(email) = lower('${email}') RETURNING email, role`;
3560
+ }
3561
+ async function setUserRole(email, role, options2 = {}) {
3562
+ const log = options2.log ?? ((message) => console.log(message));
3563
+ const appRoot = findAppRoot(options2.cwd ?? process.cwd());
3564
+ const sql2 = userRoleSql(email, role);
3565
+ const roles = await listRoles(options2);
3566
+ if (roles && !roles.includes(role)) {
3567
+ log(pc15.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
3568
+ log(pc15.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
3569
+ return 1;
3570
+ }
3571
+ const db = readD1Databases(appRoot)[0];
3572
+ if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
3573
+ const target = [options2.remote ? "--remote" : "--local", ...options2.env ? ["--env", options2.env] : []];
3574
+ const result = await runWrangler(resolveBin(appRoot, "wrangler", "wrangler"), ["d1", "execute", db.binding, ...target, "--json", "--command", sql2], appRoot, {
3575
+ capture: true
3576
+ });
3577
+ if (result.code !== 0) throw new Error(`Could not update the user:
3578
+ ${result.output.trim()}`);
3579
+ const [first] = extractJson(result.output);
3580
+ const updated = first?.results ?? [];
3581
+ if (updated.length === 0) {
3582
+ log(pc15.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
3583
+ return 1;
3584
+ }
3585
+ log(pc15.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
3586
+ return 0;
3587
+ }
3588
+
3589
+ // src/cli.ts
3590
+ function createCli() {
3591
+ const cli = cac("flare");
3592
+ cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--auth-providers <list>", "OAuth providers to scaffold, comma-separated (google, github)").option("--skip-install", "Write files without installing dependencies").example("flare create shop --auth-providers google,github").action((dir, options2) => {
3593
+ const install = !options2.skipInstall;
3594
+ const result = createApp(dir, { pm: options2.pm, install, authProviders: options2.authProviders });
3595
+ printNextSteps(result, install);
3596
+ });
3597
+ 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)"').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(
3598
+ async (generator, name, options2) => {
3599
+ if (generator === "billing")
3600
+ return genBilling({ provider: options2.provider, mode: options2.mode, force: options2.force, skipInstall: options2.skipInstall, skipMigration: options2.skipMigration });
3601
+ if (generator === "security") return genSecurity({ force: options2.force, skipMigration: options2.skipMigration });
3602
+ if (["resource", "migration", "policy"].includes(generator) && !name) {
3603
+ throw new Error(`flare gen ${generator} needs a name, e.g. flare gen ${generator} ${generator === "migration" ? "add_phone_to_contacts" : "Contact"}`);
3604
+ }
3605
+ if (generator === "resource") return genResource(name, { fields: options2.fields, force: options2.force });
3606
+ if (generator === "migration") return genMigration(name, { fromSchema: options2.fromSchema });
3607
+ if (generator === "policy") return genPolicy(name, { roles: options2.roles, deleteRoles: options2.deleteRoles, force: options2.force });
3608
+ throw new Error(`Unknown generator "${generator}". Available: resource, migration, policy, billing, security.`);
3609
+ }
3610
+ );
3611
+ cli.command("rm <kind> <name>", "Remove generated code. Kinds: resource").option("--force", "Delete even when files contain hand-written code").example("flare rm resource Tag").action(async (kind, name, options2) => {
3612
+ if (kind !== "resource") throw new Error(`Unknown kind "${kind}". Available: resource.`);
3613
+ await rmResource(name, options2);
3614
+ });
3615
+ cli.command("role:add <name>", "Register a role users can be assigned (local database unless --remote)").option("--label <label>", "Display label (default: humanized name)").option("--remote", "Target the deployed database").option("--env <name>", "Wrangler environment").example("flare role:add support").action(async (name, options2) => {
3616
+ process.exitCode = await addRole(name, options2);
3617
+ });
3618
+ cli.command("user:role <email> <role>", "Set a user's role, e.g. make the first admin (local database unless --remote)").option("--remote", "Target the deployed database").option("--env <name>", "Wrangler environment").example("flare user:role you@example.com admin").action(async (email, role, options2) => {
3619
+ process.exitCode = await setUserRole(email, role, options2);
3620
+ });
3621
+ cli.command("migrate", "Apply pending D1 migrations (local database unless --remote)").option("--remote", "Target the deployed database").option("--env <name>", "Wrangler environment").option("--database <binding>", "D1 binding, when the app has several").action(async (options2) => {
3622
+ process.exitCode = await migrate(options2);
3623
+ });
3624
+ cli.command("migrate:rollback", "Undo the most recently applied migrations").option("--steps <n>", "How many migrations to roll back", { default: 1 }).option("--remote", "Target the deployed database (requires --yes)").option("--yes", "Confirm a remote rollback").option("--env <name>", "Wrangler environment").option("--database <binding>", "D1 binding, when the app has several").action(async (options2) => {
3625
+ process.exitCode = await rollback({ ...options2, steps: Number(options2.steps) });
3626
+ });
3627
+ 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) => {
3628
+ await runSeeds({ names });
3629
+ });
3630
+ 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) => {
3631
+ await makeSeed(name, options2);
3632
+ });
3633
+ cli.command("billing:sync-plans", "Pull Stripe Products/Prices into the Plan table and enable plan changes in the portal (local database unless --remote)").option("--remote", "Write to the deployed database").option("--env <name>", "Wrangler environment").option("--all", "Import every active Product, not only those tagged flare_app=<app name>").example("flare billing:sync-plans").example("flare billing:sync-plans --remote").action(async (options2) => {
3634
+ await syncPlans({ remote: options2.remote, env: options2.env, all: options2.all });
3635
+ });
3636
+ cli.command("sync-types", "Regenerate schema, routes, clients, and validators from resource descriptors; report drift").option("--check", "Change nothing; exit 1 if anything is out of sync (CI)").option("--force", "Overwrite generated blocks that were edited by hand").action(async (options2) => {
3637
+ process.exitCode = await syncTypes(options2);
3638
+ });
3639
+ for (const [name, spec] of Object.entries(DELEGATED_COMMANDS)) {
3640
+ cli.command(`${name} [...args]`, spec.description).allowUnknownOptions();
3641
+ }
3642
+ cli.help();
3643
+ cli.version(FLARE_VERSION2);
3644
+ return cli;
3645
+ }
3646
+
3647
+ // src/index.ts
3648
+ try {
3649
+ const [command, ...rest] = process.argv.slice(2);
3650
+ if (isDelegatedCommand(command)) {
3651
+ process.exitCode = await runDelegated(command, rest);
3652
+ } else {
3653
+ const cli = createCli();
3654
+ cli.parse(process.argv, { run: false });
3655
+ if (!cli.matchedCommand && cli.args.length === 0 && !cli.options.help && !cli.options.version) {
3656
+ cli.outputHelp();
3657
+ } else {
3658
+ await cli.runMatchedCommand();
3659
+ }
3660
+ }
3661
+ } catch (error) {
3662
+ console.error(pc16.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
3663
+ process.exitCode = 1;
3664
+ }