1import * as Config from "effect/Config";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Console from "effect/Console";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as Stream from "effect/Stream";
8import { Command, Flag } from "effect/cli";
9
10import * as Cloudflare from "../../Alchemist/routes/cloudflare.ts";
11import * as CloudflareToken from "../../Alchemist/routes/cloudflareToken.ts";
12import type { CreatedToken } from "../../Alchemist/routes/cloudflareToken.ts";
13import { STATE_STORE_SCRIPT_NAME } from "../../Cloudflare/StateStore/Api.ts";
14import * as CliKit from "../../Cli/CliKit/index.ts";
15import { formatLocalTimestamp } from "../Format.ts";
16import { loadConfigProvider } from "../../Util/ConfigProvider.ts";
17import { confirmOrDecline } from "./confirm.ts";
18import { envFile, parseSince, profile, yes } from "./flags.ts";
19import { instrumentCommand } from "./instrument.ts";
20
21const SELECTABLE_SCOPE_LABELS: Record<string, string> = {
22 "com.cloudflare.api.account": "account",
23 "com.cloudflare.api.account.zone": "zone",
24 "com.cloudflare.api.user": "user",
25 "com.cloudflare.edge.r2.bucket": "r2",
26};
27
28export const formatCreatedCloudflareToken = (
29 result: CreatedToken,
30 token: string,
31) =>
32 [
33 "",
34 `Created Cloudflare API token "${result.name}" (${result.id}).`,
35 `Granted ${result.grantedPermissionGroups} permission group(s) across ${result.policies.length} policy(ies)${
36 result.verificationStatus
37 ? `; token status: ${result.verificationStatus}.`
38 : "."
39 }`,
40 "",
41 token,
42 "",
43 "Store this value now — Cloudflare only shows it once. Use it as CLOUDFLARE_API_TOKEN.",
44 ...result.diagnostics.map(
45 (diagnostic) =>
46 `${diagnostic.severity.toUpperCase()}: ${diagnostic.message}`,
47 ),
48 ].join("\n");
49
50const cloudflareForce = Flag.Boolean("force").pipe(
51 Flag.withDescription(
52 "Force a full redeploy even if the state-store worker already exists. " +
53 "Without this flag, an existing worker is adopted and only its credentials are refreshed.",
54 ),
55 Flag.withDefault(false),
56);
57
58const cloudflareWorkerName = Flag.String("worker-name").pipe(
59 Flag.withDescription(
60 "Override the default state-store worker name (advanced; only needed for multiple state stores per account).",
61 ),
62 Flag.optional,
63 Flag.map(Option.getOrUndefined),
64);
65
66const bootstrapCommand = Command.make(
67 "bootstrap",
68 {
69 envFile,
70 profile,
71 force: cloudflareForce,
72 workerName: cloudflareWorkerName,
73 },
74 instrumentCommand(
75 "provider.cloudflare.bootstrap",
76 (a: {
77 profile: string | undefined;
78 force: boolean;
79 workerName: string | undefined;
80 }) => ({
81 "alchemy.profile": a.profile ?? "",
82 "alchemy.force": a.force,
83 "alchemy.worker_name": a.workerName ?? "",
84 }),
85 )(
86 Effect.fn(function* ({ envFile, profile, force, workerName }) {
87 yield* Cloudflare.bootstrap({
88 workerName,
89 force,
90 profile,
91 envFile: Option.getOrUndefined(envFile),
92 });
93 }),
94 ),
95).pipe(
96 Command.withDescription(
97 "Provision Cloudflare account prerequisites for deployments",
98 ),
99);
100
101const teardownCommand = Command.make(
102 "teardown",
103 {
104 envFile,
105 profile,
106 workerName: cloudflareWorkerName,
107 yes,
108 },
109 instrumentCommand(
110 "provider.cloudflare.teardown",
111 (a: { profile: string | undefined; workerName: string | undefined }) => ({
112 "alchemy.profile": a.profile ?? "",
113 "alchemy.worker_name": a.workerName ?? "",
114 }),
115 )(
116 Effect.fn(function* ({ envFile, profile, workerName, yes: approved }) {
117 yield* confirmOrDecline({
118 yes: approved,
119 message:
120 "Tear down the Cloudflare state store and its backing resources?",
121 confirmLabel: "Destroy",
122 cancelLabel: "Cancel",
123 });
124 yield* Cloudflare.teardown({
125 workerName,
126 profile,
127 envFile: Option.getOrUndefined(envFile),
128 });
129 }),
130 ),
131).pipe(
132 Command.withDescription("Tear down the Cloudflare state store"),
133 Command.unlisted,
134);
135const allPermissionsFlag = Flag.Boolean("all-permissions").pipe(
136 Flag.withDescription(
137 "Grant the token EVERY Cloudflare permission group (a 'god token'). " +
138 "Use with care — it has full access to your account.",
139 ),
140 Flag.withDefault(false),
141);
142
143const tokenNameFlag = Flag.String("name").pipe(
144 Flag.withDescription(
145 "Name for the API token. Defaults to 'alchemy' (or 'alchemy-all-permissions').",
146 ),
147 Flag.optional,
148 Flag.map(Option.getOrUndefined),
149);
150
151const tokenAccountIdFlag = Flag.String("account-id").pipe(
152 Flag.withDescription(
153 "Cloudflare account ID(s) to scope the token to (comma-separated for " +
154 "multiple). If omitted, you'll be prompted to select from your accounts.",
155 ),
156 Flag.optional,
157 Flag.map(
158 Option.match({
159 onNone: () => undefined,
160 onSome: (v) =>
161 v
162 .split(",")
163 .map((s) => s.trim())
164 .filter((s) => s.length > 0),
165 }),
166 ),
167);
168
169/**
170 * `alchemy provider cloudflare create-token` — mint a Cloudflare API token
171 * (`POST /user/tokens`).
172 *
173 * This command is **standalone**: it does not use an Alchemy auth profile.
174 * Cloudflare only mints a token whose permissions the authenticating
175 * credential is allowed to grant — and OAuth/scoped tokens silently produce a
176 * token with zero permissions — so it always authenticates with the account's
177 * **Global API Key** (read from `CLOUDFLARE_API_KEY` / `CLOUDFLARE_EMAIL`,
178 * otherwise prompted). The key is used only to create the token and is never
179 * stored.
180 *
181 * With `--all-permissions` it builds a "superuser" token spanning every
182 * permission group (after a confirmation prompt). Otherwise it prompts the
183 * user to pick which permission groups to grant from the account's live set.
184 *
185 * The token can be scoped to more than one account: pass a comma-separated
186 * list to `--account-id`, or (when neither is supplied) pick multiple accounts
187 * from the interactive selection prompt.
188 */
189const createTokenCommand = Command.make(
190 "token",
191 {
192 envFile,
193 allPermissions: allPermissionsFlag,
194 name: tokenNameFlag,
195 accountId: tokenAccountIdFlag,
196 yes,
197 },
198 instrumentCommand(
199 "cloudflare.create-token",
200 (a: { allPermissions: boolean }) => ({
201 "alchemy.all_permissions": a.allPermissions,
202 }),
203 )(
204 Effect.fn(function* ({
205 envFile,
206 allPermissions,
207 name,
208 accountId,
209 yes: approved,
210 }) {
211 const prompt = yield* CliKit.CliKit;
212 const provider = yield* loadConfigProvider(envFile);
213 const read = <A>(config: Config.Config<Option.Option<A>>) =>
214 config.pipe(
215 Effect.provide(ConfigProvider.layer(provider)),
216 Effect.map(Option.getOrUndefined),
217 );
218 const apiKey =
219 (yield* read(
220 Config.String("CLOUDFLARE_API_KEY").pipe(Config.option),
221 )) ??
222 (yield* prompt.prompt.password({
223 message:
224 "Paste your Global API Key (see bottom of https://dash.cloudflare.com/profile/api-tokens)",
225 validate: (value) =>
226 value.trim().length === 0 ? "Required" : undefined,
227 }));
228 const email =
229 (yield* read(Config.String("CLOUDFLARE_EMAIL").pipe(Config.option))) ??
230 (yield* prompt.prompt.text({
231 message: "Cloudflare account email",
232 validate: (value) =>
233 value.trim().length === 0 ? "Required" : undefined,
234 }));
235 const credentials = { email, apiKey: Redacted.make(apiKey) };
236 const catalog = yield* CloudflareToken.catalog(credentials);
237 const resolvedAccountIds =
238 accountId ??
239 (catalog.accounts.length === 1
240 ? [catalog.accounts[0]!.id]
241 : yield* prompt.prompt.multiSelect<string>({
242 message: "Select the Cloudflare accounts to scope the token to",
243 searchable: true,
244 options: catalog.accounts.map((account) => ({
245 value: account.id,
246 label: account.name,
247 description: account.id,
248 })),
249 required: true,
250 }));
251 const tokenName =
252 name ??
253 (yield* prompt.prompt.text({
254 message: "Token name",
255 placeholder: allPermissions ? "alchemy-superuser" : "alchemy",
256 validate: (value) =>
257 value.trim().length === 0 ? "Token name is required" : undefined,
258 }));
259 const permissionGroupIds = allPermissions
260 ? ("all" as const)
261 : yield* prompt.prompt.multiSelect<string>({
262 message: "Select the permission groups to grant",
263 descriptionPlacement: "inline",
264 options: catalog.permissionGroups
265 .filter(({ selectable }) => selectable)
266 .sort(
267 (a, b) =>
268 (a.category ?? "").localeCompare(b.category ?? "") ||
269 a.name.localeCompare(b.name),
270 )
271 .map((group) => ({
272 value: group.id,
273 label: group.name,
274 description:
275 SELECTABLE_SCOPE_LABELS[group.scopes[0]!] ?? group.scopes[0],
276 })),
277 required: true,
278 });
279 const plan = yield* CloudflareToken.plan({
280 credentials,
281 name: tokenName,
282 accountIds: resolvedAccountIds,
283 permissionGroupIds,
284 });
285 if (plan.grantsFullAccess) {
286 yield* prompt.output.warning(
287 "This token will have FULL access to your Cloudflare account. Keep it secret.",
288 );
289 // `--yes` is the non-interactive path: without it, a CI invocation
290 // of `--all-permissions` dies on the confirm prompt.
291 yield* confirmOrDecline({
292 yes: approved,
293 message: "Create a superuser token with all permissions?",
294 confirmLabel: "Create",
295 cancelLabel: "Cancel",
296 abortMessage: "Cancelled.",
297 });
298 }
299 const result = yield* CloudflareToken.create({
300 credentials,
301 plan,
302 });
303 yield* Console.log(
304 formatCreatedCloudflareToken(result, Redacted.value(result.value)),
305 );
306 }),
307 ),
308).pipe(Command.withDescription("Create a scoped Cloudflare API token"));
309
310const tailFlag = Flag.Boolean("tail").pipe(
311 Flag.withAlias("t"),
312 Flag.withDescription(
313 "Stream logs in real time via the Cloudflare tail websocket instead of fetching past entries.",
314 ),
315 Flag.withDefault(false),
316);
317
318const limitFlag = Flag.Int("limit").pipe(
319 Flag.withDescription("Number of log entries to fetch (ignored with --tail)"),
320 Flag.withDefault(100),
321);
322
323const sinceFlag = Flag.String("since").pipe(
324 Flag.withDescription(
325 "Fetch logs since this time (e.g. '1h', '30m', '2024-01-01T00:00:00Z')",
326 ),
327 Flag.optional,
328 Flag.map(Option.getOrUndefined),
329);
330
331/**
332 * `alchemy provider cloudflare state logs` — get or tail logs from the
333 * `alchemy-state-store` Worker on the user's account. Lets us debug
334 * the state-store worker without standing up a stack file.
335 */
336const stateLogsCommand = Command.make(
337 "logs",
338 {
339 envFile,
340 profile,
341 workerName: cloudflareWorkerName,
342 tail: tailFlag,
343 limit: limitFlag,
344 since: sinceFlag,
345 },
346 instrumentCommand(
347 "cloudflare.state.logs",
348 (a: {
349 profile: string | undefined;
350 workerName: string | undefined;
351 tail: boolean;
352 limit: number;
353 }) => ({
354 "alchemy.profile": a.profile ?? "",
355 "alchemy.worker_name": a.workerName ?? STATE_STORE_SCRIPT_NAME,
356 "alchemy.tail": a.tail,
357 "alchemy.limit": a.limit,
358 }),
359 )(
360 Effect.fn(function* ({ envFile, profile, workerName, tail, limit, since }) {
361 const scriptName = workerName ?? STATE_STORE_SCRIPT_NAME;
362 const target = {
363 profile,
364 workerName,
365 envFile: Option.getOrUndefined(envFile),
366 };
367 const formatLine = (line: { timestamp: Date; message: string }) =>
368 `${formatLocalTimestamp(line.timestamp)} [${scriptName}] ${line.message}`;
369 if (tail) {
370 yield* CliKit.accessors.output.info(`Tailing ${scriptName}...`);
371 yield* Cloudflare.tailStateLogs(target).pipe(
372 Stream.runForEach((line) => Console.log(formatLine(line))),
373 );
374 return;
375 }
376 const lines = yield* Cloudflare.stateLogs({
377 ...target,
378 limit,
379 since: since ? yield* parseSince(since) : undefined,
380 });
381 if (lines.length === 0) {
382 yield* Console.log(`(no log entries for ${scriptName})`);
383 return;
384 }
385 for (const line of lines) yield* Console.log(formatLine(line));
386 }),
387 ),
388).pipe(
389 Command.withDescription("Stream or fetch logs from the state-store worker"),
390);
391
392const stateCommand = Command.make("state", {}).pipe(
393 Command.withDescription("Manage the Cloudflare-hosted state store"),
394 Command.withSubcommands([stateLogsCommand]),
395);
396
397export const cloudflareCommand = Command.make("cloudflare", {}).pipe(
398 Command.withDescription("Manage Cloudflare provider prerequisites"),
399 Command.withSubcommands([
400 bootstrapCommand,
401 teardownCommand,
402 createTokenCommand,
403 stateCommand,
404 ]),
405);