| 1 | import * as Config from "effect/Config"; |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 3 | import * as Console from "effect/Console"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { Command, Flag } from "effect/cli"; |
| 9 | |
| 10 | import * as Cloudflare from "../../Alchemist/routes/cloudflare.ts"; |
| 11 | import * as CloudflareToken from "../../Alchemist/routes/cloudflareToken.ts"; |
| 12 | import type { CreatedToken } from "../../Alchemist/routes/cloudflareToken.ts"; |
| 13 | import { STATE_STORE_SCRIPT_NAME } from "../../Cloudflare/StateStore/Api.ts"; |
| 14 | import * as CliKit from "../../Cli/CliKit/index.ts"; |
| 15 | import { formatLocalTimestamp } from "../Format.ts"; |
| 16 | import { loadConfigProvider } from "../../Util/ConfigProvider.ts"; |
| 17 | import { confirmOrDecline } from "./confirm.ts"; |
| 18 | import { envFile, parseSince, profile, yes } from "./flags.ts"; |
| 19 | import { instrumentCommand } from "./instrument.ts"; |
| 20 | |
| 21 | const 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 | |
| 28 | export 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 | |
| 50 | const 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 | |
| 58 | const 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 | |
| 66 | const 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 | |
| 101 | const 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 | ); |
| 135 | const 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 | |
| 143 | const 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 | |
| 151 | const 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 | */ |
| 189 | const 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 | |
| 310 | const 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 | |
| 318 | const limitFlag = Flag.Int("limit").pipe( |
| 319 | Flag.withDescription("Number of log entries to fetch (ignored with --tail)"), |
| 320 | Flag.withDefault(100), |
| 321 | ); |
| 322 | |
| 323 | const 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 | */ |
| 336 | const 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 | |
| 392 | const stateCommand = Command.make("state", {}).pipe( |
| 393 | Command.withDescription("Manage the Cloudflare-hosted state store"), |
| 394 | Command.withSubcommands([stateLogsCommand]), |
| 395 | ); |
| 396 | |
| 397 | export 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 | ); |