@flaredev/cli 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/flare.js +9 -1
- package/dist/index.js +985 -337
- package/package.json +3 -2
- package/templates/app/app/admin/page.tsx +9 -61
- package/templates/app/app/api/openapi.json/route.ts +23 -0
- package/templates/app/app/api/reference/route.ts +36 -0
- package/templates/app/app/dashboard/account/page.tsx +40 -0
- package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
- package/templates/app/app/dashboard/import-actions.ts +91 -0
- package/templates/app/app/dashboard/layout.tsx +42 -0
- package/templates/app/app/dashboard/observability/page.tsx +174 -0
- package/templates/app/app/dashboard/page.tsx +123 -9
- package/templates/app/app/forgot-password/page.tsx +22 -0
- package/templates/app/app/globals.css +109 -4
- package/templates/app/app/layout.tsx +28 -26
- package/templates/app/app/page.tsx +126 -13
- package/templates/app/app/reset-password/page.tsx +31 -0
- package/templates/app/app/sign-in/page.tsx +49 -33
- package/templates/app/app/sign-up/page.tsx +42 -23
- package/templates/app/app/two-factor/page.tsx +25 -0
- package/templates/app/app/verify-email/page.tsx +33 -0
- package/templates/app/components/account/security-settings.tsx +522 -0
- package/templates/app/components/auth/auth-shell.tsx +284 -0
- package/templates/app/components/auth/otp-input.tsx +95 -0
- package/templates/app/components/auth/password-field.tsx +116 -0
- package/templates/app/components/auth/password-forms.tsx +76 -0
- package/templates/app/components/auth/provider-icons.tsx +39 -0
- package/templates/app/components/auth/sign-in-flow.tsx +306 -0
- package/templates/app/components/auth/sign-up-form.tsx +93 -0
- package/templates/app/components/auth/social-buttons.tsx +56 -0
- package/templates/app/components/auth/two-factor-form.tsx +139 -0
- package/templates/app/components/auth/ui.tsx +96 -0
- package/templates/app/components/auth/use-resend.ts +44 -0
- package/templates/app/components/auth/verify-email-form.tsx +135 -0
- package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
- package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
- package/templates/app/components/dashboard/export-button.tsx +41 -0
- package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
- package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
- package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
- package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
- package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
- package/templates/app/components/dashboard/import-dialog.tsx +487 -0
- package/templates/app/components/dashboard/page-header.tsx +57 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
- package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
- package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
- package/templates/app/components/dashboard/resource-stats.tsx +53 -0
- package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
- package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
- package/templates/app/components/dashboard/stat-card.tsx +69 -0
- package/templates/app/components/dashboard/table-selection.tsx +185 -0
- package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
- package/templates/app/components/marketing/product-preview.tsx +70 -0
- package/templates/app/components/marketing/site-header.tsx +39 -0
- package/templates/app/components/sign-out-button.tsx +1 -1
- package/templates/app/components/ui/checkbox.tsx +25 -0
- package/templates/app/components/ui/radio-group.tsx +29 -0
- package/templates/app/db/auth-schema.ts +167 -118
- package/templates/app/db/flare-schema.ts +33 -1
- package/templates/app/lib/analytics.ts +121 -0
- package/templates/app/lib/api-docs.ts +16 -0
- package/templates/app/lib/audit.ts +80 -0
- package/templates/app/lib/auth-client.ts +16 -1
- package/templates/app/lib/auth-config.ts +38 -0
- package/templates/app/lib/auth-emails.ts +94 -0
- package/templates/app/lib/auth.ts +115 -6
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/csv.ts +24 -0
- package/templates/app/lib/dashboard-nav.ts +15 -0
- package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
- package/templates/app/lib/password-rules.ts +159 -0
- package/templates/app/lib/site.ts +56 -0
- package/templates/app/lib/theme.ts +79 -0
- package/templates/app/migrations/0000_init.sql +47 -4
- package/templates/app/migrations/meta/0000_snapshot.json +311 -1
- package/templates/app/migrations/meta/_journal.json +1 -1
- package/templates/app/vite.config.ts +5 -3
- package/templates/app/worker-configuration.d.ts +15764 -0
- package/templates/locks/package-lock.json +9189 -0
- package/templates/locks/pnpm-lock.yaml +6347 -0
- package/templates/app/app/admin/layout.tsx +0 -32
- package/templates/app/components/auth-form.tsx +0 -120
- package/templates/app/lib/admin-nav.ts +0 -15
- /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
- /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
package/dist/index.js
CHANGED
|
@@ -1,47 +1,105 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import
|
|
2
|
+
import pc21 from "picocolors";
|
|
3
3
|
|
|
4
4
|
// src/cli.ts
|
|
5
5
|
import { cac } from "cac";
|
|
6
|
-
import
|
|
6
|
+
import * as prompts3 from "@clack/prompts";
|
|
7
|
+
import { FLARE_VERSION as FLARE_VERSION3 } from "@flaredev/core";
|
|
7
8
|
|
|
8
9
|
// src/commands/create.ts
|
|
9
10
|
import { randomBytes } from "crypto";
|
|
10
|
-
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11
|
+
import { appendFileSync, copyFileSync, existsSync as existsSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11
12
|
import { basename, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "path";
|
|
13
|
+
import * as prompts from "@clack/prompts";
|
|
12
14
|
import pc from "picocolors";
|
|
13
15
|
import { FLARE_VERSION } from "@flaredev/core";
|
|
14
16
|
|
|
15
17
|
// src/auth-providers.ts
|
|
16
18
|
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
|
+
google: { label: "Google", envPrefix: "GOOGLE", console: "https://console.cloud.google.com/apis/credentials", extra: [] },
|
|
20
|
+
github: { label: "GitHub", envPrefix: "GITHUB", console: "https://github.com/settings/developers", extra: [] },
|
|
21
|
+
apple: {
|
|
22
|
+
label: "Apple",
|
|
23
|
+
envPrefix: "APPLE",
|
|
24
|
+
console: "https://developer.apple.com/account/resources/identifiers/list/serviceId",
|
|
25
|
+
// The client secret is a JWT signed with your key; the bundle id is only for native apps.
|
|
26
|
+
extra: ["# APPLE_CLIENT_SECRET is a JWT you generate from your Sign in with Apple key (see the auth guide).", "APPLE_APP_BUNDLE_ID="]
|
|
27
|
+
},
|
|
28
|
+
microsoft: {
|
|
29
|
+
label: "Microsoft",
|
|
30
|
+
envPrefix: "MICROSOFT",
|
|
31
|
+
console: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade",
|
|
32
|
+
extra: ['# "common" (the default) accepts personal and work accounts; a tenant id limits sign-in to one organisation.', "MICROSOFT_TENANT_ID="]
|
|
33
|
+
}
|
|
19
34
|
};
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
var AUTH_METHODS = {
|
|
36
|
+
"magic-link": "Magic links (a sign-in link by email)",
|
|
37
|
+
"email-otp": "Email codes (a 6-digit sign-in code)",
|
|
38
|
+
passkeys: "Passkeys (Face ID, Touch ID, Windows Hello)",
|
|
39
|
+
"2fa-app": "Two-factor with an authenticator app, plus backup codes",
|
|
40
|
+
"2fa-email": "Two-factor with codes by email"
|
|
41
|
+
};
|
|
42
|
+
var DEFAULT_AUTH_METHODS = ["magic-link", "email-otp", "passkeys", "2fa-app", "2fa-email"];
|
|
43
|
+
function parseList(input, known, what) {
|
|
22
44
|
const names = input.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
|
|
23
|
-
const unknown = names.filter((name) => !(name
|
|
45
|
+
const unknown = names.filter((name) => !known.includes(name));
|
|
24
46
|
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
|
-
);
|
|
47
|
+
throw new Error(`Unknown ${what}${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Supported: ${known.join(", ")}.`);
|
|
28
48
|
}
|
|
29
49
|
return [...new Set(names)];
|
|
30
50
|
}
|
|
31
|
-
function
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return [
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
function parseAuthProviders(input) {
|
|
52
|
+
if (!input) return [];
|
|
53
|
+
return parseList(input, Object.keys(AUTH_PROVIDERS), "auth provider");
|
|
54
|
+
}
|
|
55
|
+
function parseAuthMethods(input) {
|
|
56
|
+
if (input === void 0) return DEFAULT_AUTH_METHODS;
|
|
57
|
+
const value = input.trim().toLowerCase();
|
|
58
|
+
if (value === "all") return [...DEFAULT_AUTH_METHODS];
|
|
59
|
+
if (value === "none" || value === "") return [];
|
|
60
|
+
return parseList(value, Object.keys(AUTH_METHODS), "sign-in method");
|
|
61
|
+
}
|
|
62
|
+
function renderAuthConfig(methods, providers) {
|
|
63
|
+
const on = (method) => methods.includes(method);
|
|
64
|
+
return `/**
|
|
65
|
+
* How people sign in to this app. \`flare create\` wrote the choices you made; change
|
|
66
|
+
* them here any time. Every method's tables already exist, so switching one on or off
|
|
67
|
+
* needs no migration, and a method that's off is refused by the API, not just hidden.
|
|
68
|
+
*
|
|
69
|
+
* Email and password sign-in is always available.
|
|
70
|
+
*/
|
|
71
|
+
export const authConfig = {
|
|
72
|
+
/** A sign-in link by email. */
|
|
73
|
+
magicLink: ${on("magic-link")},
|
|
74
|
+
/** A 6-digit sign-in code by email. */
|
|
75
|
+
emailOtp: ${on("email-otp")},
|
|
76
|
+
/** Face ID, Touch ID, Windows Hello or a security key. */
|
|
77
|
+
passkeys: ${on("passkeys")},
|
|
78
|
+
twoFactor: {
|
|
79
|
+
/** Codes from an authenticator app (TOTP), with backup codes. */
|
|
80
|
+
authenticator: ${on("2fa-app")},
|
|
81
|
+
/** Codes by email as the second step. */
|
|
82
|
+
email: ${on("2fa-email")},
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* Social sign-in. Each provider also needs its credentials (see .dev.vars.example);
|
|
86
|
+
* without them its button stays hidden.
|
|
87
|
+
*/
|
|
88
|
+
social: ${JSON.stringify(providers).replace(/","/g, '", "')} as SocialProvider[],
|
|
89
|
+
/** Refuse password sign-in until the email address is verified. */
|
|
90
|
+
requireEmailVerification: false,
|
|
91
|
+
/**
|
|
92
|
+
* Check new passwords against Have I Been Pwned's breach list. Only the first five
|
|
93
|
+
* characters of the password's hash are sent, and an outage lets the password through.
|
|
94
|
+
*/
|
|
95
|
+
checkBreachedPasswords: true,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
99
|
+
|
|
100
|
+
/** Whether any second factor can be set up. */
|
|
101
|
+
export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
|
|
102
|
+
`;
|
|
45
103
|
}
|
|
46
104
|
function devVarsEntries(providers) {
|
|
47
105
|
return providers.map((provider) => `${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_ID=
|
|
@@ -50,21 +108,39 @@ ${AUTH_PROVIDERS[provider].envPrefix}_CLIENT_SECRET=
|
|
|
50
108
|
}
|
|
51
109
|
function devVarsExampleEntries(providers) {
|
|
52
110
|
return providers.map((provider) => {
|
|
53
|
-
const { envPrefix, console: console2 } = AUTH_PROVIDERS[provider];
|
|
111
|
+
const { label, envPrefix, console: console2, extra } = AUTH_PROVIDERS[provider];
|
|
54
112
|
return [
|
|
55
113
|
"",
|
|
56
|
-
`# ${
|
|
114
|
+
`# ${label} sign-in: ${console2}`,
|
|
57
115
|
`# Callback URL: <your app URL>/api/auth/callback/${provider}`,
|
|
58
116
|
`${envPrefix}_CLIENT_ID=`,
|
|
59
117
|
`${envPrefix}_CLIENT_SECRET=`,
|
|
118
|
+
...extra,
|
|
60
119
|
""
|
|
61
120
|
].join("\n");
|
|
62
121
|
}).join("");
|
|
63
122
|
}
|
|
64
123
|
|
|
124
|
+
// src/themes.ts
|
|
125
|
+
var THEMES = {
|
|
126
|
+
default: "Calm and centred, indigo accents",
|
|
127
|
+
coral: "Warm and rounded, sign-in as a card over the page",
|
|
128
|
+
amber: "Plain and direct, boxed forms with pill buttons",
|
|
129
|
+
sky: "Crisp blue, bold headings, social sign-in first",
|
|
130
|
+
mono: "Black and white on a fine grid",
|
|
131
|
+
emerald: "Fresh green, sign-in beside a customer quote"
|
|
132
|
+
};
|
|
133
|
+
function parseTheme(value) {
|
|
134
|
+
if (value === void 0) return "default";
|
|
135
|
+
const name = value.trim().toLowerCase();
|
|
136
|
+
if (!(name in THEMES)) throw new Error(`Unknown theme "${value}". Themes: ${Object.keys(THEMES).join(", ")}.`);
|
|
137
|
+
return name;
|
|
138
|
+
}
|
|
139
|
+
|
|
65
140
|
// src/versions.ts
|
|
66
141
|
var APP_DEPENDENCIES = {
|
|
67
142
|
"@better-auth/drizzle-adapter": "1.7.5",
|
|
143
|
+
"@better-auth/passkey": "1.7.5",
|
|
68
144
|
// Server components import Slot from here rather than the "radix-ui" umbrella (see components/ui/button.tsx).
|
|
69
145
|
"@radix-ui/react-slot": "1.3.3",
|
|
70
146
|
"@vinext/cloudflare": "1.0.0-beta.8",
|
|
@@ -80,6 +156,14 @@ var APP_DEPENDENCIES = {
|
|
|
80
156
|
sonner: "^2.0.8",
|
|
81
157
|
"tailwind-merge": "^3.7.0",
|
|
82
158
|
"tw-animate-css": "^1.4.0",
|
|
159
|
+
// Typefaces for the six themes (app/globals.css).
|
|
160
|
+
"@fontsource-variable/figtree": "5.3.0",
|
|
161
|
+
"@fontsource-variable/geist": "5.3.0",
|
|
162
|
+
"@fontsource-variable/inter": "5.3.0",
|
|
163
|
+
"@fontsource-variable/noto-sans": "5.3.0",
|
|
164
|
+
"@fontsource-variable/plus-jakarta-sans": "5.3.0",
|
|
165
|
+
// QR codes for authenticator-app (2FA) setup.
|
|
166
|
+
uqr: "0.1.3",
|
|
83
167
|
"drizzle-orm": "^0.45.2",
|
|
84
168
|
react: "19.3.0",
|
|
85
169
|
"react-dom": "19.3.0",
|
|
@@ -101,6 +185,43 @@ var APP_DEV_DEPENDENCIES = {
|
|
|
101
185
|
wrangler: "^4.132.0"
|
|
102
186
|
};
|
|
103
187
|
|
|
188
|
+
// src/terminal.ts
|
|
189
|
+
var isTTY = () => Boolean(process.stdout.isTTY);
|
|
190
|
+
function formatDuration(ms) {
|
|
191
|
+
const seconds = Math.round(ms / 1e3);
|
|
192
|
+
if (seconds < 60) return `${seconds}s`;
|
|
193
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
194
|
+
return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds % 3600 / 60)).padStart(2, "0")}m`;
|
|
195
|
+
}
|
|
196
|
+
var formatCount = (value) => value.toLocaleString();
|
|
197
|
+
function formatRate(count, ms, unit = "rows") {
|
|
198
|
+
if (ms <= 0) return `${formatCount(count)} ${unit}/s`;
|
|
199
|
+
return `${formatCount(Math.round(count / ms * 1e3))} ${unit}/s`;
|
|
200
|
+
}
|
|
201
|
+
function progressLine(everyMs = 120) {
|
|
202
|
+
if (!isTTY()) return { update: () => {
|
|
203
|
+
}, done: () => {
|
|
204
|
+
} };
|
|
205
|
+
let last = 0;
|
|
206
|
+
let width = 0;
|
|
207
|
+
const write = (message) => {
|
|
208
|
+
process.stdout.write(`\r${message.padEnd(width)}`);
|
|
209
|
+
width = Math.max(width, message.length);
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
update(message) {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
if (now - last < everyMs) return;
|
|
215
|
+
last = now;
|
|
216
|
+
write(message);
|
|
217
|
+
},
|
|
218
|
+
done() {
|
|
219
|
+
if (width > 0) process.stdout.write(`\r${" ".repeat(width)}\r`);
|
|
220
|
+
width = 0;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
104
225
|
// src/utils/fs.ts
|
|
105
226
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
106
227
|
import { dirname, join, resolve } from "path";
|
|
@@ -108,16 +229,19 @@ import { fileURLToPath } from "url";
|
|
|
108
229
|
var RENAMES = { _gitignore: ".gitignore" };
|
|
109
230
|
function copyTemplate(from, to, tokens) {
|
|
110
231
|
mkdirSync(to, { recursive: true });
|
|
232
|
+
let files = 0;
|
|
111
233
|
for (const entry of readdirSync(from)) {
|
|
112
234
|
const src = join(from, entry);
|
|
113
235
|
const dest = join(to, RENAMES[entry] ?? entry);
|
|
114
236
|
if (statSync(src).isDirectory()) {
|
|
115
|
-
copyTemplate(src, dest, tokens);
|
|
237
|
+
files += copyTemplate(src, dest, tokens);
|
|
116
238
|
continue;
|
|
117
239
|
}
|
|
118
240
|
const content = readFileSync(src, "utf8").replace(/__([A-Z_]+)__/g, (match, key) => tokens[key] ?? match);
|
|
119
241
|
writeFileSync(dest, content);
|
|
242
|
+
files++;
|
|
120
243
|
}
|
|
244
|
+
return files;
|
|
121
245
|
}
|
|
122
246
|
function writeJson(path, value) {
|
|
123
247
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -194,14 +318,17 @@ function createApp(target, options2 = {}) {
|
|
|
194
318
|
}
|
|
195
319
|
const packageManager = options2.pm ?? detectPackageManager();
|
|
196
320
|
const authProviders = parseAuthProviders(options2.authProviders);
|
|
321
|
+
const authMethods = parseAuthMethods(options2.auth);
|
|
322
|
+
const theme = parseTheme(options2.theme);
|
|
197
323
|
const inWorkspace = findUp("pnpm-workspace.yaml", dirname2(dir)) !== void 0;
|
|
198
324
|
const compatibilityDate = options2.compatibilityDate ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
199
|
-
copyTemplate(join2(templatesDir, "app"), dir, {
|
|
325
|
+
const files = copyTemplate(join2(templatesDir, "app"), dir, {
|
|
200
326
|
APP_NAME: name,
|
|
201
327
|
COMPAT_DATE: compatibilityDate,
|
|
202
328
|
PM: packageManager,
|
|
203
|
-
|
|
329
|
+
THEME: theme
|
|
204
330
|
});
|
|
331
|
+
writeFileSync2(join2(dir, "lib", "auth-config.ts"), renderAuthConfig(authMethods, authProviders));
|
|
205
332
|
appendFileSync(join2(dir, ".dev.vars.example"), devVarsExampleEntries(authProviders));
|
|
206
333
|
writeJson(join2(dir, "package.json"), {
|
|
207
334
|
name,
|
|
@@ -230,42 +357,40 @@ ${devVarsEntries(authProviders)}`
|
|
|
230
357
|
if (packageManager === "pnpm" && !inWorkspace) {
|
|
231
358
|
writeFileSync2(join2(dir, "pnpm-workspace.yaml"), "allowBuilds:\n esbuild: true\n workerd: true\n sharp: false\n");
|
|
232
359
|
}
|
|
233
|
-
const
|
|
360
|
+
const pinned = flarePackageSpec("core", dir, packageManager).startsWith("^");
|
|
361
|
+
if (pinned) copyLockfile(packageManager, dir);
|
|
362
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
363
|
+
log2(`${pc.green("\u2714")} Wrote ${files} files to ${relative(process.cwd(), dir) || "."}`);
|
|
234
364
|
if (options2.install !== false) {
|
|
235
|
-
log(`${pc.green("\u2714")} Wrote the app to ${relative(process.cwd(), dir) || "."}`);
|
|
236
|
-
log(`${pc.cyan("\u25CF")} Installing dependencies with ${packageManager}. The first install downloads the Workers runtime and toolchain, so it can take a few minutes.
|
|
237
|
-
`);
|
|
238
365
|
const started = Date.now();
|
|
239
|
-
const code = run(packageManager, installArgs(packageManager), dir);
|
|
366
|
+
const code = (options2.installer ?? run)(packageManager, installArgs(packageManager), dir);
|
|
240
367
|
if (code !== 0) throw new Error(`${packageManager} install failed (exit code ${code}).`);
|
|
241
|
-
|
|
242
|
-
const typegen = runQuiet(packageManager, ["run", "cf-typegen"], dir);
|
|
243
|
-
if (typegen.code !== 0) {
|
|
244
|
-
process.stderr.write(typegen.output);
|
|
245
|
-
throw new Error(`wrangler types failed (exit code ${typegen.code}).`);
|
|
246
|
-
}
|
|
247
|
-
log(`${pc.green("\u2714")} Generated types for the Cloudflare bindings`);
|
|
368
|
+
if (!options2.installer) log2(`${pc.green("\u2714")} Installed dependencies ${pc.dim(`(${formatDuration(Date.now() - started)})`)}`);
|
|
248
369
|
}
|
|
249
370
|
return { dir, name, packageManager, inWorkspace };
|
|
250
371
|
}
|
|
251
|
-
function
|
|
252
|
-
const
|
|
253
|
-
|
|
372
|
+
function copyLockfile(packageManager, dir) {
|
|
373
|
+
const file = packageManager === "npm" ? "package-lock.json" : packageManager === "pnpm" ? "pnpm-lock.yaml" : void 0;
|
|
374
|
+
if (!file) return false;
|
|
375
|
+
const source = join2(templatesDir, "locks", file);
|
|
376
|
+
if (!existsSync2(source)) return false;
|
|
377
|
+
copyFileSync(source, join2(dir, file));
|
|
378
|
+
return true;
|
|
254
379
|
}
|
|
255
380
|
function printNextSteps(result, installed) {
|
|
256
381
|
const rel = relative(process.cwd(), result.dir) || ".";
|
|
257
382
|
const pm = result.packageManager;
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
383
|
+
const commands = [
|
|
384
|
+
[`cd ${rel}`, "your new app"],
|
|
385
|
+
...installed ? [] : [[`${pm} install`, "dependencies first"]],
|
|
386
|
+
[`${pm} run dev`, "http://localhost:3000"],
|
|
387
|
+
["npx flare gen resource Contact", "a table, API and admin screens"],
|
|
388
|
+
["npx flare seed:resource Contact 1000", "fill it with sample rows"]
|
|
389
|
+
];
|
|
390
|
+
const width = Math.max(...commands.map(([command]) => command.length)) + 2;
|
|
391
|
+
const steps = commands.map(([command, note2]) => `${pc.cyan(command.padEnd(width))}${pc.dim(note2)}`);
|
|
392
|
+
prompts.note(steps.join("\n"), "Next");
|
|
393
|
+
prompts.outro(`${pc.bold(result.name)} is ready ${pc.dim("\xB7")} ${pc.cyan("https://flare-docs.codetotech.com/start/quickstart/")}`);
|
|
269
394
|
}
|
|
270
395
|
|
|
271
396
|
// src/commands/gen-billing.ts
|
|
@@ -274,9 +399,10 @@ import { dirname as dirname6, join as join11 } from "path";
|
|
|
274
399
|
import pc6 from "picocolors";
|
|
275
400
|
|
|
276
401
|
// src/generator/grammar.ts
|
|
277
|
-
import { camelCase, FILE_CATEGORIES } from "@flaredev/core";
|
|
402
|
+
import { camelCase, FILE_CATEGORIES, STRING_FORMATS } from "@flaredev/core";
|
|
278
403
|
var SIMPLE_KINDS = ["string", "text", "int", "float", "boolean", "date", "datetime"];
|
|
279
|
-
var
|
|
404
|
+
var FORMAT_ALIASES = { phone: "tel" };
|
|
405
|
+
var ALL_KINDS = [...SIMPLE_KINDS, ...STRING_FORMATS, "phone", "enum", "select", "radio", "multiselect", "file", "belongsTo", "hasMany"];
|
|
280
406
|
var FieldGrammarError = class extends Error {
|
|
281
407
|
constructor(message) {
|
|
282
408
|
super(message);
|
|
@@ -344,15 +470,21 @@ function parseField(spec) {
|
|
|
344
470
|
let match;
|
|
345
471
|
if (SIMPLE_KINDS.includes(type)) {
|
|
346
472
|
field.kind = type;
|
|
347
|
-
} else if (
|
|
348
|
-
field.kind = "
|
|
349
|
-
|
|
473
|
+
} else if (STRING_FORMATS.includes(type) || type in FORMAT_ALIASES) {
|
|
474
|
+
field.kind = "string";
|
|
475
|
+
field.format = FORMAT_ALIASES[type] ?? type;
|
|
476
|
+
} else if (match = /^(enum|select|radio|multiselect)\((.*)\)$/.exec(type)) {
|
|
477
|
+
const choice = match[1];
|
|
478
|
+
field.kind = choice === "multiselect" ? "multiselect" : "enum";
|
|
479
|
+
if (choice === "radio") field.widget = "radio";
|
|
480
|
+
if (choice === "multiselect" && unique) throw new FieldGrammarError(`"${spec}": multiselect fields can't be unique.`);
|
|
481
|
+
const options2 = list(match[2], `${choice} option`, spec);
|
|
350
482
|
for (const option of options2) {
|
|
351
483
|
if (!/^[A-Za-z0-9_-]+$/.test(option)) {
|
|
352
|
-
throw new FieldGrammarError(`"${spec}":
|
|
484
|
+
throw new FieldGrammarError(`"${spec}": ${choice} option "${option}" may only use letters, digits, _ and -.`);
|
|
353
485
|
}
|
|
354
486
|
}
|
|
355
|
-
if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate
|
|
487
|
+
if (new Set(options2).size !== options2.length) throw new FieldGrammarError(`"${spec}": duplicate ${choice} options.`);
|
|
356
488
|
field.options = options2;
|
|
357
489
|
} else if (match = /^file(?::\[(.*)\])?$/.exec(type)) {
|
|
358
490
|
field.kind = "file";
|
|
@@ -387,7 +519,7 @@ function parseField(spec) {
|
|
|
387
519
|
const base = type.replace(/[([].*$/, "").replace(/:$/, "");
|
|
388
520
|
throw new FieldGrammarError(`"${spec}": unknown type "${type}".${suggest(base, ALL_KINDS)} Types: ${ALL_KINDS.join(", ")}.`);
|
|
389
521
|
}
|
|
390
|
-
if (field.kind === "string") {
|
|
522
|
+
if (field.kind === "string" && !field.format) {
|
|
391
523
|
if (/^email$|Email$/.test(key)) field.format = "email";
|
|
392
524
|
else if (/^(url|website)$|(Url|Website)$/.test(key)) field.format = "url";
|
|
393
525
|
}
|
|
@@ -505,6 +637,7 @@ function options(field) {
|
|
|
505
637
|
if (!field.required) opts.required = false;
|
|
506
638
|
if (field.unique) opts.unique = true;
|
|
507
639
|
if (field.format) opts.format = field.format;
|
|
640
|
+
if (field.widget) opts.widget = field.widget;
|
|
508
641
|
if (field.kind === "belongsTo" && !field.required) opts.onDelete = "set null";
|
|
509
642
|
return opts;
|
|
510
643
|
}
|
|
@@ -513,6 +646,8 @@ function toField(field) {
|
|
|
513
646
|
switch (field.kind) {
|
|
514
647
|
case "enum":
|
|
515
648
|
return builders.enum(field.options, opts);
|
|
649
|
+
case "multiselect":
|
|
650
|
+
return builders.multiselect(field.options, opts);
|
|
516
651
|
case "file":
|
|
517
652
|
return builders.file(field.accept, opts);
|
|
518
653
|
case "belongsTo":
|
|
@@ -534,6 +669,8 @@ function renderField(field) {
|
|
|
534
669
|
switch (field.kind) {
|
|
535
670
|
case "enum":
|
|
536
671
|
return `${field.key}: field.enum(${args(literal(field.options), opts)}),`;
|
|
672
|
+
case "multiselect":
|
|
673
|
+
return `${field.key}: field.multiselect(${args(literal(field.options), opts)}),`;
|
|
537
674
|
case "file":
|
|
538
675
|
return `${field.key}: field.file(${args(literal(field.accept), opts)}),`;
|
|
539
676
|
case "belongsTo":
|
|
@@ -1302,12 +1439,12 @@ function onPath() {
|
|
|
1302
1439
|
const probe = spawnSync2(process.platform === "win32" ? "where" : "which", ["cloudflared"], { encoding: "utf8" });
|
|
1303
1440
|
return probe.status === 0 ? probe.stdout.split(/\r?\n/)[0]?.trim() ?? null : null;
|
|
1304
1441
|
}
|
|
1305
|
-
async function download(
|
|
1442
|
+
async function download(log2) {
|
|
1306
1443
|
const asset = releaseAsset();
|
|
1307
1444
|
const dir = cacheDir();
|
|
1308
1445
|
mkdirSync3(dir, { recursive: true });
|
|
1309
1446
|
const target = join4(dir, binName);
|
|
1310
|
-
|
|
1447
|
+
log2(pc2.dim(`Downloading cloudflared (one time) into ${dir}\u2026`));
|
|
1311
1448
|
const response = await fetch(`https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`);
|
|
1312
1449
|
if (!response.ok) throw new Error(`Downloading cloudflared failed: HTTP ${response.status}.`);
|
|
1313
1450
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
@@ -1324,12 +1461,12 @@ async function download(log) {
|
|
|
1324
1461
|
if (process.platform !== "win32") chmodSync(target, 493);
|
|
1325
1462
|
return target;
|
|
1326
1463
|
}
|
|
1327
|
-
async function ensureCloudflared(
|
|
1464
|
+
async function ensureCloudflared(log2 = console.log) {
|
|
1328
1465
|
const found = onPath();
|
|
1329
1466
|
if (found) return found;
|
|
1330
1467
|
const cached = join4(cacheDir(), binName);
|
|
1331
1468
|
if (existsSync5(cached)) return cached;
|
|
1332
|
-
return download(
|
|
1469
|
+
return download(log2);
|
|
1333
1470
|
}
|
|
1334
1471
|
async function openTunnel(localUrl, options2 = {}) {
|
|
1335
1472
|
const bin = await ensureCloudflared(options2.log);
|
|
@@ -1483,9 +1620,9 @@ async function runDeploy(args, context3) {
|
|
|
1483
1620
|
if (plan.passthroughOnly) return context3.deploy(plan.forwarded);
|
|
1484
1621
|
const { appRoot, wranglerBin } = context3;
|
|
1485
1622
|
const shellEnv = context3.env ?? process.env;
|
|
1486
|
-
const
|
|
1623
|
+
const log2 = context3.log ?? ((message) => console.log(message));
|
|
1487
1624
|
const envArgs = plan.env ? ["--env", plan.env] : [];
|
|
1488
|
-
const step = (message) =>
|
|
1625
|
+
const step = (message) => log2(`
|
|
1489
1626
|
${pc3.bold(pc3.cyan("flare"))} ${message}`);
|
|
1490
1627
|
const databases = plan.skipMigrations ? [] : readD1Databases(appRoot).filter((db) => existsSync7(join6(appRoot, db.migrations_dir ?? "migrations")));
|
|
1491
1628
|
const migrateAfterDeploy = [];
|
|
@@ -1517,7 +1654,7 @@ ${info.output.trim()}`);
|
|
|
1517
1654
|
const apiToken = shellEnv.FLARE_SECURITY_API_TOKEN;
|
|
1518
1655
|
if (security && zoneId && apiToken) {
|
|
1519
1656
|
step("Provisioning zone security rules");
|
|
1520
|
-
await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log, fetch: context3.fetch });
|
|
1657
|
+
await provisionZoneSecurity(appRoot, security, { zoneId, apiToken }, { log: log2, fetch: context3.fetch });
|
|
1521
1658
|
} else if (security) {
|
|
1522
1659
|
step("Zone security rules skipped (Worker-layer protection is on). To add them, deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set.");
|
|
1523
1660
|
}
|
|
@@ -1550,7 +1687,7 @@ ${put.output.trim()}`);
|
|
|
1550
1687
|
const optional = existsSync7(examplePath) ? declaredSecretNames(readFileSync4(examplePath, "utf8")).filter((name) => !existing.has(name)) : [];
|
|
1551
1688
|
if (optional.length > 0) {
|
|
1552
1689
|
step("Optional secrets not set in production (features using them stay off):");
|
|
1553
|
-
for (const name of optional)
|
|
1690
|
+
for (const name of optional) log2(` wrangler secret put ${name}${plan.env ? ` --env ${plan.env}` : ""}`);
|
|
1554
1691
|
}
|
|
1555
1692
|
}
|
|
1556
1693
|
return 0;
|
|
@@ -1757,7 +1894,7 @@ function previousSnapshot(dir, migrationFile) {
|
|
|
1757
1894
|
}
|
|
1758
1895
|
|
|
1759
1896
|
// src/generator/plan.ts
|
|
1760
|
-
import { copyFileSync, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
|
|
1897
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync4, readdirSync as readdirSync6, readFileSync as readFileSync7 } from "fs";
|
|
1761
1898
|
import { dirname as dirname5, join as join10 } from "path";
|
|
1762
1899
|
import pc5 from "picocolors";
|
|
1763
1900
|
|
|
@@ -1843,6 +1980,9 @@ function columnExpression(key, def, resources, selfName) {
|
|
|
1843
1980
|
case "enum":
|
|
1844
1981
|
expr = `text(${col}, { enum: ${q(def.options)} })`;
|
|
1845
1982
|
break;
|
|
1983
|
+
case "multiselect":
|
|
1984
|
+
expr = `text(${col}, { mode: "json" }).$type<(${def.options.map((option) => q(option)).join(" | ")})[]>()`;
|
|
1985
|
+
break;
|
|
1846
1986
|
case "belongsTo": {
|
|
1847
1987
|
const target = resources.get(def.target);
|
|
1848
1988
|
if (!target) {
|
|
@@ -1856,7 +1996,7 @@ function columnExpression(key, def, resources, selfName) {
|
|
|
1856
1996
|
expr = `text(${col})`;
|
|
1857
1997
|
}
|
|
1858
1998
|
if (def.required) expr += ".notNull()";
|
|
1859
|
-
if (def.unique) expr += ".unique()";
|
|
1999
|
+
if ("unique" in def && def.unique) expr += ".unique()";
|
|
1860
2000
|
if ("default" in def && def.default !== void 0) expr += `.default(${q(def.default)})`;
|
|
1861
2001
|
return expr;
|
|
1862
2002
|
}
|
|
@@ -1983,16 +2123,24 @@ var adminImports = (entry) => [
|
|
|
1983
2123
|
function renderAdminListPage(entry) {
|
|
1984
2124
|
const local = resourceLocal(entry.stem);
|
|
1985
2125
|
return [
|
|
1986
|
-
`import {
|
|
1987
|
-
`import
|
|
2126
|
+
`import { PageHeader } from "@/components/dashboard/page-header";`,
|
|
2127
|
+
`import { ResourceStats } from "@/components/dashboard/resource-stats";`,
|
|
2128
|
+
`import { ResourceTable } from "@/components/dashboard/resource-table";`,
|
|
2129
|
+
`import type { SearchParams } from "@/components/dashboard/query";`,
|
|
1988
2130
|
...adminImports(entry),
|
|
1989
2131
|
"",
|
|
2132
|
+
`export const metadata = { title: ${local}.pluralLabel };`,
|
|
2133
|
+
"",
|
|
1990
2134
|
`export default async function ${entry.resource.name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {`,
|
|
1991
2135
|
" return (",
|
|
1992
|
-
|
|
1993
|
-
|
|
2136
|
+
" <>",
|
|
2137
|
+
" <PageHeader",
|
|
2138
|
+
` title={${local}.pluralLabel}`,
|
|
2139
|
+
` crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: ${local}.pluralLabel }]}`,
|
|
2140
|
+
" />",
|
|
2141
|
+
` <ResourceStats resource={${local}} />`,
|
|
1994
2142
|
` <ResourceTable resource={${local}} searchParams={await searchParams} />`,
|
|
1995
|
-
"
|
|
2143
|
+
" </>",
|
|
1996
2144
|
" );",
|
|
1997
2145
|
"}",
|
|
1998
2146
|
""
|
|
@@ -2000,7 +2148,7 @@ function renderAdminListPage(entry) {
|
|
|
2000
2148
|
}
|
|
2001
2149
|
function renderAdminNewPage(entry) {
|
|
2002
2150
|
return [
|
|
2003
|
-
`import { ResourceFormPage } from "@/components/
|
|
2151
|
+
`import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
|
|
2004
2152
|
...adminImports(entry),
|
|
2005
2153
|
"",
|
|
2006
2154
|
`export default function New${entry.resource.name}Page() {`,
|
|
@@ -2011,7 +2159,7 @@ function renderAdminNewPage(entry) {
|
|
|
2011
2159
|
}
|
|
2012
2160
|
function renderAdminEditPage(entry) {
|
|
2013
2161
|
return [
|
|
2014
|
-
`import { ResourceFormPage } from "@/components/
|
|
2162
|
+
`import { ResourceFormPage } from "@/components/dashboard/resource-form-page";`,
|
|
2015
2163
|
...adminImports(entry),
|
|
2016
2164
|
"",
|
|
2017
2165
|
`export default async function Edit${entry.resource.name}Page({ params }: { params: Promise<{ id: string }> }) {`,
|
|
@@ -2079,9 +2227,9 @@ function resourceFiles(entry, all) {
|
|
|
2079
2227
|
{ path: `app/api/${resource.slug}/[id]/route.ts`, content: renderItemRoute(entry) },
|
|
2080
2228
|
{ path: `resources/${stem}.client.ts`, content: renderClient(entry) },
|
|
2081
2229
|
{ path: `resources/${stem}.validators.ts`, content: renderValidators(entry) },
|
|
2082
|
-
{ path: `app/
|
|
2083
|
-
{ path: `app/
|
|
2084
|
-
{ path: `app/
|
|
2230
|
+
{ path: `app/dashboard/${resource.slug}/page.tsx`, content: renderAdminListPage(entry) },
|
|
2231
|
+
{ path: `app/dashboard/${resource.slug}/new/page.tsx`, content: renderAdminNewPage(entry) },
|
|
2232
|
+
{ path: `app/dashboard/${resource.slug}/[id]/edit/page.tsx`, content: renderAdminEditPage(entry) }
|
|
2085
2233
|
];
|
|
2086
2234
|
}
|
|
2087
2235
|
|
|
@@ -2141,7 +2289,7 @@ function findOrphans(appRoot, all) {
|
|
|
2141
2289
|
for (const dir of list2("app/api", () => true)) {
|
|
2142
2290
|
for (const file of [`${dir}/route.ts`, `${dir}/[id]/route.ts`]) if (existsSync11(join10(appRoot, file))) candidates.push(file);
|
|
2143
2291
|
}
|
|
2144
|
-
for (const dir of list2("app/
|
|
2292
|
+
for (const dir of list2("app/dashboard", () => true)) {
|
|
2145
2293
|
for (const file of [`${dir}/page.tsx`, `${dir}/new/page.tsx`, `${dir}/[id]/edit/page.tsx`]) {
|
|
2146
2294
|
if (existsSync11(join10(appRoot, file))) candidates.push(file);
|
|
2147
2295
|
}
|
|
@@ -2153,20 +2301,20 @@ function findOrphans(appRoot, all) {
|
|
|
2153
2301
|
}
|
|
2154
2302
|
return orphans;
|
|
2155
2303
|
}
|
|
2156
|
-
function ensureSupportFiles(appRoot,
|
|
2304
|
+
function ensureSupportFiles(appRoot, log2) {
|
|
2157
2305
|
for (const path of ["lib/api.ts", "lib/cache.ts"]) {
|
|
2158
2306
|
const target = join10(appRoot, path);
|
|
2159
2307
|
if (existsSync11(target)) continue;
|
|
2160
2308
|
mkdirSync4(dirname5(target), { recursive: true });
|
|
2161
|
-
|
|
2162
|
-
|
|
2309
|
+
copyFileSync2(join10(templatesDir, "app", path), target);
|
|
2310
|
+
log2(`${pc5.green("create".padEnd(9))} ${path}`);
|
|
2163
2311
|
}
|
|
2164
2312
|
}
|
|
2165
|
-
function logResults(results,
|
|
2313
|
+
function logResults(results, log2, options2 = {}) {
|
|
2166
2314
|
for (const { path, outcome } of results) {
|
|
2167
2315
|
if (outcome === "identical" && !options2.showIdentical) continue;
|
|
2168
2316
|
const label = outcome === "create" ? pc5.green("create") : outcome === "update" ? pc5.yellow("update") : outcome === "identical" ? pc5.dim("identical") : pc5.red(outcome === "skipped-drift" ? "drift" : "hand-written");
|
|
2169
|
-
|
|
2317
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2170
2318
|
}
|
|
2171
2319
|
}
|
|
2172
2320
|
|
|
@@ -2201,20 +2349,20 @@ function addStripeDependency(appRoot) {
|
|
|
2201
2349
|
writeJson(path, pkg);
|
|
2202
2350
|
return true;
|
|
2203
2351
|
}
|
|
2204
|
-
function writeBillingResource(appRoot, name, force,
|
|
2352
|
+
function writeBillingResource(appRoot, name, force, log2) {
|
|
2205
2353
|
const relative2 = descriptorPath(name);
|
|
2206
2354
|
const path = join11(appRoot, relative2);
|
|
2207
2355
|
if (!existsSync12(path)) {
|
|
2208
2356
|
mkdirSync5(dirname6(path), { recursive: true });
|
|
2209
2357
|
writeFileSync6(path, renderBillingDescriptor(name));
|
|
2210
|
-
|
|
2358
|
+
log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
|
|
2211
2359
|
return;
|
|
2212
2360
|
}
|
|
2213
2361
|
const parts = splitMarkers(readFileSync8(path, "utf8"));
|
|
2214
2362
|
if (!parts) throw new Error(`${relative2} has no generated fields block, so the billing fields can't be merged. Add them by hand: ${BILLING_RESOURCES[name]}`);
|
|
2215
2363
|
const merged = mergeBillingFields(name, parts.block);
|
|
2216
2364
|
if (!merged) {
|
|
2217
|
-
|
|
2365
|
+
log2(`${pc6.dim("identical".padEnd(9))} ${relative2} (has every billing field)`);
|
|
2218
2366
|
return;
|
|
2219
2367
|
}
|
|
2220
2368
|
try {
|
|
@@ -2225,22 +2373,22 @@ function writeBillingResource(appRoot, name, force, log) {
|
|
|
2225
2373
|
}
|
|
2226
2374
|
throw error;
|
|
2227
2375
|
}
|
|
2228
|
-
|
|
2376
|
+
log2(`${pc6.yellow("update".padEnd(9))} ${relative2} (added billing fields, kept yours)`);
|
|
2229
2377
|
}
|
|
2230
2378
|
async function genBilling(options2 = {}) {
|
|
2231
|
-
const
|
|
2379
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2232
2380
|
validateBillingOptions(options2.provider, options2.mode);
|
|
2233
2381
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2234
|
-
for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force,
|
|
2382
|
+
for (const name of ["Plan", "Customer", "Purchase"]) writeBillingResource(appRoot, name, options2.force, log2);
|
|
2235
2383
|
for (const [name, roles] of Object.entries(BILLING_POLICIES)) {
|
|
2236
2384
|
const relative2 = policyPath(name);
|
|
2237
2385
|
if (existsSync12(join11(appRoot, relative2))) {
|
|
2238
|
-
|
|
2386
|
+
log2(`${pc6.dim("kept".padEnd(9))} ${relative2} (already exists)`);
|
|
2239
2387
|
continue;
|
|
2240
2388
|
}
|
|
2241
2389
|
mkdirSync5(join11(appRoot, "policies"), { recursive: true });
|
|
2242
2390
|
writeFileSync6(join11(appRoot, relative2), renderPolicy(name, roles));
|
|
2243
|
-
|
|
2391
|
+
log2(`${pc6.green("create".padEnd(9))} ${relative2}`);
|
|
2244
2392
|
}
|
|
2245
2393
|
const files = [];
|
|
2246
2394
|
const appName = JSON.parse(readFileSync8(join11(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
@@ -2248,7 +2396,7 @@ async function genBilling(options2 = {}) {
|
|
|
2248
2396
|
const outcome = writeGenerated(join11(appRoot, path), content, { header: billingHeader(), force: options2.force });
|
|
2249
2397
|
files.push({ path, status: "missing", outcome });
|
|
2250
2398
|
const label = outcome === "create" ? pc6.green("create") : outcome === "update" ? pc6.yellow("update") : pc6.dim("identical");
|
|
2251
|
-
|
|
2399
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2252
2400
|
}
|
|
2253
2401
|
for (const path of RETIRED_FILES) {
|
|
2254
2402
|
const absolute = join11(appRoot, path);
|
|
@@ -2257,15 +2405,15 @@ async function genBilling(options2 = {}) {
|
|
|
2257
2405
|
const untouched = parts && parts.before.includes("flare gen billing") && !parts.after.trim() && parts.hash !== void 0 && hashBlock(parts.block) === parts.hash;
|
|
2258
2406
|
if (untouched) {
|
|
2259
2407
|
unlinkSync(absolute);
|
|
2260
|
-
|
|
2408
|
+
log2(`${pc6.red("remove".padEnd(9))} ${path} (no longer generated)`);
|
|
2261
2409
|
} else {
|
|
2262
|
-
|
|
2410
|
+
log2(pc6.yellow(`${path} is no longer generated but has your changes; remove it when you're done with it.`));
|
|
2263
2411
|
}
|
|
2264
2412
|
}
|
|
2265
|
-
if (appendVars(appRoot, ".dev.vars.example"))
|
|
2266
|
-
if (appendVars(appRoot, ".dev.vars"))
|
|
2413
|
+
if (appendVars(appRoot, ".dev.vars.example")) log2(`${pc6.green("append".padEnd(9))} .dev.vars.example`);
|
|
2414
|
+
if (appendVars(appRoot, ".dev.vars")) log2(`${pc6.green("append".padEnd(9))} .dev.vars`);
|
|
2267
2415
|
const addedStripe = addStripeDependency(appRoot);
|
|
2268
|
-
if (addedStripe)
|
|
2416
|
+
if (addedStripe) log2(`${pc6.yellow("update".padEnd(9))} package.json (stripe@${STRIPE_DEP})`);
|
|
2269
2417
|
if (!options2.skipInstall) {
|
|
2270
2418
|
const pm = detectPackageManager();
|
|
2271
2419
|
const code = run(pm, ["install"], appRoot);
|
|
@@ -2273,16 +2421,16 @@ async function genBilling(options2 = {}) {
|
|
|
2273
2421
|
run(pm, ["run", "cf-typegen"], appRoot);
|
|
2274
2422
|
}
|
|
2275
2423
|
const all = await loadResources(appRoot);
|
|
2276
|
-
ensureSupportFiles(appRoot,
|
|
2277
|
-
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }),
|
|
2424
|
+
ensureSupportFiles(appRoot, log2);
|
|
2425
|
+
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
|
|
2278
2426
|
let migrations = [];
|
|
2279
2427
|
if (!options2.skipMigration) {
|
|
2280
2428
|
const result = await generateSchemaMigration(appRoot, "billing");
|
|
2281
2429
|
migrations = result.files;
|
|
2282
|
-
for (const file of migrations)
|
|
2283
|
-
for (const repair of result.repairs)
|
|
2430
|
+
for (const file of migrations) log2(`${pc6.green("create".padEnd(9))} ${file}`);
|
|
2431
|
+
for (const repair of result.repairs) log2(pc6.dim(`repaired ${repair}`));
|
|
2284
2432
|
}
|
|
2285
|
-
|
|
2433
|
+
log2(
|
|
2286
2434
|
[
|
|
2287
2435
|
"",
|
|
2288
2436
|
`Next:`,
|
|
@@ -2424,7 +2572,7 @@ var renderSecurityActions = () => `"use server";
|
|
|
2424
2572
|
import { revalidatePath } from "next/cache";
|
|
2425
2573
|
import { can } from "@flaredev/core";
|
|
2426
2574
|
import { isIp } from "@flaredev/core/security";
|
|
2427
|
-
import {
|
|
2575
|
+
import { dashboardSession, policyFor } from "@/lib/dashboard";
|
|
2428
2576
|
import { logSecurityEvent, security } from "@/lib/security";
|
|
2429
2577
|
|
|
2430
2578
|
/** On failure the submitted values come back, so the form (which React resets) keeps them. */
|
|
@@ -2433,7 +2581,7 @@ export type BanResult = { ok: true } | { ok: false; error: string; ip?: string;
|
|
|
2433
2581
|
const DURATIONS: Record<string, number | null> = { "1h": 3600, "24h": 86_400, "7d": 604_800, permanent: null };
|
|
2434
2582
|
|
|
2435
2583
|
async function adminOnly(): Promise<{ email: string } | { error: string }> {
|
|
2436
|
-
const { session, role } = await
|
|
2584
|
+
const { session, role } = await dashboardSession();
|
|
2437
2585
|
if (!session) return { error: "Sign in required." };
|
|
2438
2586
|
if (!can(policyFor("SecurityEvent"), role, "create")) return { error: "Only admins can ban or unban." };
|
|
2439
2587
|
return { email: session.user.email };
|
|
@@ -2454,8 +2602,8 @@ export async function banAction(_previous: BanResult | null, form: FormData): Pr
|
|
|
2454
2602
|
} catch (error) {
|
|
2455
2603
|
return { ok: false, error: \`The ban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\`, ip, reason };
|
|
2456
2604
|
}
|
|
2457
|
-
await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/
|
|
2458
|
-
revalidatePath("/
|
|
2605
|
+
await logSecurityEvent({ kind: "manual-ban", severity: "medium", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: true, detail: \`\${reason} (by \${admin.email})\` });
|
|
2606
|
+
revalidatePath("/dashboard/security");
|
|
2459
2607
|
return { ok: true };
|
|
2460
2608
|
}
|
|
2461
2609
|
|
|
@@ -2468,8 +2616,8 @@ export async function unbanAction(ip: string): Promise<BanResult> {
|
|
|
2468
2616
|
} catch (error) {
|
|
2469
2617
|
return { ok: false, error: \`The unban didn't reach every layer: \${error instanceof Error ? error.message : String(error)}\` };
|
|
2470
2618
|
}
|
|
2471
|
-
await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/
|
|
2472
|
-
revalidatePath("/
|
|
2619
|
+
await logSecurityEvent({ kind: "manual-unban", severity: "low", ip, path: "/dashboard/security", userAgent: null, count: 0, banned: false, detail: \`by \${admin.email}\` });
|
|
2620
|
+
revalidatePath("/dashboard/security");
|
|
2473
2621
|
return { ok: true };
|
|
2474
2622
|
}
|
|
2475
2623
|
`;
|
|
@@ -2562,7 +2710,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|
|
2562
2710
|
import { Separator } from "@/components/ui/separator";
|
|
2563
2711
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
|
2564
2712
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
2565
|
-
import {
|
|
2713
|
+
import { dashboardStore, policyFor, requireDashboard } from "@/lib/dashboard";
|
|
2566
2714
|
import { security, zoneClient } from "@/lib/security";
|
|
2567
2715
|
import { BanForm, UnbanButton } from "./ban-controls";
|
|
2568
2716
|
|
|
@@ -2571,7 +2719,7 @@ const when = (value: string | number | Date | null) =>
|
|
|
2571
2719
|
value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "Until unbanned";
|
|
2572
2720
|
|
|
2573
2721
|
export default async function SecurityPage() {
|
|
2574
|
-
const { role } = await
|
|
2722
|
+
const { role } = await requireDashboard("/dashboard/security");
|
|
2575
2723
|
if (!can(policyFor("SecurityEvent"), role, "read")) {
|
|
2576
2724
|
return (
|
|
2577
2725
|
<Alert>
|
|
@@ -2584,7 +2732,7 @@ export default async function SecurityPage() {
|
|
|
2584
2732
|
|
|
2585
2733
|
const [bans, events, edge] = await Promise.all([
|
|
2586
2734
|
security().bans(),
|
|
2587
|
-
|
|
2735
|
+
dashboardStore("SecurityEvent").list(new URLSearchParams({ sort: "-createdAt", perPage: "20" })),
|
|
2588
2736
|
zone
|
|
2589
2737
|
? Promise.all([zone.bans(), zone.firewallEvents(new Date(Date.now() - 24 * 3600 * 1000))]).then(
|
|
2590
2738
|
([zoneBans, firewall]) => ({ ok: true as const, zoneBans, firewall }),
|
|
@@ -2695,7 +2843,7 @@ export default async function SecurityPage() {
|
|
|
2695
2843
|
<CardHeader>
|
|
2696
2844
|
<CardTitle>Recent events</CardTitle>
|
|
2697
2845
|
<CardDescription>
|
|
2698
|
-
Detectors that tripped, and manual bans. <Link href="/
|
|
2846
|
+
Detectors that tripped, and manual bans. <Link href="/dashboard/security-events" className="underline underline-offset-4">All events</Link>
|
|
2699
2847
|
</CardDescription>
|
|
2700
2848
|
</CardHeader>
|
|
2701
2849
|
<CardContent>
|
|
@@ -2805,43 +2953,43 @@ function addSecurityBindings(source) {
|
|
|
2805
2953
|
}
|
|
2806
2954
|
return { text, added };
|
|
2807
2955
|
}
|
|
2808
|
-
var renderAdminNavBlock = () => `export interface
|
|
2956
|
+
var renderAdminNavBlock = () => `export interface DashboardLink {
|
|
2809
2957
|
label: string;
|
|
2810
2958
|
href: string;
|
|
2811
|
-
/** A resource icon name, e.g. "shield" (see components/
|
|
2959
|
+
/** A resource icon name, e.g. "shield" (see components/dashboard/resource-icon.tsx). */
|
|
2812
2960
|
icon: string;
|
|
2813
2961
|
}
|
|
2814
2962
|
|
|
2815
|
-
export const
|
|
2963
|
+
export const generatedDashboardLinks: DashboardLink[] = [{ label: "Security", href: "/dashboard/security", icon: "shield" }];
|
|
2816
2964
|
`;
|
|
2817
2965
|
|
|
2818
2966
|
// src/commands/gen-security.ts
|
|
2819
2967
|
function securityFiles(appName) {
|
|
2820
2968
|
return [
|
|
2821
2969
|
{ path: "lib/security.ts", content: renderLibSecurity(appName) },
|
|
2822
|
-
{ path: "app/
|
|
2823
|
-
{ path: "app/
|
|
2824
|
-
{ path: "app/
|
|
2970
|
+
{ path: "app/dashboard/security/page.tsx", content: renderSecurityPage() },
|
|
2971
|
+
{ path: "app/dashboard/security/actions.ts", content: renderSecurityActions() },
|
|
2972
|
+
{ path: "app/dashboard/security/ban-controls.tsx", content: renderBanControls() }
|
|
2825
2973
|
];
|
|
2826
2974
|
}
|
|
2827
2975
|
var PASS_THROUGH_HEADER = /^\/\/ Request protection for worker\/index\.ts\.[\s\S]*?\n(?=\/\/ generated:start)/;
|
|
2828
|
-
function writeOnce(appRoot, relative2, content,
|
|
2976
|
+
function writeOnce(appRoot, relative2, content, log2) {
|
|
2829
2977
|
const path = join12(appRoot, relative2);
|
|
2830
2978
|
if (existsSync13(path)) {
|
|
2831
|
-
|
|
2979
|
+
log2(`${pc7.dim("kept".padEnd(9))} ${relative2} (already exists)`);
|
|
2832
2980
|
return;
|
|
2833
2981
|
}
|
|
2834
2982
|
mkdirSync6(dirname7(path), { recursive: true });
|
|
2835
2983
|
writeFileSync7(path, content);
|
|
2836
|
-
|
|
2984
|
+
log2(`${pc7.green("create".padEnd(9))} ${relative2}`);
|
|
2837
2985
|
}
|
|
2838
2986
|
async function genSecurity(options2 = {}) {
|
|
2839
|
-
const
|
|
2987
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2840
2988
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2841
2989
|
const appName = JSON.parse(readFileSync9(join12(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
2842
|
-
writeOnce(appRoot, "security.config.ts", renderSecurityConfig(),
|
|
2843
|
-
writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(),
|
|
2844
|
-
writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY),
|
|
2990
|
+
writeOnce(appRoot, "security.config.ts", renderSecurityConfig(), log2);
|
|
2991
|
+
writeOnce(appRoot, descriptorPath("SecurityEvent"), renderSecurityDescriptor(), log2);
|
|
2992
|
+
writeOnce(appRoot, policyPath("SecurityEvent"), renderPolicy("SecurityEvent", SECURITY_EVENT_POLICY), log2);
|
|
2845
2993
|
const files = [];
|
|
2846
2994
|
const write = (path, content, header) => {
|
|
2847
2995
|
const absolute = join12(appRoot, path);
|
|
@@ -2852,12 +3000,12 @@ async function genSecurity(options2 = {}) {
|
|
|
2852
3000
|
const outcome = writeGenerated(absolute, content, { header, force: options2.force });
|
|
2853
3001
|
files.push({ path, status: "missing", outcome });
|
|
2854
3002
|
const label = outcome === "create" ? pc7.green("create") : outcome === "update" ? pc7.yellow("update") : pc7.dim("identical");
|
|
2855
|
-
|
|
3003
|
+
log2(`${label.padEnd(18)} ${path}`);
|
|
2856
3004
|
};
|
|
2857
3005
|
for (const { path, content } of securityFiles(appName)) write(path, content, securityHeader());
|
|
2858
|
-
const navPath = join12(appRoot, "lib/
|
|
2859
|
-
if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/
|
|
2860
|
-
else
|
|
3006
|
+
const navPath = join12(appRoot, "lib/dashboard-nav.ts");
|
|
3007
|
+
if (existsSync13(navPath) && splitMarkers(readFileSync9(navPath, "utf8"))) write("lib/dashboard-nav.ts", renderAdminNavBlock());
|
|
3008
|
+
else log2(pc7.yellow(`lib/dashboard-nav.ts not found: link to /dashboard/security from your dashboard sidebar yourself.`));
|
|
2861
3009
|
const wranglerPath = join12(appRoot, "wrangler.jsonc");
|
|
2862
3010
|
let bindings = [];
|
|
2863
3011
|
if (existsSync13(wranglerPath)) {
|
|
@@ -2865,27 +3013,27 @@ async function genSecurity(options2 = {}) {
|
|
|
2865
3013
|
bindings = added;
|
|
2866
3014
|
if (added.length) {
|
|
2867
3015
|
writeFileSync7(wranglerPath, text);
|
|
2868
|
-
for (const line of added)
|
|
3016
|
+
for (const line of added) log2(`${pc7.yellow("update".padEnd(9))} wrangler.jsonc (${line})`);
|
|
2869
3017
|
}
|
|
2870
3018
|
} else {
|
|
2871
|
-
|
|
3019
|
+
log2(pc7.yellow("wrangler.jsonc not found: add the FLARE_SECURITY, FLARE_SECURITY_MONITOR and FLARE_RATE_LIMIT bindings yourself."));
|
|
2872
3020
|
}
|
|
2873
3021
|
const all = await loadResources(appRoot);
|
|
2874
|
-
ensureSupportFiles(appRoot,
|
|
2875
|
-
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }),
|
|
3022
|
+
ensureSupportFiles(appRoot, log2);
|
|
3023
|
+
logResults(applyPlan(appRoot, planFiles(all, await loadPolicies(appRoot)), { force: options2.force }), log2);
|
|
2876
3024
|
let migrations = [];
|
|
2877
3025
|
if (!options2.skipMigration) {
|
|
2878
3026
|
const result = await generateSchemaMigration(appRoot, "security");
|
|
2879
3027
|
migrations = result.files;
|
|
2880
|
-
for (const file of migrations)
|
|
2881
|
-
for (const repair of result.repairs)
|
|
3028
|
+
for (const file of migrations) log2(`${pc7.green("create".padEnd(9))} ${file}`);
|
|
3029
|
+
for (const repair of result.repairs) log2(pc7.dim(`repaired ${repair}`));
|
|
2882
3030
|
}
|
|
2883
|
-
|
|
3031
|
+
log2(
|
|
2884
3032
|
[
|
|
2885
3033
|
"",
|
|
2886
3034
|
`Next:`,
|
|
2887
3035
|
` 1. ${pc7.bold("flare migrate")} to create the security_events table.`,
|
|
2888
|
-
` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/
|
|
3036
|
+
` 2. ${pc7.bold("flare dev")}, then open ${pc7.bold("/dashboard/security")}. Tune detectors in security.config.ts.`,
|
|
2889
3037
|
` 3. Optional zone layer: deploy with FLARE_SECURITY_ZONE_ID and FLARE_SECURITY_API_TOKEN set (a token scoped to that`,
|
|
2890
3038
|
` one zone with Zone WAF Edit, Firewall Services Edit, Analytics Read, Zone Read). ${pc7.bold("flare deploy")} pushes the`,
|
|
2891
3039
|
` zone rules and uploads both as secrets.`
|
|
@@ -2894,12 +3042,103 @@ async function genSecurity(options2 = {}) {
|
|
|
2894
3042
|
return { files, migrations, bindings };
|
|
2895
3043
|
}
|
|
2896
3044
|
|
|
3045
|
+
// src/commands/theme.ts
|
|
3046
|
+
import { existsSync as existsSync14, readFileSync as readFileSync10, writeFileSync as writeFileSync8 } from "fs";
|
|
3047
|
+
import { join as join13 } from "path";
|
|
3048
|
+
import pc8 from "picocolors";
|
|
3049
|
+
var THEME_LINE = /theme: "([a-z]+)" as ThemeName,/;
|
|
3050
|
+
function setTheme(name, cwd = process.cwd(), log2 = console.log) {
|
|
3051
|
+
const appRoot = findAppRoot(cwd);
|
|
3052
|
+
const path = join13(appRoot, "lib/site.ts");
|
|
3053
|
+
if (!existsSync14(path)) throw new Error("lib/site.ts not found: this app was created before themes. Add it from the Flare app template.");
|
|
3054
|
+
const source = readFileSync10(path, "utf8");
|
|
3055
|
+
const current = THEME_LINE.exec(source)?.[1];
|
|
3056
|
+
if (!name) {
|
|
3057
|
+
for (const [theme2, description] of Object.entries(THEMES)) {
|
|
3058
|
+
log2(`${theme2 === current ? pc8.green("\u25CF") : " "} ${theme2.padEnd(8)} ${pc8.dim(description)}`);
|
|
3059
|
+
}
|
|
3060
|
+
log2(pc8.dim("\nSwitch with `flare theme <name>`. FLARE_THEME in .env overrides it for a build."));
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
const theme = parseTheme(name);
|
|
3064
|
+
if (!current) throw new Error('lib/site.ts has no `theme: "\u2026" as ThemeName,` line to change.');
|
|
3065
|
+
writeFileSync8(path, source.replace(THEME_LINE, `theme: "${theme}" as ThemeName,`));
|
|
3066
|
+
log2(`${pc8.green("\u2714")} Theme is now ${pc8.bold(theme)}. Restart \`flare dev\` (or rebuild) to see it.`);
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
// src/commands/create-prompts.ts
|
|
3070
|
+
import * as prompts2 from "@clack/prompts";
|
|
3071
|
+
import pc9 from "picocolors";
|
|
3072
|
+
import { FLARE_VERSION as FLARE_VERSION2 } from "@flaredev/core";
|
|
3073
|
+
var canPrompt = (yes) => !yes && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
3074
|
+
var AUTH_HINTS = {
|
|
3075
|
+
"magic-link": "a sign-in link by email",
|
|
3076
|
+
"email-otp": "a 6-digit sign-in code by email",
|
|
3077
|
+
passkeys: "Face ID, Touch ID, Windows Hello",
|
|
3078
|
+
"2fa-app": "authenticator app, with backup codes",
|
|
3079
|
+
"2fa-email": "a code by email as the second step"
|
|
3080
|
+
};
|
|
3081
|
+
var AUTH_LABELS = {
|
|
3082
|
+
"magic-link": "Magic links",
|
|
3083
|
+
"email-otp": "Email codes",
|
|
3084
|
+
passkeys: "Passkeys",
|
|
3085
|
+
"2fa-app": "Two-factor: authenticator app",
|
|
3086
|
+
"2fa-email": "Two-factor: email code"
|
|
3087
|
+
};
|
|
3088
|
+
function stopIfCancelled(value) {
|
|
3089
|
+
if (prompts2.isCancel(value)) {
|
|
3090
|
+
prompts2.cancel("Cancelled: nothing was created.");
|
|
3091
|
+
process.exit(1);
|
|
3092
|
+
}
|
|
3093
|
+
return value;
|
|
3094
|
+
}
|
|
3095
|
+
async function askCreateQuestions(given, appName) {
|
|
3096
|
+
prompts2.intro(`${pc9.bgRed(pc9.white(" Flare "))} ${pc9.dim(`v${FLARE_VERSION2}`)}${appName ? ` ${pc9.dim("\xB7")} ${pc9.bold(appName)}` : ""}`);
|
|
3097
|
+
const answers = { ...given };
|
|
3098
|
+
if (given.theme === void 0) {
|
|
3099
|
+
answers.theme = stopIfCancelled(
|
|
3100
|
+
await prompts2.select({
|
|
3101
|
+
message: "Pick a look",
|
|
3102
|
+
initialValue: "default",
|
|
3103
|
+
options: Object.entries(THEMES).map(([value, hint]) => ({ value, label: value, hint }))
|
|
3104
|
+
})
|
|
3105
|
+
);
|
|
3106
|
+
}
|
|
3107
|
+
if (given.auth === void 0) {
|
|
3108
|
+
const methods = stopIfCancelled(
|
|
3109
|
+
await prompts2.multiselect({
|
|
3110
|
+
message: `How can people sign in? ${pc9.dim("Email and password is always on")}`,
|
|
3111
|
+
initialValues: DEFAULT_AUTH_METHODS,
|
|
3112
|
+
required: false,
|
|
3113
|
+
options: Object.keys(AUTH_METHODS).map((value) => ({
|
|
3114
|
+
value,
|
|
3115
|
+
label: AUTH_LABELS[value],
|
|
3116
|
+
hint: AUTH_HINTS[value]
|
|
3117
|
+
}))
|
|
3118
|
+
})
|
|
3119
|
+
);
|
|
3120
|
+
answers.auth = methods.length ? methods.join(",") : "none";
|
|
3121
|
+
}
|
|
3122
|
+
if (given.authProviders === void 0) {
|
|
3123
|
+
const providers = stopIfCancelled(
|
|
3124
|
+
await prompts2.multiselect({
|
|
3125
|
+
message: `Social sign-in? ${pc9.dim("each needs its own credentials later")}`,
|
|
3126
|
+
initialValues: [],
|
|
3127
|
+
required: false,
|
|
3128
|
+
options: Object.entries(AUTH_PROVIDERS).map(([value, { label }]) => ({ value, label }))
|
|
3129
|
+
})
|
|
3130
|
+
);
|
|
3131
|
+
answers.authProviders = providers.join(",");
|
|
3132
|
+
}
|
|
3133
|
+
return answers;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
2897
3136
|
// src/commands/gen.ts
|
|
2898
|
-
import { existsSync as
|
|
2899
|
-
import { dirname as dirname8, join as
|
|
3137
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync7, writeFileSync as writeFileSync9 } from "fs";
|
|
3138
|
+
import { dirname as dirname8, join as join14 } from "path";
|
|
2900
3139
|
import { camelCase as camelCase4, defineResource as defineResource2, relationGraph as relationGraph2 } from "@flaredev/core";
|
|
2901
|
-
import
|
|
2902
|
-
function validateFields(name, fields, others,
|
|
3140
|
+
import pc10 from "picocolors";
|
|
3141
|
+
function validateFields(name, fields, others, log2) {
|
|
2903
3142
|
const known = /* @__PURE__ */ new Set([name, ...others.map((resource) => resource.name)]);
|
|
2904
3143
|
for (const field of fields) {
|
|
2905
3144
|
if (field.kind === "belongsTo" && !known.has(field.target)) {
|
|
@@ -2909,16 +3148,16 @@ function validateFields(name, fields, others, log) {
|
|
|
2909
3148
|
const candidate = defineResource2({ name, fields: Object.fromEntries(fields.map((f) => [f.key, toField(f)])) });
|
|
2910
3149
|
const graph = relationGraph2([...others, candidate]);
|
|
2911
3150
|
for (const pending of graph.pending.filter((p) => p.resource === name)) {
|
|
2912
|
-
|
|
3151
|
+
log2(pc10.dim(`note: ${name}.${pending.key} will link up once ${pending.target} exists (give it a ${camelCase4(name)}Id: belongsTo(${name}) field).`));
|
|
2913
3152
|
}
|
|
2914
3153
|
}
|
|
2915
3154
|
async function genResource(rawName, options2) {
|
|
2916
|
-
const
|
|
3155
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2917
3156
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2918
3157
|
const name = resourceName(rawName);
|
|
2919
3158
|
const relativePath = descriptorPath(name);
|
|
2920
|
-
const path =
|
|
2921
|
-
const exists =
|
|
3159
|
+
const path = join14(appRoot, relativePath);
|
|
3160
|
+
const exists = existsSync15(path);
|
|
2922
3161
|
if (!exists && !options2.fields) {
|
|
2923
3162
|
throw new Error(`--fields is required, e.g. flare gen resource ${name} --fields "name:string, email:string"`);
|
|
2924
3163
|
}
|
|
@@ -2926,15 +3165,15 @@ async function genResource(rawName, options2) {
|
|
|
2926
3165
|
const others = existing.filter(({ resource }) => resource.name !== name).map(({ resource }) => resource);
|
|
2927
3166
|
if (options2.fields) {
|
|
2928
3167
|
const fields = parseFields(options2.fields);
|
|
2929
|
-
validateFields(name, fields, others,
|
|
3168
|
+
validateFields(name, fields, others, log2);
|
|
2930
3169
|
if (!exists) {
|
|
2931
3170
|
mkdirSync7(dirname8(path), { recursive: true });
|
|
2932
|
-
|
|
2933
|
-
|
|
3171
|
+
writeFileSync9(path, renderDescriptor(name, fields));
|
|
3172
|
+
log2(`${pc10.green("create".padEnd(9))} ${relativePath}`);
|
|
2934
3173
|
} else {
|
|
2935
3174
|
try {
|
|
2936
3175
|
const outcome = writeGenerated(path, renderFieldsBlock(fields), { force: options2.force });
|
|
2937
|
-
|
|
3176
|
+
log2(`${(outcome === "identical" ? pc10.dim : pc10.yellow)(outcome.padEnd(9))} ${relativePath}`);
|
|
2938
3177
|
} catch (error) {
|
|
2939
3178
|
if (error instanceof DriftError) {
|
|
2940
3179
|
throw new Error(
|
|
@@ -2950,9 +3189,9 @@ async function genResource(rawName, options2) {
|
|
|
2950
3189
|
try {
|
|
2951
3190
|
const all = await loadResources(appRoot);
|
|
2952
3191
|
table = all.find(({ resource }) => resource.name === name).resource.table;
|
|
2953
|
-
ensureSupportFiles(appRoot,
|
|
3192
|
+
ensureSupportFiles(appRoot, log2);
|
|
2954
3193
|
written = applyPlan(appRoot, planFiles(all), { force: options2.force });
|
|
2955
|
-
logResults(written,
|
|
3194
|
+
logResults(written, log2);
|
|
2956
3195
|
} catch (error) {
|
|
2957
3196
|
const hint = exists ? "" : `
|
|
2958
3197
|
(${relativePath} was written; fix it and run \`flare sync-types\`.)`;
|
|
@@ -2960,75 +3199,75 @@ async function genResource(rawName, options2) {
|
|
|
2960
3199
|
}
|
|
2961
3200
|
const skipped = written.filter((r) => r.outcome.startsWith("skipped"));
|
|
2962
3201
|
if (skipped.length) {
|
|
2963
|
-
|
|
3202
|
+
log2(pc10.yellow(`
|
|
2964
3203
|
Skipped ${skipped.length} hand-edited file(s); run \`flare sync-types\` for details.`));
|
|
2965
3204
|
}
|
|
2966
3205
|
let migrations = [];
|
|
2967
3206
|
if (!options2.skipMigration) {
|
|
2968
3207
|
const result = await generateSchemaMigration(appRoot, `${exists ? "update" : "create"}_${table}`);
|
|
2969
3208
|
migrations = result.files;
|
|
2970
|
-
for (const file of migrations)
|
|
2971
|
-
for (const repair of result.repairs)
|
|
2972
|
-
if (migrations.length)
|
|
2973
|
-
Next: ${
|
|
2974
|
-
else if (exists)
|
|
3209
|
+
for (const file of migrations) log2(`${pc10.green("create".padEnd(9))} ${file}`);
|
|
3210
|
+
for (const repair of result.repairs) log2(pc10.dim(`repaired ${repair}`));
|
|
3211
|
+
if (migrations.length) log2(`
|
|
3212
|
+
Next: ${pc10.bold("flare migrate")} to apply it locally.`);
|
|
3213
|
+
else if (exists) log2(pc10.dim("\nNo table changes, so no migration."));
|
|
2975
3214
|
}
|
|
2976
3215
|
const descriptor = { path: relativePath, status: exists ? "outdated" : "missing", outcome: exists ? "update" : "create" };
|
|
2977
3216
|
return { name, mode: exists ? "update" : "create", written: [descriptor, ...written], migrations };
|
|
2978
3217
|
}
|
|
2979
3218
|
|
|
2980
3219
|
// src/commands/gen-migration.ts
|
|
2981
|
-
import { existsSync as
|
|
2982
|
-
import { basename as basename2, join as
|
|
3220
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync8, writeFileSync as writeFileSync10 } from "fs";
|
|
3221
|
+
import { basename as basename2, join as join15 } from "path";
|
|
2983
3222
|
import { snakeCase } from "@flaredev/core";
|
|
2984
|
-
import
|
|
3223
|
+
import pc11 from "picocolors";
|
|
2985
3224
|
var DOWN_TEMPLATE = (up) => `-- Rollback for ${up}, run by \`flare migrate:rollback\`.
|
|
2986
3225
|
-- Write SQL that undoes the up migration, e.g.:
|
|
2987
3226
|
-- DROP INDEX IF EXISTS \`contacts_phone_idx\`;
|
|
2988
3227
|
-- While this file has no statements, rollback of ${up} is refused.
|
|
2989
3228
|
`;
|
|
2990
3229
|
async function genMigration(rawName, options2 = {}) {
|
|
2991
|
-
const
|
|
3230
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
2992
3231
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
2993
3232
|
const name = snakeCase(rawName);
|
|
2994
3233
|
if (!name) throw new Error(`Invalid migration name "${rawName}". Use letters, digits, and underscores.`);
|
|
2995
3234
|
const { files, repairs } = await generateSchemaMigration(appRoot, name, { custom: !options2.fromSchema });
|
|
2996
3235
|
if (files.length === 0) {
|
|
2997
|
-
|
|
3236
|
+
log2(options2.fromSchema ? "No schema changes, so no migration was created." : "drizzle-kit didn't create a migration.");
|
|
2998
3237
|
return [];
|
|
2999
3238
|
}
|
|
3000
|
-
for (const file of files)
|
|
3001
|
-
for (const repair of repairs)
|
|
3239
|
+
for (const file of files) log2(`${pc11.green("create".padEnd(9))} ${file}`);
|
|
3240
|
+
for (const repair of repairs) log2(pc11.dim(`repaired ${repair}`));
|
|
3002
3241
|
if (!options2.fromSchema) {
|
|
3003
|
-
const downDir =
|
|
3242
|
+
const downDir = join15(appRoot, "migrations", "down");
|
|
3004
3243
|
mkdirSync8(downDir, { recursive: true });
|
|
3005
3244
|
for (const file of files) {
|
|
3006
|
-
const down =
|
|
3007
|
-
if (
|
|
3008
|
-
|
|
3009
|
-
|
|
3245
|
+
const down = join15(downDir, basename2(file));
|
|
3246
|
+
if (existsSync16(down)) continue;
|
|
3247
|
+
writeFileSync10(down, DOWN_TEMPLATE(basename2(file)));
|
|
3248
|
+
log2(`${pc11.green("create".padEnd(9))} migrations/down/${basename2(file)}`);
|
|
3010
3249
|
}
|
|
3011
|
-
|
|
3012
|
-
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${
|
|
3250
|
+
log2(`
|
|
3251
|
+
Write your SQL in ${files[0]} (and its rollback in migrations/down/), then run ${pc11.bold("flare migrate")}.`);
|
|
3013
3252
|
} else {
|
|
3014
|
-
|
|
3015
|
-
Next: ${
|
|
3253
|
+
log2(`
|
|
3254
|
+
Next: ${pc11.bold("flare migrate")}.`);
|
|
3016
3255
|
}
|
|
3017
3256
|
return files;
|
|
3018
3257
|
}
|
|
3019
3258
|
|
|
3020
3259
|
// src/commands/gen-policy.ts
|
|
3021
|
-
import { existsSync as
|
|
3022
|
-
import { dirname as dirname9, join as
|
|
3260
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync9, writeFileSync as writeFileSync11 } from "fs";
|
|
3261
|
+
import { dirname as dirname9, join as join16 } from "path";
|
|
3023
3262
|
import { definePolicy } from "@flaredev/core";
|
|
3024
|
-
import
|
|
3263
|
+
import pc12 from "picocolors";
|
|
3025
3264
|
function parseRoles(input, what = "--roles") {
|
|
3026
3265
|
const roles = (input ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
3027
3266
|
if (roles.length === 0) throw new Error(`${what} is required, e.g. ${what} admin,staff`);
|
|
3028
3267
|
return [...new Set(roles)];
|
|
3029
3268
|
}
|
|
3030
3269
|
async function genPolicy(rawName, options2) {
|
|
3031
|
-
const
|
|
3270
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3032
3271
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3033
3272
|
const name = resourceName(rawName);
|
|
3034
3273
|
const resources = await loadResources(appRoot);
|
|
@@ -3037,8 +3276,8 @@ async function genPolicy(rawName, options2) {
|
|
|
3037
3276
|
throw new Error(`No resource "${name}". Generate it first. Known resources: ${known}.`);
|
|
3038
3277
|
}
|
|
3039
3278
|
const relative2 = policyPath(name);
|
|
3040
|
-
const path =
|
|
3041
|
-
const exists =
|
|
3279
|
+
const path = join16(appRoot, relative2);
|
|
3280
|
+
const exists = existsSync17(path);
|
|
3042
3281
|
let roles;
|
|
3043
3282
|
let deleteRoles;
|
|
3044
3283
|
if (options2.roles || !exists) {
|
|
@@ -3050,12 +3289,12 @@ async function genPolicy(rawName, options2) {
|
|
|
3050
3289
|
const block = renderPolicyBlock({ read: roles, create: roles, update: roles, delete: deleteRoles });
|
|
3051
3290
|
if (!exists) {
|
|
3052
3291
|
mkdirSync9(dirname9(path), { recursive: true });
|
|
3053
|
-
|
|
3054
|
-
|
|
3292
|
+
writeFileSync11(path, renderPolicy(name, { read: roles, create: roles, update: roles, delete: deleteRoles }));
|
|
3293
|
+
log2(`${pc12.green("create".padEnd(9))} ${relative2}`);
|
|
3055
3294
|
} else {
|
|
3056
3295
|
try {
|
|
3057
3296
|
const outcome = writeGenerated(path, block, { force: options2.force });
|
|
3058
|
-
|
|
3297
|
+
log2(`${(outcome === "identical" ? pc12.dim : pc12.yellow)(outcome.padEnd(9))} ${relative2}`);
|
|
3059
3298
|
} catch (error) {
|
|
3060
3299
|
if (error instanceof DriftError) {
|
|
3061
3300
|
throw new Error(`The roles in ${relative2} were edited by hand, so --roles won't overwrite them. Edit the file directly, or pass --force.`);
|
|
@@ -3065,17 +3304,17 @@ async function genPolicy(rawName, options2) {
|
|
|
3065
3304
|
}
|
|
3066
3305
|
}
|
|
3067
3306
|
const policies = await loadPolicies(appRoot);
|
|
3068
|
-
logResults(applyPlan(appRoot, planFiles(resources, policies)),
|
|
3069
|
-
|
|
3070
|
-
${
|
|
3307
|
+
logResults(applyPlan(appRoot, planFiles(resources, policies)), log2);
|
|
3308
|
+
log2(`
|
|
3309
|
+
${pc12.dim("Roles come from `flare role:add`; the API and the admin both enforce this policy.")}`);
|
|
3071
3310
|
return { name, path: relative2 };
|
|
3072
3311
|
}
|
|
3073
3312
|
|
|
3074
3313
|
// src/commands/migrate.ts
|
|
3075
|
-
import { existsSync as
|
|
3314
|
+
import { existsSync as existsSync18, mkdtempSync, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync12 } from "fs";
|
|
3076
3315
|
import { tmpdir } from "os";
|
|
3077
|
-
import { join as
|
|
3078
|
-
import
|
|
3316
|
+
import { join as join17 } from "path";
|
|
3317
|
+
import pc13 from "picocolors";
|
|
3079
3318
|
|
|
3080
3319
|
// src/generator/down.ts
|
|
3081
3320
|
var IDENT = '(`[^`]+`|"[^"]+"|\\[[^\\]]+\\]|[A-Za-z_][\\w$]*)';
|
|
@@ -3191,28 +3430,28 @@ ${result.output.trim()}`);
|
|
|
3191
3430
|
async function rollback(options2 = {}) {
|
|
3192
3431
|
const steps = options2.steps ?? 1;
|
|
3193
3432
|
if (!Number.isInteger(steps) || steps < 1) throw new Error("--steps must be a positive integer.");
|
|
3194
|
-
const { appRoot, db, target, where, wrangler, log } = context(options2);
|
|
3195
|
-
const migrationsDir =
|
|
3433
|
+
const { appRoot, db, target, where, wrangler, log: log2 } = context(options2);
|
|
3434
|
+
const migrationsDir = join17(appRoot, db.migrations_dir ?? "migrations");
|
|
3196
3435
|
const applied = await appliedMigrations(options2, steps);
|
|
3197
3436
|
if (applied.length === 0) {
|
|
3198
|
-
|
|
3437
|
+
log2("Nothing to roll back.");
|
|
3199
3438
|
return 0;
|
|
3200
3439
|
}
|
|
3201
3440
|
const plan = [];
|
|
3202
3441
|
for (const { name } of applied) {
|
|
3203
3442
|
if (!/^[\w.-]+\.sql$/.test(name)) throw new Error(`Unexpected migration name "${name}" in d1_migrations.`);
|
|
3204
|
-
const handWritten =
|
|
3205
|
-
if (
|
|
3206
|
-
const sql2 =
|
|
3443
|
+
const handWritten = join17(migrationsDir, "down", name);
|
|
3444
|
+
if (existsSync18(handWritten)) {
|
|
3445
|
+
const sql2 = readFileSync11(handWritten, "utf8");
|
|
3207
3446
|
if (splitStatements(sql2).length === 0) {
|
|
3208
3447
|
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.`);
|
|
3209
3448
|
}
|
|
3210
3449
|
plan.push({ name, sql: sql2, source: `migrations/down/${name}` });
|
|
3211
3450
|
continue;
|
|
3212
3451
|
}
|
|
3213
|
-
const upPath =
|
|
3214
|
-
if (!
|
|
3215
|
-
const down = deriveDown(
|
|
3452
|
+
const upPath = join17(migrationsDir, name);
|
|
3453
|
+
if (!existsSync18(upPath)) throw new Error(`${name} was applied but migrations/${name} is missing, so it can't be reversed.`);
|
|
3454
|
+
const down = deriveDown(readFileSync11(upPath, "utf8"));
|
|
3216
3455
|
if (!down.reversible) {
|
|
3217
3456
|
throw new Error(
|
|
3218
3457
|
`Can't roll back ${name}: ${down.reason}
|
|
@@ -3221,37 +3460,37 @@ Write the reverse SQL to migrations/down/${name} and run the rollback again. Not
|
|
|
3221
3460
|
}
|
|
3222
3461
|
plan.push({ name, sql: down.sql, source: "derived" });
|
|
3223
3462
|
}
|
|
3224
|
-
|
|
3463
|
+
log2(`${pc13.bold(`Rolling back ${plan.length} migration(s) on ${db.database_name} (${where}):`)}`);
|
|
3225
3464
|
for (const step of plan) {
|
|
3226
|
-
|
|
3227
|
-
for (const line of step.sql.trim().split("\n"))
|
|
3465
|
+
log2(` ${step.name} ${pc13.dim(`(${step.source})`)}`);
|
|
3466
|
+
for (const line of step.sql.trim().split("\n")) log2(pc13.dim(` ${line}`));
|
|
3228
3467
|
}
|
|
3229
3468
|
if (options2.remote && !options2.yes) {
|
|
3230
|
-
|
|
3469
|
+
log2(pc13.yellow("\nThis changes the remote database. Re-run with --yes to proceed."));
|
|
3231
3470
|
return 1;
|
|
3232
3471
|
}
|
|
3233
3472
|
const script = plan.map(({ name, sql: sql2 }) => `${sql2.trim()}
|
|
3234
3473
|
DELETE FROM d1_migrations WHERE name = '${name.replace(/'/g, "''")}';`).join("\n") + "\n";
|
|
3235
|
-
const dir = mkdtempSync(
|
|
3474
|
+
const dir = mkdtempSync(join17(tmpdir(), "flare-rollback-"));
|
|
3236
3475
|
try {
|
|
3237
|
-
const file =
|
|
3238
|
-
|
|
3476
|
+
const file = join17(dir, "rollback.sql");
|
|
3477
|
+
writeFileSync12(file, script);
|
|
3239
3478
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3240
3479
|
if (result.code !== 0) throw new Error(`Rollback failed:
|
|
3241
3480
|
${result.output.trim()}`);
|
|
3242
3481
|
} finally {
|
|
3243
3482
|
rmSync2(dir, { recursive: true, force: true });
|
|
3244
3483
|
}
|
|
3245
|
-
|
|
3484
|
+
log2(pc13.green(`
|
|
3246
3485
|
Rolled back ${plan.map((step) => step.name).join(", ")}.`));
|
|
3247
3486
|
return 0;
|
|
3248
3487
|
}
|
|
3249
3488
|
|
|
3250
3489
|
// src/commands/rm.ts
|
|
3251
|
-
import { existsSync as
|
|
3252
|
-
import { dirname as dirname10, join as
|
|
3490
|
+
import { existsSync as existsSync19, readdirSync as readdirSync7, readFileSync as readFileSync12, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
|
|
3491
|
+
import { dirname as dirname10, join as join18 } from "path";
|
|
3253
3492
|
import { relationGraph as relationGraph3 } from "@flaredev/core";
|
|
3254
|
-
import
|
|
3493
|
+
import pc14 from "picocolors";
|
|
3255
3494
|
var squash = (text) => text.replace(/\r\n/g, "\n").replace(/\s+/g, " ").trim();
|
|
3256
3495
|
function handWrittenReason(source, header, options2 = {}) {
|
|
3257
3496
|
const parts = splitMarkers(source);
|
|
@@ -3268,16 +3507,16 @@ function handWrittenReason(source, header, options2 = {}) {
|
|
|
3268
3507
|
return null;
|
|
3269
3508
|
}
|
|
3270
3509
|
function removeEmptyDirs(appRoot, start) {
|
|
3271
|
-
let dir = dirname10(
|
|
3272
|
-
const roots = [
|
|
3510
|
+
let dir = dirname10(join18(appRoot, start));
|
|
3511
|
+
const roots = [join18(appRoot, "app", "api"), join18(appRoot, "app", "dashboard")];
|
|
3273
3512
|
const inside = () => roots.some((root) => dir.startsWith(root) && dir !== root);
|
|
3274
|
-
while (inside() &&
|
|
3513
|
+
while (inside() && existsSync19(dir) && readdirSync7(dir).length === 0) {
|
|
3275
3514
|
rmdirSync(dir);
|
|
3276
3515
|
dir = dirname10(dir);
|
|
3277
3516
|
}
|
|
3278
3517
|
}
|
|
3279
3518
|
async function rmResource(rawName, options2 = {}) {
|
|
3280
|
-
const
|
|
3519
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3281
3520
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3282
3521
|
const name = resourceName(rawName);
|
|
3283
3522
|
const all = await loadResources(appRoot);
|
|
@@ -3300,7 +3539,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
3300
3539
|
if (entry) {
|
|
3301
3540
|
owned.add(entry.file);
|
|
3302
3541
|
for (const file of planFiles(all)) {
|
|
3303
|
-
if (
|
|
3542
|
+
if (existsSync19(join18(appRoot, file.path)) && file.header === resourceHeader(name)) owned.add(file.path);
|
|
3304
3543
|
}
|
|
3305
3544
|
}
|
|
3306
3545
|
for (const orphan of findOrphans(appRoot, remaining)) if (orphan.resource === name) owned.add(orphan.path);
|
|
@@ -3309,7 +3548,7 @@ async function rmResource(rawName, options2 = {}) {
|
|
|
3309
3548
|
if (!entry && owned.size === 0) throw new Error(`No resource "${name}" (no ${descriptorPath(name)} and no generated files).`);
|
|
3310
3549
|
const findings = [];
|
|
3311
3550
|
for (const path of owned) {
|
|
3312
|
-
const source =
|
|
3551
|
+
const source = readFileSync12(join18(appRoot, path), "utf8");
|
|
3313
3552
|
let wrapper;
|
|
3314
3553
|
if (entry && path === entry.file) {
|
|
3315
3554
|
const standard = splitMarkers(renderDescriptor(name, [{ key: "placeholder", kind: "string", required: true, unique: false }]));
|
|
@@ -3330,34 +3569,34 @@ Move what you want to keep, or pass --force to delete anyway.`);
|
|
|
3330
3569
|
}
|
|
3331
3570
|
const removed = [...owned].sort();
|
|
3332
3571
|
for (const path of removed) {
|
|
3333
|
-
unlinkSync2(
|
|
3572
|
+
unlinkSync2(join18(appRoot, path));
|
|
3334
3573
|
removeEmptyDirs(appRoot, path);
|
|
3335
|
-
|
|
3574
|
+
log2(`${pc14.red("remove".padEnd(9))} ${path}`);
|
|
3336
3575
|
}
|
|
3337
|
-
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)),
|
|
3576
|
+
logResults(applyPlan(appRoot, planFiles(remaining, policy ? await loadPolicies(appRoot) : void 0)), log2);
|
|
3338
3577
|
const table = entry ? tableExport(entry.resource) : void 0;
|
|
3339
|
-
if (table &&
|
|
3340
|
-
const seeds = readdirSync7(
|
|
3341
|
-
(file) => new RegExp(`\\b${table}\\b`).test(
|
|
3578
|
+
if (table && existsSync19(join18(appRoot, "seeds"))) {
|
|
3579
|
+
const seeds = readdirSync7(join18(appRoot, "seeds")).filter(
|
|
3580
|
+
(file) => new RegExp(`\\b${table}\\b`).test(readFileSync12(join18(appRoot, "seeds", file), "utf8"))
|
|
3342
3581
|
);
|
|
3343
|
-
if (seeds.length)
|
|
3582
|
+
if (seeds.length) log2(pc14.yellow(`
|
|
3344
3583
|
These seeds still use ${table}: ${seeds.map((s) => `seeds/${s}`).join(", ")}.`));
|
|
3345
3584
|
}
|
|
3346
3585
|
let migrations = [];
|
|
3347
3586
|
if (!options2.skipMigration && entry) {
|
|
3348
3587
|
const result = await generateSchemaMigration(appRoot, `drop_${entry.resource.table}`);
|
|
3349
3588
|
migrations = result.files;
|
|
3350
|
-
for (const file of migrations)
|
|
3351
|
-
for (const repair of result.repairs)
|
|
3352
|
-
if (migrations.length)
|
|
3353
|
-
Next: ${
|
|
3589
|
+
for (const file of migrations) log2(`${pc14.green("create".padEnd(9))} ${file}`);
|
|
3590
|
+
for (const repair of result.repairs) log2(pc14.dim(`repaired ${repair}`));
|
|
3591
|
+
if (migrations.length) log2(`
|
|
3592
|
+
Next: ${pc14.bold("flare migrate")} to drop the table locally.`);
|
|
3354
3593
|
}
|
|
3355
3594
|
return { removed, migrations };
|
|
3356
3595
|
}
|
|
3357
3596
|
|
|
3358
3597
|
// src/commands/role.ts
|
|
3359
3598
|
import { humanize } from "@flaredev/core";
|
|
3360
|
-
import
|
|
3599
|
+
import pc15 from "picocolors";
|
|
3361
3600
|
var ROLE_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3362
3601
|
function roleInsertSql(name, label) {
|
|
3363
3602
|
if (!ROLE_NAME.test(name)) throw new Error(`Invalid role "${name}". Use lowercase letters, digits, _ or -, e.g. admin or support-agent.`);
|
|
@@ -3390,7 +3629,7 @@ ${result.output.trim()}`);
|
|
|
3390
3629
|
return (first?.results ?? []).map((row) => row.name);
|
|
3391
3630
|
}
|
|
3392
3631
|
async function addRole(name, options2 = {}) {
|
|
3393
|
-
const { appRoot, binding, wrangler, target, log } = context2(options2);
|
|
3632
|
+
const { appRoot, binding, wrangler, target, log: log2 } = context2(options2);
|
|
3394
3633
|
const sql2 = roleInsertSql(name, options2.label ?? humanize(name));
|
|
3395
3634
|
const result = await runWrangler(wrangler, ["d1", "execute", binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
|
|
3396
3635
|
if (result.code !== 0) {
|
|
@@ -3402,24 +3641,137 @@ ${result.output.trim()}`);
|
|
|
3402
3641
|
}
|
|
3403
3642
|
const [first] = extractJson(result.output);
|
|
3404
3643
|
const added = first?.results?.[0];
|
|
3405
|
-
|
|
3406
|
-
|
|
3644
|
+
log2(pc15.green(`Role "${added?.name ?? name}" registered (${options2.remote ? "remote" : "local"}).`));
|
|
3645
|
+
log2(pc15.dim(`Assign it with: flare user:role <email> ${added?.name ?? name}${options2.remote ? " --remote" : ""}`));
|
|
3407
3646
|
return 0;
|
|
3408
3647
|
}
|
|
3409
3648
|
|
|
3410
3649
|
// src/commands/seed.ts
|
|
3411
|
-
import { existsSync as
|
|
3650
|
+
import { existsSync as existsSync20, mkdirSync as mkdirSync10, readdirSync as readdirSync8, writeFileSync as writeFileSync13 } from "fs";
|
|
3651
|
+
import { join as join20 } from "path";
|
|
3652
|
+
import { createFake, kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
|
|
3653
|
+
import { createJiti as createJiti4 } from "jiti";
|
|
3654
|
+
import pc16 from "picocolors";
|
|
3655
|
+
|
|
3656
|
+
// src/seed/bulk.ts
|
|
3657
|
+
var MAX_STATEMENT_BYTES = 8e4;
|
|
3658
|
+
var STATEMENTS_PER_CALL = 25;
|
|
3659
|
+
function sqlLiteral(value) {
|
|
3660
|
+
if (value === null || value === void 0) return "NULL";
|
|
3661
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
|
3662
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
3663
|
+
if (value instanceof Date) return String(value.getTime());
|
|
3664
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
3665
|
+
return `'${text.replaceAll("'", "''")}'`;
|
|
3666
|
+
}
|
|
3667
|
+
var tuple = (row, columns) => `(${columns.map((column) => sqlLiteral(row[column])).join(",")})`;
|
|
3668
|
+
function* insertStatements(table, columns, rows, maxBytes = MAX_STATEMENT_BYTES) {
|
|
3669
|
+
const head = `INSERT INTO "${table}" (${columns.map((column) => `"${column}"`).join(",")}) VALUES `;
|
|
3670
|
+
let values = [];
|
|
3671
|
+
let bytes = head.length;
|
|
3672
|
+
for (const row of rows) {
|
|
3673
|
+
const text = tuple(row, columns);
|
|
3674
|
+
if (values.length > 0 && bytes + text.length + 1 > maxBytes) {
|
|
3675
|
+
yield { sql: `${head}${values.join(",")};`, rows: values.length };
|
|
3676
|
+
values = [];
|
|
3677
|
+
bytes = head.length;
|
|
3678
|
+
}
|
|
3679
|
+
values.push(text);
|
|
3680
|
+
bytes += text.length + 1;
|
|
3681
|
+
}
|
|
3682
|
+
if (values.length > 0) yield { sql: `${head}${values.join(",")};`, rows: values.length };
|
|
3683
|
+
}
|
|
3684
|
+
async function bulkInsert(sink, options2) {
|
|
3685
|
+
const perCall = options2.statementsPerCall ?? STATEMENTS_PER_CALL;
|
|
3686
|
+
let written = 0;
|
|
3687
|
+
let batch = [];
|
|
3688
|
+
let batchRows = 0;
|
|
3689
|
+
const flush = async () => {
|
|
3690
|
+
if (batch.length === 0) return;
|
|
3691
|
+
await sink.exec(batch.join("\n"));
|
|
3692
|
+
written += batchRows;
|
|
3693
|
+
batch = [];
|
|
3694
|
+
batchRows = 0;
|
|
3695
|
+
options2.onProgress?.(written);
|
|
3696
|
+
};
|
|
3697
|
+
for (const statement of insertStatements(options2.table, options2.columns, options2.rows, options2.maxBytes)) {
|
|
3698
|
+
batch.push(statement.sql);
|
|
3699
|
+
batchRows += statement.rows;
|
|
3700
|
+
if (batch.length === perCall) await flush();
|
|
3701
|
+
}
|
|
3702
|
+
await flush();
|
|
3703
|
+
return written;
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// src/seed/insert-many.ts
|
|
3707
|
+
var isIterable = (value) => typeof value === "object" && value !== null && Symbol.iterator in value;
|
|
3708
|
+
function createInsertMany(sink, drizzle, onProgress) {
|
|
3709
|
+
return async function insertMany(table, count, build) {
|
|
3710
|
+
const name = drizzle.getTableName(table);
|
|
3711
|
+
const columns = drizzle.getTableColumns(table);
|
|
3712
|
+
if (typeof count === "number" && !build) throw new Error(`insertMany(${name}, ${count}) needs a function to build each row.`);
|
|
3713
|
+
const source = typeof count === "number" ? (function* () {
|
|
3714
|
+
for (let i = 0; i < count; i++) yield build(i);
|
|
3715
|
+
})() : isIterable(count) ? count : [];
|
|
3716
|
+
const iterator = source[Symbol.iterator]();
|
|
3717
|
+
const first = iterator.next();
|
|
3718
|
+
if (first.done) return 0;
|
|
3719
|
+
const keys = new Set(Object.keys(first.value));
|
|
3720
|
+
for (const [key, column] of Object.entries(columns)) if (column.defaultFn) keys.add(key);
|
|
3721
|
+
const used = Object.keys(columns).filter((key) => keys.has(key));
|
|
3722
|
+
const unknownKeys = [...keys].filter((key) => !columns[key]);
|
|
3723
|
+
if (unknownKeys.length) throw new Error(`${name} has no column called ${unknownKeys.join(", ")}.`);
|
|
3724
|
+
const toRow = (value) => {
|
|
3725
|
+
const row = {};
|
|
3726
|
+
for (const key of used) {
|
|
3727
|
+
const column = columns[key];
|
|
3728
|
+
let raw = value[key];
|
|
3729
|
+
if (raw === void 0 && column.defaultFn) raw = column.defaultFn();
|
|
3730
|
+
row[column.name] = raw === void 0 || raw === null ? null : column.mapToDriverValue?.(raw) ?? raw;
|
|
3731
|
+
}
|
|
3732
|
+
return row;
|
|
3733
|
+
};
|
|
3734
|
+
const rows = (function* () {
|
|
3735
|
+
yield toRow(first.value);
|
|
3736
|
+
for (let next = iterator.next(); !next.done; next = iterator.next()) yield toRow(next.value);
|
|
3737
|
+
})();
|
|
3738
|
+
return bulkInsert(sink, {
|
|
3739
|
+
table: name,
|
|
3740
|
+
columns: used.map((key) => columns[key].name),
|
|
3741
|
+
rows,
|
|
3742
|
+
onProgress: onProgress && ((written) => onProgress(written, name))
|
|
3743
|
+
});
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
// src/seed/local-d1.ts
|
|
3412
3748
|
import { createRequire } from "module";
|
|
3413
|
-
import { join as
|
|
3749
|
+
import { join as join19 } from "path";
|
|
3414
3750
|
import { pathToFileURL } from "url";
|
|
3415
|
-
import { kebabCase as kebabCase3, storedFields as storedFields2 } from "@flaredev/core";
|
|
3416
|
-
import { createJiti as createJiti4 } from "jiti";
|
|
3417
|
-
import pc14 from "picocolors";
|
|
3418
|
-
var SEEDS_DIR = "seeds";
|
|
3419
3751
|
var LOCAL_STATE = ".wrangler/state/v3";
|
|
3752
|
+
async function openLocalD1(appRoot, binding) {
|
|
3753
|
+
process.env.WRANGLER_LOG ??= "error";
|
|
3754
|
+
const appRequire = createRequire(join19(appRoot, "package.json"));
|
|
3755
|
+
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3756
|
+
const { env, dispose } = await wrangler.getPlatformProxy({
|
|
3757
|
+
configPath: join19(appRoot, "wrangler.jsonc"),
|
|
3758
|
+
persist: { path: join19(appRoot, LOCAL_STATE) }
|
|
3759
|
+
});
|
|
3760
|
+
const bindings = env;
|
|
3761
|
+
const name = binding ?? "DB";
|
|
3762
|
+
const db = bindings[name];
|
|
3763
|
+
if (!db) {
|
|
3764
|
+
const available = Object.keys(bindings).filter((key) => typeof bindings[key]?.prepare === "function");
|
|
3765
|
+
throw new Error(`No "${name}" database binding in wrangler.jsonc.${available.length ? ` Available: ${available.join(", ")}.` : ""}`);
|
|
3766
|
+
}
|
|
3767
|
+
return { db, env: bindings, dispose };
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
// src/commands/seed.ts
|
|
3771
|
+
var SEEDS_DIR = "seeds";
|
|
3420
3772
|
function seedFiles(appRoot, names) {
|
|
3421
|
-
const dir =
|
|
3422
|
-
const all =
|
|
3773
|
+
const dir = join20(appRoot, SEEDS_DIR);
|
|
3774
|
+
const all = existsSync20(dir) ? readdirSync8(dir).filter((file) => file.endsWith(".seed.ts")).sort() : [];
|
|
3423
3775
|
if (!names?.length) return all;
|
|
3424
3776
|
return names.map((name) => {
|
|
3425
3777
|
const file = `${name.replace(/\.seed\.ts$/, "")}.seed.ts`;
|
|
@@ -3430,58 +3782,66 @@ function seedFiles(appRoot, names) {
|
|
|
3430
3782
|
});
|
|
3431
3783
|
}
|
|
3432
3784
|
async function runSeeds(options2 = {}) {
|
|
3433
|
-
const
|
|
3785
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3434
3786
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3435
3787
|
const files = seedFiles(appRoot, options2.names);
|
|
3436
3788
|
if (files.length === 0) {
|
|
3437
|
-
|
|
3789
|
+
log2(`No seeds found. Create one with ${pc16.bold("flare seed:make <name>")}.`);
|
|
3438
3790
|
return;
|
|
3439
3791
|
}
|
|
3440
|
-
const jiti = createJiti4(
|
|
3441
|
-
const appRequire = createRequire(join18(appRoot, "package.json"));
|
|
3442
|
-
const wrangler = await import(pathToFileURL(appRequire.resolve("wrangler")).href);
|
|
3792
|
+
const jiti = createJiti4(join20(appRoot, "package.json"), { alias: { "@": appRoot }, moduleCache: false, fsCache: false });
|
|
3443
3793
|
const { drizzle } = await jiti.import("drizzle-orm/d1");
|
|
3444
|
-
const
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
persist: { path: join18(appRoot, LOCAL_STATE) }
|
|
3448
|
-
});
|
|
3794
|
+
const drizzleHelpers = await jiti.import("drizzle-orm");
|
|
3795
|
+
const schema = await jiti.import(join20(appRoot, "db/schema.ts"));
|
|
3796
|
+
const local = await openLocalD1(appRoot);
|
|
3449
3797
|
try {
|
|
3450
|
-
const
|
|
3451
|
-
|
|
3452
|
-
const db = drizzle(bindings.DB, { schema });
|
|
3798
|
+
const db = drizzle(local.db, { schema });
|
|
3799
|
+
const insertMany = createInsertMany(local.db, drizzleHelpers);
|
|
3453
3800
|
for (const file of files) {
|
|
3454
|
-
const mod = await jiti.import(
|
|
3801
|
+
const mod = await jiti.import(join20(appRoot, SEEDS_DIR, file));
|
|
3455
3802
|
if (typeof mod.default !== "function") throw new Error(`${SEEDS_DIR}/${file} must \`export default defineSeed(async ({ db }) => { ... })\`.`);
|
|
3456
|
-
|
|
3457
|
-
await mod.default({ db, env:
|
|
3803
|
+
log2(`${pc16.cyan("seed")} ${file}`);
|
|
3804
|
+
await mod.default({ db, env: local.env, insertMany, fake: createFake(), log: (message) => log2(` ${message}`) });
|
|
3458
3805
|
}
|
|
3459
|
-
|
|
3806
|
+
log2(pc16.green(`
|
|
3460
3807
|
Ran ${files.length} seed(s) against the local database.`));
|
|
3461
3808
|
} finally {
|
|
3462
|
-
await dispose();
|
|
3809
|
+
await local.dispose();
|
|
3463
3810
|
}
|
|
3464
3811
|
}
|
|
3465
|
-
function sampleValue(key, def
|
|
3812
|
+
function sampleValue(key, def) {
|
|
3813
|
+
const name = key.toLowerCase();
|
|
3466
3814
|
switch (def.kind) {
|
|
3467
3815
|
case "string":
|
|
3468
|
-
if (def.format === "email") return
|
|
3469
|
-
if (def.format === "url") return
|
|
3470
|
-
|
|
3816
|
+
if (def.format === "email") return "fake.email()";
|
|
3817
|
+
if (def.format === "url") return "fake.url()";
|
|
3818
|
+
if (def.format === "tel") return "fake.phone()";
|
|
3819
|
+
if (def.format === "domain") return "fake.domain()";
|
|
3820
|
+
if (def.format === "country") return "fake.country()";
|
|
3821
|
+
if (def.format === "color") return "fake.color()";
|
|
3822
|
+
if (def.format === "slug") return "fake.slug()";
|
|
3823
|
+
if (name.includes("email")) return "fake.email()";
|
|
3824
|
+
if (name.includes("phone") || name.includes("mobile")) return "fake.phone()";
|
|
3825
|
+
if (name.includes("company")) return "fake.company()";
|
|
3826
|
+
if (name.includes("city")) return "fake.city()";
|
|
3827
|
+
if (name.includes("name")) return "fake.fullName()";
|
|
3828
|
+
return "fake.words(2)";
|
|
3471
3829
|
case "text":
|
|
3472
|
-
return
|
|
3830
|
+
return "fake.paragraph()";
|
|
3473
3831
|
case "int":
|
|
3474
|
-
return "
|
|
3832
|
+
return "fake.int(1, 1000)";
|
|
3475
3833
|
case "float":
|
|
3476
|
-
return "
|
|
3834
|
+
return "fake.float(1, 1000)";
|
|
3477
3835
|
case "boolean":
|
|
3478
|
-
return "
|
|
3836
|
+
return "fake.bool()";
|
|
3479
3837
|
case "date":
|
|
3480
|
-
return "
|
|
3838
|
+
return "fake.date()";
|
|
3481
3839
|
case "datetime":
|
|
3482
|
-
return "
|
|
3840
|
+
return "fake.datetime()";
|
|
3483
3841
|
case "enum":
|
|
3484
|
-
return JSON.stringify(def.options
|
|
3842
|
+
return `fake.pick(${JSON.stringify(def.options)})`;
|
|
3843
|
+
case "multiselect":
|
|
3844
|
+
return `fake.some(${JSON.stringify(def.options)}, ${Math.max(1, def.minItems ?? 1)})`;
|
|
3485
3845
|
case "file":
|
|
3486
3846
|
return void 0;
|
|
3487
3847
|
case "belongsTo":
|
|
@@ -3492,9 +3852,10 @@ function renderSeed(resource) {
|
|
|
3492
3852
|
if (!resource) {
|
|
3493
3853
|
return `import { defineSeed } from "@flaredev/core";
|
|
3494
3854
|
|
|
3495
|
-
export default defineSeed(async ({ db, log }) => {
|
|
3855
|
+
export default defineSeed(async ({ db, insertMany, fake, log }) => {
|
|
3496
3856
|
// Insert rows with Drizzle, e.g.:
|
|
3497
3857
|
// await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
|
|
3858
|
+
// Or many at once, batched: insertMany(contacts, 10_000, () => ({ name: fake.fullName() }));
|
|
3498
3859
|
log("nothing to seed yet");
|
|
3499
3860
|
});
|
|
3500
3861
|
`;
|
|
@@ -3503,30 +3864,32 @@ export default defineSeed(async ({ db, log }) => {
|
|
|
3503
3864
|
const lines = [];
|
|
3504
3865
|
const todos = [];
|
|
3505
3866
|
for (const [key, def] of storedFields2(resource)) {
|
|
3506
|
-
const value = sampleValue(key, def
|
|
3867
|
+
const value = sampleValue(key, def);
|
|
3507
3868
|
if (value !== void 0) lines.push(` ${key}: ${value},`);
|
|
3508
3869
|
else if (def.required) todos.push(` // ${key}: TODO (${def.kind === "belongsTo" ? `id of an existing ${def.target}` : "R2 object key"}),`);
|
|
3509
3870
|
}
|
|
3510
3871
|
return `import { defineSeed } from "@flaredev/core";
|
|
3511
3872
|
import { ${table} } from "@/db/schema";
|
|
3512
3873
|
|
|
3513
|
-
|
|
3514
|
-
|
|
3874
|
+
/** How many to create. insertMany batches them, so hundreds of thousands are fine. */
|
|
3875
|
+
const COUNT = 50;
|
|
3876
|
+
|
|
3877
|
+
export default defineSeed(async ({ insertMany, fake, log }) => {
|
|
3878
|
+
const rows = await insertMany(${table}, COUNT, () => ({
|
|
3515
3879
|
${[...lines, ...todos].join("\n")}
|
|
3516
3880
|
}));
|
|
3517
|
-
|
|
3518
|
-
log(\`inserted \${rows.length} ${resource.pluralLabel.toLowerCase()}\`);
|
|
3881
|
+
log(\`inserted \${rows.toLocaleString()} ${resource.pluralLabel.toLowerCase()}\`);
|
|
3519
3882
|
});
|
|
3520
3883
|
`;
|
|
3521
3884
|
}
|
|
3522
3885
|
async function makeSeed(rawName, options2 = {}) {
|
|
3523
|
-
const
|
|
3886
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3524
3887
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3525
3888
|
const name = kebabCase3(rawName);
|
|
3526
3889
|
if (!name) throw new Error(`Invalid seed name "${rawName}".`);
|
|
3527
3890
|
const relative2 = `${SEEDS_DIR}/${name}.seed.ts`;
|
|
3528
|
-
const path =
|
|
3529
|
-
if (
|
|
3891
|
+
const path = join20(appRoot, relative2);
|
|
3892
|
+
if (existsSync20(path)) throw new Error(`${relative2} already exists.`);
|
|
3530
3893
|
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
3531
3894
|
let resource;
|
|
3532
3895
|
if (options2.resource) {
|
|
@@ -3535,27 +3898,287 @@ async function makeSeed(rawName, options2 = {}) {
|
|
|
3535
3898
|
} else {
|
|
3536
3899
|
resource = resources.find((r) => [kebabCase3(r.name), r.slug].includes(name));
|
|
3537
3900
|
}
|
|
3538
|
-
mkdirSync10(
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
Run it with ${
|
|
3901
|
+
mkdirSync10(join20(appRoot, SEEDS_DIR), { recursive: true });
|
|
3902
|
+
writeFileSync13(path, renderSeed(resource));
|
|
3903
|
+
log2(`${pc16.green("create".padEnd(9))} ${relative2}`);
|
|
3904
|
+
log2(`
|
|
3905
|
+
Run it with ${pc16.bold(`flare seed ${name}`)}.`);
|
|
3543
3906
|
return relative2;
|
|
3544
3907
|
}
|
|
3545
3908
|
|
|
3909
|
+
// src/commands/seed-resource.ts
|
|
3910
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync3, writeFileSync as writeFileSync14 } from "fs";
|
|
3911
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
3912
|
+
import { join as join21 } from "path";
|
|
3913
|
+
import pc17 from "picocolors";
|
|
3914
|
+
|
|
3915
|
+
// src/seed/rows.ts
|
|
3916
|
+
import { columnName as columnName2, createFake as createFake2, storedFields as storedFields3 } from "@flaredev/core";
|
|
3917
|
+
var has = (key, ...words) => words.some((word) => key.includes(word));
|
|
3918
|
+
function stringValue(key, def, fake, resource, index) {
|
|
3919
|
+
switch (def.format) {
|
|
3920
|
+
case "email":
|
|
3921
|
+
return fake.email();
|
|
3922
|
+
case "url":
|
|
3923
|
+
return fake.url();
|
|
3924
|
+
case "tel":
|
|
3925
|
+
return fake.phone();
|
|
3926
|
+
case "domain":
|
|
3927
|
+
return fake.domain();
|
|
3928
|
+
case "country":
|
|
3929
|
+
return fake.country();
|
|
3930
|
+
case "color":
|
|
3931
|
+
return fake.color();
|
|
3932
|
+
case "slug":
|
|
3933
|
+
return fake.slug();
|
|
3934
|
+
}
|
|
3935
|
+
const name = key.toLowerCase();
|
|
3936
|
+
const organisation = has(resource.name.toLowerCase(), "company", "vendor", "supplier", "organisation", "organization", "account", "team", "store", "brand");
|
|
3937
|
+
let value;
|
|
3938
|
+
if (has(name, "email")) value = fake.email();
|
|
3939
|
+
else if (has(name, "phone", "mobile", "tel", "whatsapp")) value = fake.phone();
|
|
3940
|
+
else if (has(name, "firstname", "givenname")) value = fake.firstName();
|
|
3941
|
+
else if (has(name, "lastname", "surname", "familyname")) value = fake.lastName();
|
|
3942
|
+
else if (has(name, "company", "organisation", "organization", "employer", "vendor", "supplier", "business")) value = fake.company();
|
|
3943
|
+
else if (has(name, "city", "town")) value = fake.city();
|
|
3944
|
+
else if (has(name, "country")) value = fake.country();
|
|
3945
|
+
else if (has(name, "address", "street")) value = `${fake.int(1, 240)} ${fake.words(1)} Street, ${fake.city()}`;
|
|
3946
|
+
else if (has(name, "website", "url", "link")) value = fake.url();
|
|
3947
|
+
else if (has(name, "slug", "handle", "username")) value = fake.slug();
|
|
3948
|
+
else if (has(name, "colour", "color")) value = fake.color();
|
|
3949
|
+
else if (has(name, "code", "sku", "reference", "number")) value = `${fake.words(1).slice(0, 3).toUpperCase()}-${String(index + 1).padStart(6, "0")}`;
|
|
3950
|
+
else if (has(name, "title", "position", "role", "job")) value = fake.jobTitle();
|
|
3951
|
+
else if (has(name, "name")) value = organisation ? fake.company() : fake.fullName();
|
|
3952
|
+
else if (has(name, "description", "summary", "note", "bio", "about", "message", "comment")) value = fake.sentence();
|
|
3953
|
+
else value = `${fake.words(2)}`.replace(/^./, (letter) => letter.toUpperCase());
|
|
3954
|
+
if (def.maxLength && value.length > def.maxLength) value = value.slice(0, def.maxLength);
|
|
3955
|
+
if (def.minLength && value.length < def.minLength) value = value.padEnd(def.minLength, "x");
|
|
3956
|
+
return value;
|
|
3957
|
+
}
|
|
3958
|
+
function numberValue(key, def, fake) {
|
|
3959
|
+
const name = key.toLowerCase();
|
|
3960
|
+
const money = has(name, "price", "amount", "total", "cost", "fee", "revenue", "salary", "balance");
|
|
3961
|
+
let min = def.min;
|
|
3962
|
+
let max = def.max;
|
|
3963
|
+
if (min === void 0 || max === void 0) {
|
|
3964
|
+
const [low, high] = money ? [5, 5e3] : has(name, "age") ? [18, 78] : has(name, "year") ? [2015, (/* @__PURE__ */ new Date()).getFullYear()] : has(name, "quantity", "qty", "stock", "seats", "items") ? [1, 250] : has(name, "percent", "rate", "score", "discount") ? [0, 100] : [1, 1e3];
|
|
3965
|
+
min = min ?? low;
|
|
3966
|
+
max = max ?? high;
|
|
3967
|
+
}
|
|
3968
|
+
return def.kind === "int" ? fake.int(Math.ceil(min), Math.floor(max)) : fake.float(min, max, money ? 2 : 2);
|
|
3969
|
+
}
|
|
3970
|
+
function fieldValue(key, def, fake, resource, parentIds, index) {
|
|
3971
|
+
const name = key.toLowerCase();
|
|
3972
|
+
if (!def.required && def.kind !== "belongsTo" && fake.bool(0.15)) return null;
|
|
3973
|
+
switch (def.kind) {
|
|
3974
|
+
case "string":
|
|
3975
|
+
return stringValue(key, def, fake, resource, index);
|
|
3976
|
+
case "text":
|
|
3977
|
+
return fake.paragraph(fake.int(1, 3));
|
|
3978
|
+
case "int":
|
|
3979
|
+
case "float":
|
|
3980
|
+
return numberValue(key, def, fake);
|
|
3981
|
+
case "boolean":
|
|
3982
|
+
return fake.bool(has(name, "active", "enabled", "published", "verified", "visible") ? 0.85 : 0.4);
|
|
3983
|
+
case "date":
|
|
3984
|
+
return has(name, "due", "expire", "end", "renew") ? fake.date(-90) : fake.date();
|
|
3985
|
+
case "datetime":
|
|
3986
|
+
return has(name, "due", "expire", "end", "renew") ? fake.datetime(-90) : fake.datetime();
|
|
3987
|
+
case "enum":
|
|
3988
|
+
return fake.pick(def.options);
|
|
3989
|
+
case "multiselect":
|
|
3990
|
+
return fake.some(def.options, Math.max(1, def.minItems ?? 1), def.maxItems ?? def.options.length);
|
|
3991
|
+
case "file":
|
|
3992
|
+
return def.required ? `seed/${resource.table}/${fake.count}.bin` : null;
|
|
3993
|
+
case "belongsTo": {
|
|
3994
|
+
const ids = parentIds.get(def.target) ?? [];
|
|
3995
|
+
if (ids.length === 0) {
|
|
3996
|
+
if (def.required) {
|
|
3997
|
+
throw new Error(`${resource.name}.${key} needs a ${def.target} to belong to, and there are none. Seed ${def.target} first.`);
|
|
3998
|
+
}
|
|
3999
|
+
return null;
|
|
4000
|
+
}
|
|
4001
|
+
return fake.pick(ids);
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
}
|
|
4005
|
+
function uniquify(value, row) {
|
|
4006
|
+
const trailing = /(\d+)$/.exec(value)?.[1];
|
|
4007
|
+
if (trailing && Number(trailing) === row) return value;
|
|
4008
|
+
const at = value.indexOf("@");
|
|
4009
|
+
if (at > 0) return `${value.slice(0, at)}.${row}${value.slice(at)}`;
|
|
4010
|
+
if (/^[a-z0-9.-]+$/.test(value)) return `${value}-${row}`;
|
|
4011
|
+
return `${value} ${row}`;
|
|
4012
|
+
}
|
|
4013
|
+
function seedColumns(resource) {
|
|
4014
|
+
return ["id", ...storedFields3(resource).map(([key]) => columnName2(key)), "created_at", "updated_at"];
|
|
4015
|
+
}
|
|
4016
|
+
function* seedRows(resource, count, options2 = {}) {
|
|
4017
|
+
const fake = createFake2(options2.seed ?? Math.floor(Math.random() * 2 ** 31));
|
|
4018
|
+
const parentIds = options2.parentIds ?? /* @__PURE__ */ new Map();
|
|
4019
|
+
const fields = storedFields3(resource);
|
|
4020
|
+
const now = options2.now ?? Date.now();
|
|
4021
|
+
for (let i = 0; i < count; i++) {
|
|
4022
|
+
const row = { id: fake.id() };
|
|
4023
|
+
for (const [key, def] of fields) {
|
|
4024
|
+
let value = fieldValue(key, def, fake, resource, parentIds, i);
|
|
4025
|
+
if (typeof value === "string" && "unique" in def && def.unique) value = uniquify(value, i + 1);
|
|
4026
|
+
row[columnName2(key)] = value;
|
|
4027
|
+
}
|
|
4028
|
+
const created = now - fake.int(0, 365) * 864e5 - fake.int(0, 86399999);
|
|
4029
|
+
row.created_at = created;
|
|
4030
|
+
row.updated_at = created + fake.int(0, Math.min(now - created, 30 * 864e5));
|
|
4031
|
+
yield row;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
// src/commands/seed-resource.ts
|
|
4036
|
+
var PARENT_SAMPLE = 5e3;
|
|
4037
|
+
function parseCount(value, fallback = 25) {
|
|
4038
|
+
if (value === void 0 || value === "") return fallback;
|
|
4039
|
+
if (typeof value === "number") return Math.floor(value);
|
|
4040
|
+
const text = String(value).trim().toLowerCase().replaceAll(",", "").replaceAll("_", "").replaceAll(" ", "");
|
|
4041
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/.exec(text);
|
|
4042
|
+
if (!match) throw new Error(`"${value}" is not a number of rows. Try 1000, 25k or 1m.`);
|
|
4043
|
+
const size = Number(match[1]) * (match[2] === "k" ? 1e3 : match[2] === "m" ? 1e6 : 1);
|
|
4044
|
+
if (size < 1) throw new Error("Ask for at least one row.");
|
|
4045
|
+
return Math.floor(size);
|
|
4046
|
+
}
|
|
4047
|
+
function pickResource(resources, name) {
|
|
4048
|
+
const wanted = name.toLowerCase();
|
|
4049
|
+
const match = resources.find((resource) => [resource.name.toLowerCase(), resource.slug, resource.table].includes(wanted));
|
|
4050
|
+
if (!match) {
|
|
4051
|
+
throw new Error(`No resource "${name}". Available: ${resources.map((resource) => resource.name).join(", ") || "none"}.`);
|
|
4052
|
+
}
|
|
4053
|
+
return match;
|
|
4054
|
+
}
|
|
4055
|
+
function pickDatabase(databases, wanted) {
|
|
4056
|
+
if (databases.length === 0) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
4057
|
+
if (wanted) {
|
|
4058
|
+
const match = databases.find((db) => db.binding === wanted || db.database_name === wanted);
|
|
4059
|
+
if (!match) throw new Error(`No D1 database "${wanted}". Available: ${databases.map((db) => db.binding).join(", ")}.`);
|
|
4060
|
+
return match;
|
|
4061
|
+
}
|
|
4062
|
+
if (databases.length > 1) throw new Error(`Several D1 databases; pick one with --database (${databases.map((db) => db.binding).join(", ")}).`);
|
|
4063
|
+
return databases[0];
|
|
4064
|
+
}
|
|
4065
|
+
async function parentsOf(resource, all, ids) {
|
|
4066
|
+
const targets = new Set(
|
|
4067
|
+
Object.values(resource.fields).filter((field) => field.kind === "belongsTo").map((field) => field.target)
|
|
4068
|
+
);
|
|
4069
|
+
const parents = /* @__PURE__ */ new Map();
|
|
4070
|
+
for (const target of targets) {
|
|
4071
|
+
const parent = all.find((candidate) => candidate.name === target);
|
|
4072
|
+
if (parent) parents.set(target, await ids(parent.table));
|
|
4073
|
+
}
|
|
4074
|
+
return parents;
|
|
4075
|
+
}
|
|
4076
|
+
async function seedResourceCommand(name, options2 = {}) {
|
|
4077
|
+
const progress = progressLine();
|
|
4078
|
+
const started = Date.now();
|
|
4079
|
+
try {
|
|
4080
|
+
const result = await seedResource(name, {
|
|
4081
|
+
...options2,
|
|
4082
|
+
onProgress: (written, total) => {
|
|
4083
|
+
const elapsed = Date.now() - started;
|
|
4084
|
+
progress.update(` ${pc17.cyan(formatCount(written))} of ${formatCount(total)} rows ${pc17.dim(formatRate(written, elapsed))}`);
|
|
4085
|
+
}
|
|
4086
|
+
});
|
|
4087
|
+
progress.done();
|
|
4088
|
+
const { rows, ms, where } = result;
|
|
4089
|
+
console.log(
|
|
4090
|
+
`${pc17.green("\u2714")} Seeded ${pc17.bold(formatCount(rows))} ${result.resource.pluralLabel.toLowerCase()} into the ${where} database ` + pc17.dim(`(${formatDuration(ms)}, ${formatRate(rows, ms)})`)
|
|
4091
|
+
);
|
|
4092
|
+
return result;
|
|
4093
|
+
} catch (error) {
|
|
4094
|
+
progress.done();
|
|
4095
|
+
throw error;
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
async function seedResource(name, options2 = {}) {
|
|
4099
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
4100
|
+
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
4101
|
+
const resources = (await loadResources(appRoot)).map(({ resource: resource2 }) => resource2);
|
|
4102
|
+
const resource = pickResource(resources, name);
|
|
4103
|
+
const total = parseCount(options2.count);
|
|
4104
|
+
const where = options2.remote ? "remote" : "local";
|
|
4105
|
+
if (options2.remote && !options2.yes) {
|
|
4106
|
+
throw new Error(`Seeding the remote database writes ${total.toLocaleString()} rows to ${resource.table}. Re-run with --yes to confirm.`);
|
|
4107
|
+
}
|
|
4108
|
+
const columns = seedColumns(resource);
|
|
4109
|
+
const started = Date.now();
|
|
4110
|
+
let parents = /* @__PURE__ */ new Map();
|
|
4111
|
+
const rows = () => seedRows(resource, total, { seed: options2.seed, parentIds: parents });
|
|
4112
|
+
if (options2.remote) {
|
|
4113
|
+
const db = pickDatabase(readD1Databases(appRoot), options2.database);
|
|
4114
|
+
const wrangler = resolveBin(appRoot, "wrangler", "wrangler");
|
|
4115
|
+
const target = ["--remote", ...options2.env ? ["--env", options2.env] : []];
|
|
4116
|
+
const query = async (sql2) => {
|
|
4117
|
+
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--json", "--command", sql2], appRoot, { capture: true });
|
|
4118
|
+
if (result.code !== 0) throw new Error(`Query failed on the remote database:
|
|
4119
|
+
${result.output.trim()}`);
|
|
4120
|
+
const start = result.output.indexOf("[");
|
|
4121
|
+
const parsed = JSON.parse(result.output.slice(start, result.output.lastIndexOf("]") + 1));
|
|
4122
|
+
return parsed[0]?.results ?? [];
|
|
4123
|
+
};
|
|
4124
|
+
parents = await parentsOf(resource, resources, async (table) => {
|
|
4125
|
+
const found = await query(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`);
|
|
4126
|
+
return found.map((row) => row.id);
|
|
4127
|
+
});
|
|
4128
|
+
const dir = mkdtempSync2(join21(tmpdir2(), "flare-seed-"));
|
|
4129
|
+
const file = join21(dir, `${resource.table}.sql`);
|
|
4130
|
+
try {
|
|
4131
|
+
const statements = [];
|
|
4132
|
+
if (options2.truncate) statements.push(`DELETE FROM "${resource.table}";`);
|
|
4133
|
+
let written = 0;
|
|
4134
|
+
for (const statement of insertStatements(resource.table, columns, rows())) {
|
|
4135
|
+
statements.push(statement.sql);
|
|
4136
|
+
written += statement.rows;
|
|
4137
|
+
options2.onProgress?.(written, total);
|
|
4138
|
+
}
|
|
4139
|
+
writeFileSync14(file, statements.join("\n"));
|
|
4140
|
+
log2(`${pc17.cyan("\u25CF")} Uploading ${total.toLocaleString()} rows to ${pc17.bold(db.database_name)}`);
|
|
4141
|
+
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--yes", "--file", file], appRoot, { capture: true });
|
|
4142
|
+
if (result.code !== 0) throw new Error(`Seeding the remote database failed:
|
|
4143
|
+
${result.output.trim()}`);
|
|
4144
|
+
} finally {
|
|
4145
|
+
rmSync3(dir, { recursive: true, force: true });
|
|
4146
|
+
}
|
|
4147
|
+
return { resource, rows: total, ms: Date.now() - started, where };
|
|
4148
|
+
}
|
|
4149
|
+
const local = await openLocalD1(appRoot, options2.database);
|
|
4150
|
+
try {
|
|
4151
|
+
parents = await parentsOf(resource, resources, async (table) => {
|
|
4152
|
+
const found = await local.db.prepare(`SELECT id FROM "${table}" LIMIT ${PARENT_SAMPLE}`).all();
|
|
4153
|
+
return (found.results ?? []).map((row) => row.id);
|
|
4154
|
+
});
|
|
4155
|
+
if (options2.truncate) await local.db.exec(`DELETE FROM "${resource.table}";`);
|
|
4156
|
+
const sink = { exec: (sql2) => local.db.exec(sql2) };
|
|
4157
|
+
const written = await bulkInsert(sink, {
|
|
4158
|
+
table: resource.table,
|
|
4159
|
+
columns,
|
|
4160
|
+
rows: rows(),
|
|
4161
|
+
onProgress: (count) => options2.onProgress?.(count, total)
|
|
4162
|
+
});
|
|
4163
|
+
return { resource, rows: written, ms: Date.now() - started, where };
|
|
4164
|
+
} finally {
|
|
4165
|
+
await local.dispose();
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
|
|
3546
4169
|
// src/commands/billing-sync.ts
|
|
3547
4170
|
import { createRequire as createRequire2 } from "module";
|
|
3548
|
-
import { existsSync as
|
|
3549
|
-
import { tmpdir as
|
|
3550
|
-
import { join as
|
|
4171
|
+
import { existsSync as existsSync21, mkdtempSync as mkdtempSync3, readFileSync as readFileSync13, rmSync as rmSync4, writeFileSync as writeFileSync15 } from "fs";
|
|
4172
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
4173
|
+
import { join as join22 } from "path";
|
|
3551
4174
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
3552
4175
|
import { kebabCase as kebabCase4 } from "@flaredev/core";
|
|
3553
|
-
import
|
|
4176
|
+
import pc18 from "picocolors";
|
|
3554
4177
|
function stripeKeys(appRoot) {
|
|
3555
4178
|
const map = {};
|
|
3556
|
-
const path =
|
|
3557
|
-
if (
|
|
3558
|
-
for (const line of
|
|
4179
|
+
const path = join22(appRoot, ".dev.vars");
|
|
4180
|
+
if (existsSync21(path)) {
|
|
4181
|
+
for (const line of readFileSync13(path, "utf8").split(/\r?\n/)) {
|
|
3559
4182
|
const match = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
3560
4183
|
if (match) map[match[1]] = match[2].trim();
|
|
3561
4184
|
}
|
|
@@ -3632,16 +4255,16 @@ async function syncPortalConfiguration(stripe, rows, appName) {
|
|
|
3632
4255
|
return existing ? (await stripe.billingPortal.configurations.update(existing.id, settings)).id : (await stripe.billingPortal.configurations.create(settings)).id;
|
|
3633
4256
|
}
|
|
3634
4257
|
async function syncPlans(options2 = {}) {
|
|
3635
|
-
const
|
|
4258
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3636
4259
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3637
4260
|
const key = stripeKeys(appRoot).STRIPE_SECRET_KEY;
|
|
3638
4261
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not set in .dev.vars or the environment.");
|
|
3639
4262
|
const db = readD1Databases(appRoot)[0];
|
|
3640
4263
|
if (!db) throw new Error("No d1_databases in wrangler.jsonc.");
|
|
3641
|
-
const appRequire = createRequire2(
|
|
4264
|
+
const appRequire = createRequire2(join22(appRoot, "package.json"));
|
|
3642
4265
|
const stripeModule = await import(pathToFileURL2(appRequire.resolve("stripe")).href);
|
|
3643
4266
|
const stripe = new stripeModule.default(key, { apiVersion: "2026-08-26.dahlia" });
|
|
3644
|
-
const appName = JSON.parse(
|
|
4267
|
+
const appName = JSON.parse(readFileSync13(join22(appRoot, "package.json"), "utf8")).name ?? "app";
|
|
3645
4268
|
const everything = await stripe.products.list({ active: true, limit: 100 }).autoPagingToArray({ limit: 1e4 });
|
|
3646
4269
|
const products = options2.all ? everything : everything.filter((product) => product.metadata?.flare_app === appName);
|
|
3647
4270
|
if (products.length === 0) {
|
|
@@ -3662,88 +4285,88 @@ async function syncPlans(options2 = {}) {
|
|
|
3662
4285
|
${existingResult.output.trim()}`);
|
|
3663
4286
|
}
|
|
3664
4287
|
const known = new Set(extractJson(existingResult.output)[0]?.results.map((row) => row.stripe_price_id));
|
|
3665
|
-
const dir =
|
|
4288
|
+
const dir = mkdtempSync3(join22(tmpdir3(), "flare-sync-"));
|
|
3666
4289
|
try {
|
|
3667
|
-
const file =
|
|
3668
|
-
|
|
4290
|
+
const file = join22(dir, "sync-plans.sql");
|
|
4291
|
+
writeFileSync15(file, renderSyncSql(rows));
|
|
3669
4292
|
const result = await runWrangler(wrangler, ["d1", "execute", db.binding, ...target, "--file", file], appRoot, { capture: true });
|
|
3670
4293
|
if (result.code !== 0) throw new Error(`Could not write plans:
|
|
3671
4294
|
${result.output.trim()}`);
|
|
3672
4295
|
} finally {
|
|
3673
|
-
|
|
4296
|
+
rmSync4(dir, { recursive: true, force: true });
|
|
3674
4297
|
}
|
|
3675
4298
|
let created = 0;
|
|
3676
4299
|
for (const row of rows) {
|
|
3677
4300
|
const isNew = !known.has(row.stripePriceId);
|
|
3678
4301
|
if (isNew) created++;
|
|
3679
4302
|
const price = `${row.currency.toUpperCase()} ${(row.amount / 100).toFixed(2)}${row.interval ? `/${row.interval}` : " once"}`;
|
|
3680
|
-
|
|
4303
|
+
log2(`${(isNew ? pc18.green("create") : pc18.dim("update")).padEnd(18)} ${row.name} (${row.slug}) ${pc18.dim(price)}`);
|
|
3681
4304
|
}
|
|
3682
|
-
for (const name of skipped)
|
|
4305
|
+
for (const name of skipped) log2(pc18.yellow(`skipped ${name}: only monthly, yearly and one-time prices become plans`));
|
|
3683
4306
|
const configuration = await syncPortalConfiguration(stripe, rows, appName);
|
|
3684
|
-
if (configuration)
|
|
3685
|
-
|
|
4307
|
+
if (configuration) log2(`${pc18.dim("portal".padEnd(9))} plan changes enabled for ${new Set(rows.filter((row) => row.interval).map((row) => row.stripeProductId)).size} product(s) (${configuration})`);
|
|
4308
|
+
log2(pc18.green(`
|
|
3686
4309
|
${rows.length} plan(s) synced to the ${options2.remote ? "remote" : "local"} database; plans no longer active in Stripe were deactivated.`));
|
|
3687
4310
|
return { created, updated: rows.length - created, skipped };
|
|
3688
4311
|
}
|
|
3689
4312
|
|
|
3690
4313
|
// src/commands/sync.ts
|
|
3691
|
-
import { existsSync as
|
|
3692
|
-
import { join as
|
|
3693
|
-
import
|
|
4314
|
+
import { existsSync as existsSync22 } from "fs";
|
|
4315
|
+
import { join as join23 } from "path";
|
|
4316
|
+
import pc19 from "picocolors";
|
|
3694
4317
|
async function syncTypes(options2 = {}) {
|
|
3695
|
-
const
|
|
4318
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3696
4319
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3697
4320
|
const all = await loadResources(appRoot);
|
|
3698
4321
|
const policies = await loadPolicies(appRoot);
|
|
3699
|
-
const tracksPolicies = policies.length > 0 ||
|
|
4322
|
+
const tracksPolicies = policies.length > 0 || existsSync22(join23(appRoot, "policies/index.ts"));
|
|
3700
4323
|
const plan = planFiles(all, tracksPolicies ? policies : void 0);
|
|
3701
|
-
if (!options2.check) ensureSupportFiles(appRoot,
|
|
4324
|
+
if (!options2.check) ensureSupportFiles(appRoot, log2);
|
|
3702
4325
|
const results = applyPlan(appRoot, plan, { force: options2.force, dryRun: options2.check });
|
|
3703
|
-
logResults(results,
|
|
4326
|
+
logResults(results, log2);
|
|
3704
4327
|
const drift = results.filter((r) => r.outcome === "skipped-drift");
|
|
3705
4328
|
const unmarked = results.filter((r) => r.outcome === "skipped-unmarked");
|
|
3706
4329
|
const changed = results.filter((r) => r.outcome === "create" || r.outcome === "update");
|
|
3707
4330
|
const orphans = findOrphans(appRoot, all);
|
|
3708
4331
|
if (drift.length) {
|
|
3709
|
-
|
|
4332
|
+
log2(
|
|
3710
4333
|
`
|
|
3711
|
-
${
|
|
4334
|
+
${pc19.red("Drift:")} ${drift.length} generated block(s) were edited by hand and were ${options2.check ? "not checked further" : "left unchanged"}.
|
|
3712
4335
|
Move custom code outside the // generated:start \u2026 // generated:end block, or re-run with --force to overwrite.`
|
|
3713
4336
|
);
|
|
3714
4337
|
}
|
|
3715
4338
|
if (unmarked.length) {
|
|
3716
|
-
|
|
3717
|
-
${
|
|
4339
|
+
log2(`
|
|
4340
|
+
${pc19.red("Conflict:")} ${unmarked.map((r) => r.path).join(", ")} exist without a generated block (hand-written). Rename or remove them.`);
|
|
3718
4341
|
}
|
|
3719
4342
|
if (orphans.length) {
|
|
3720
4343
|
const resources = [...new Set(orphans.map((o) => o.resource))];
|
|
3721
|
-
|
|
3722
|
-
${
|
|
3723
|
-
for (const orphan of orphans)
|
|
3724
|
-
|
|
3725
|
-
`If the resource was deleted, remove them with ${
|
|
4344
|
+
log2(`
|
|
4345
|
+
${pc19.yellow("Orphaned:")} generated files the descriptors no longer produce (${resources.join(", ")}):`);
|
|
4346
|
+
for (const orphan of orphans) log2(` ${orphan.path}`);
|
|
4347
|
+
log2(
|
|
4348
|
+
`If the resource was deleted, remove them with ${pc19.bold(`flare rm resource ${resources[0]}`)}; if it was renamed (table or slug), delete the old files.`
|
|
3726
4349
|
);
|
|
3727
4350
|
}
|
|
3728
4351
|
const tablesChanged = changed.some((r) => r.path.startsWith("db/schema/") && (r.status === "outdated" || r.status === "missing"));
|
|
3729
4352
|
if (tablesChanged && !options2.check) {
|
|
3730
|
-
|
|
3731
|
-
${
|
|
4353
|
+
log2(`
|
|
4354
|
+
${pc19.cyan("Tables changed.")} Create a migration with ${pc19.bold("flare gen migration <name> --from-schema")}, then ${pc19.bold("flare migrate")}.`);
|
|
3732
4355
|
}
|
|
3733
4356
|
const problems = drift.length + unmarked.length + orphans.length;
|
|
3734
4357
|
if (options2.check) {
|
|
3735
4358
|
const stale = changed.length;
|
|
3736
|
-
if (stale === 0 && problems === 0)
|
|
3737
|
-
else
|
|
3738
|
-
${
|
|
4359
|
+
if (stale === 0 && problems === 0) log2(pc19.green("Generated files are in sync with the resource descriptors."));
|
|
4360
|
+
else log2(`
|
|
4361
|
+
${pc19.red("Out of sync:")} ${stale} file(s) would change. Run ${pc19.bold("flare sync-types")}.`);
|
|
3739
4362
|
return stale + problems > 0 ? 1 : 0;
|
|
3740
4363
|
}
|
|
3741
|
-
if (changed.length === 0 && problems === 0)
|
|
4364
|
+
if (changed.length === 0 && problems === 0) log2(pc19.green("Everything is in sync."));
|
|
3742
4365
|
return problems > 0 ? 1 : 0;
|
|
3743
4366
|
}
|
|
3744
4367
|
|
|
3745
4368
|
// src/commands/user-role.ts
|
|
3746
|
-
import
|
|
4369
|
+
import pc20 from "picocolors";
|
|
3747
4370
|
var ROLE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
3748
4371
|
var EMAIL = /^[^\s@'"\\]+@[^\s@'"\\]+\.[^\s@'"\\]+$/;
|
|
3749
4372
|
function userRoleSql(email, role) {
|
|
@@ -3752,13 +4375,13 @@ function userRoleSql(email, role) {
|
|
|
3752
4375
|
return `UPDATE "user" SET role = '${role}', updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE lower(email) = lower('${email}') RETURNING email, role`;
|
|
3753
4376
|
}
|
|
3754
4377
|
async function setUserRole(email, role, options2 = {}) {
|
|
3755
|
-
const
|
|
4378
|
+
const log2 = options2.log ?? ((message) => console.log(message));
|
|
3756
4379
|
const appRoot = findAppRoot(options2.cwd ?? process.cwd());
|
|
3757
4380
|
const sql2 = userRoleSql(email, role);
|
|
3758
4381
|
const roles = await listRoles(options2);
|
|
3759
4382
|
if (roles && !roles.includes(role)) {
|
|
3760
|
-
|
|
3761
|
-
|
|
4383
|
+
log2(pc20.red(`No role "${role}" is registered. Known roles: ${roles.join(", ") || "none"}.`));
|
|
4384
|
+
log2(pc20.dim(`Register it with: flare role:add ${role}${options2.remote ? " --remote" : ""}`));
|
|
3762
4385
|
return 1;
|
|
3763
4386
|
}
|
|
3764
4387
|
const db = readD1Databases(appRoot)[0];
|
|
@@ -3772,19 +4395,40 @@ ${result.output.trim()}`);
|
|
|
3772
4395
|
const [first] = extractJson(result.output);
|
|
3773
4396
|
const updated = first?.results ?? [];
|
|
3774
4397
|
if (updated.length === 0) {
|
|
3775
|
-
|
|
4398
|
+
log2(pc20.red(`No user with email ${email} in the ${options2.remote ? "remote" : "local"} database. Sign up first, then run this again.`));
|
|
3776
4399
|
return 1;
|
|
3777
4400
|
}
|
|
3778
|
-
|
|
4401
|
+
log2(pc20.green(`${updated[0].email} now has role "${updated[0].role}" (${options2.remote ? "remote" : "local"}).`));
|
|
3779
4402
|
return 0;
|
|
3780
4403
|
}
|
|
3781
4404
|
|
|
3782
4405
|
// src/cli.ts
|
|
3783
4406
|
function createCli() {
|
|
3784
4407
|
const cli = cac("flare");
|
|
3785
|
-
cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--auth
|
|
4408
|
+
cli.command("create <dir>", "Scaffold a new Flare app").option("--pm <manager>", "Package manager: pnpm, npm, yarn, or bun (default: detected)").option("--theme <name>", "Look of the app: default, coral, amber, sky, mono or emerald").option("--auth <list>", "Sign-in methods: magic-link, email-otp, passkeys, 2fa-app, 2fa-email, all or none (default: all)").option("--auth-providers <list>", "Social sign-in, comma-separated: google, github, apple, microsoft").option("-y, --yes", "Don't ask: use the flags given and the defaults for the rest").option("--skip-install", "Write files without installing dependencies").example("flare create shop").example("flare create shop --theme mono --auth passkeys,2fa-app --auth-providers google,github --yes").action(async (dir, options2) => {
|
|
3786
4409
|
const install = !options2.skipInstall;
|
|
3787
|
-
|
|
4410
|
+
let answers = { theme: options2.theme, auth: options2.auth, authProviders: options2.authProviders };
|
|
4411
|
+
if (canPrompt(options2.yes)) answers = await askCreateQuestions(answers, toAppName(dir));
|
|
4412
|
+
const result = createApp(dir, {
|
|
4413
|
+
pm: options2.pm,
|
|
4414
|
+
install,
|
|
4415
|
+
...answers,
|
|
4416
|
+
log: (message) => prompts3.log.message(message),
|
|
4417
|
+
// The install is the long part of `flare create`, so it gets a timer rather
|
|
4418
|
+
// than a wall of package manager output. Output is kept for when it fails.
|
|
4419
|
+
installer: (packageManager, args, cwd) => {
|
|
4420
|
+
const spin = prompts3.spinner({ indicator: "timer" });
|
|
4421
|
+
spin.start(`Installing dependencies with ${packageManager}`);
|
|
4422
|
+
const { code, output } = runQuiet(packageManager, args, cwd);
|
|
4423
|
+
if (code === 0) {
|
|
4424
|
+
spin.stop(`Installed dependencies with ${packageManager}`);
|
|
4425
|
+
} else {
|
|
4426
|
+
spin.error(`${packageManager} install failed`);
|
|
4427
|
+
process.stderr.write(output);
|
|
4428
|
+
}
|
|
4429
|
+
return code;
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
3788
4432
|
printNextSteps(result, install);
|
|
3789
4433
|
});
|
|
3790
4434
|
cli.command("gen <generator> [name]", "Generate code. Generators: resource, migration, policy, billing, security").option("--fields <fields>", "resource: fields, e.g. 'name:string, email:string!, status:enum(lead,customer)' (single quotes: bash treats ! in double quotes as history)").option("--force", "resource/policy/billing/security: overwrite hand-edited generated blocks").option("--from-schema", "migration: diff the current tables instead of a blank migration").option("--roles <roles>", "policy: roles allowed to read, create and update, e.g. admin,staff").option("--delete-roles <roles>", "policy: roles allowed to delete (default: the first --roles entry)").option("--provider <provider>", "billing: payment provider (default: stripe)").option("--mode <mode>", "billing: subscriptions (the default; includes one-time checkout)").option("--skip-install", "billing: write files without installing the stripe dependency").option("--skip-migration", "billing/security: skip generating the schema migration").example("flare gen resource Contact --fields 'name:string, email:string!, company:belongsTo(Company)?'").example("flare gen migration backfill_contact_status").example("flare gen migration add_phone_to_contacts --from-schema").example("flare gen policy Invoice --roles admin,staff --delete-roles admin").example("flare gen billing --provider stripe --mode subscriptions").example("flare gen security").action(
|
|
@@ -3820,6 +4464,9 @@ function createCli() {
|
|
|
3820
4464
|
cli.command("seed [...names]", "Run seed files from seeds/ against the local D1 database").example("flare seed # every seed, in file-name order").example("flare seed contacts # just seeds/contacts.seed.ts").action(async (names) => {
|
|
3821
4465
|
await runSeeds({ names });
|
|
3822
4466
|
});
|
|
4467
|
+
cli.command("seed:resource <resource> [count]", "Fill a resource's table with sample rows built from its descriptor").option("--count <rows>", "How many rows: 1000, 25k, 1m (default: 25)").option("--remote", "Seed the deployed database instead of the local one (needs --yes)").option("--truncate", "Delete the table's rows first").option("--seed <number>", "Same number, same rows").option("--env <name>", "Wrangler environment").option("--database <binding>", "D1 binding, when the app has several").option("-y, --yes", "Confirm writing to the remote database").example("flare seed:resource Contact --count 1000").example("flare seed:resource Contact 1m # a million rows").example("flare seed:resource Contact 5k --remote --yes").action(async (resource, count, options2) => {
|
|
4468
|
+
await seedResourceCommand(resource, { ...options2, count: options2.count ?? count });
|
|
4469
|
+
});
|
|
3823
4470
|
cli.command("seed:make <name>", "Create seeds/<name>.seed.ts (with example rows for a matching resource)").option("--resource <name>", "Resource to write example rows for").action(async (name, options2) => {
|
|
3824
4471
|
await makeSeed(name, options2);
|
|
3825
4472
|
});
|
|
@@ -3829,6 +4476,7 @@ function createCli() {
|
|
|
3829
4476
|
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) => {
|
|
3830
4477
|
process.exitCode = await syncTypes(options2);
|
|
3831
4478
|
});
|
|
4479
|
+
cli.command("theme [name]", "List the themes, or switch the app to one: default, coral, amber, sky, mono, emerald").example("flare theme").example("flare theme mono").action((name) => setTheme(name));
|
|
3832
4480
|
cli.command("tunnel [target]", "Share a local server on a public https://*.trycloudflare.com URL (Cloudflare Quick Tunnel; no account needed)").example("flare tunnel # http://localhost:3000").example("flare tunnel 8787").example("flare tunnel http://localhost:5173").action(async (target) => {
|
|
3833
4481
|
const local = toLocalUrl(target);
|
|
3834
4482
|
const tunnel = await openTunnel(local);
|
|
@@ -3845,7 +4493,7 @@ function createCli() {
|
|
|
3845
4493
|
cli.command(`${name} [...args]`, spec.description).allowUnknownOptions();
|
|
3846
4494
|
}
|
|
3847
4495
|
cli.help();
|
|
3848
|
-
cli.version(
|
|
4496
|
+
cli.version(FLARE_VERSION3);
|
|
3849
4497
|
return cli;
|
|
3850
4498
|
}
|
|
3851
4499
|
|
|
@@ -3864,6 +4512,6 @@ try {
|
|
|
3864
4512
|
}
|
|
3865
4513
|
}
|
|
3866
4514
|
} catch (error) {
|
|
3867
|
-
console.error(
|
|
4515
|
+
console.error(pc21.red(`\u2716 ${error instanceof Error ? error.message : String(error)}`));
|
|
3868
4516
|
process.exitCode = 1;
|
|
3869
4517
|
}
|