#!/usr/bin/env tsx /** * `pnpm setup:cloud` — provisions the Cloudflare resources a deployment needs, in the * right order, using the resource names already in `wrangler.jsonc` for the chosen env. * * SAFETY: dry-run by default. It prints the exact, personalized, idempotent command * sequence and changes nothing. Pass `--apply` to actually run it against the Cloudflare * account your local `wrangler` is logged into. Review the dry run first. * * What it covers (mechanical, automatable): * - R2 bucket, every Queue the env declares (producers, consumers and their DLQs), D1 database * (idempotent; "already exists" is fine) * - Resolves the real D1 id via `wrangler d1 list --json` and writes it into a gitignored * `wrangler.generated..json` (never edits the tracked wrangler.jsonc) * - Builds the TanStack Start app for the chosen env, patches `dist/server/wrangler.json` * with the real Cloudflare bindings from the generated config, then migrates + deploys * from that built Worker config * - Seeds the first mailbox via `pnpm setup:mailbox` (random `mailbox_id` assigned by the * INSERT; re-running is a no-op because primary_address is UNIQUE) * * A Worker with no mailbox row silently drops every inbound message, so `--apply` requires * --domain/--address unless you explicitly opt out with --skip-seed. * * What it deliberately does NOT do (domain / identity — see the printed "Still required"): * - Custom domain attachment (use `pnpm setup:domain`) * - BETTER_AUTH_SECRET creation (`openssl rand -base64 32 | pnpm wrangler secret put BETTER_AUTH_SECRET`) * - Email Routing DNS/verification (use `pnpm setup:routing`; MX/SPF/DKIM live on your zone) * - Outbound sender identity (use `pnpm setup:sending`) * * Usage: * pnpm setup:cloud --env dev --domain you.com --address inbox@you.com # dry run * pnpm setup:cloud --env dev --domain you.com --address inbox@you.com --apply # provision + deploy + seed */ import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { BUILT_CONFIG_PATH, patchBuiltConfig } from "./lib/built-config"; type WranglerBlock = { name?: string; vars?: { MAIL_FROM_ADDRESS?: string }; workers_dev?: boolean; triggers?: { crons?: string[] }; send_email?: Array<{ name: string }>; durable_objects?: { bindings?: Array<{ name?: string; class_name?: string }> }; r2_buckets?: Array<{ binding: string; bucket_name: string }>; queues?: { producers?: Array<{ binding: string; queue: string }>; consumers?: Array<{ queue: string; dead_letter_queue?: string }>; }; d1_databases?: Array<{ binding: string; database_name: string; database_id?: string }>; migrations?: Array<{ tag?: string; new_sqlite_classes?: string[] }>; compatibility_date?: string; compatibility_flags?: string[]; observability?: { enabled?: boolean }; upload_source_maps?: boolean; }; type WranglerConfig = WranglerBlock & { env?: Record }; function parseArgs(argv: string[]): Record { const args: Record = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg?.startsWith("--")) continue; const [rawKey, inlineValue] = arg.slice(2).split("=", 2); if (!rawKey) continue; if (inlineValue !== undefined) { args[rawKey] = inlineValue; continue; } const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[rawKey] = "true"; continue; } args[rawKey] = next; i += 1; } return args; } function stripJsonc(input: string): string { return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } const args = parseArgs(process.argv.slice(2)); const targetEnv = args.env; // undefined => top-level (production) const apply = args.apply === "true"; const envLabel = targetEnv ?? "production"; // Non-sensitive record of what this env was provisioned with — written near the end of the run. const manifestPath = `.reccado/setup.${envLabel}.json`; const seedDomain = args.domain?.trim().toLowerCase(); const seedAddress = args.address?.trim(); const seedCatchAll = args["catch-all"] === "true"; // Advanced opt-out for re-provisioning an env whose mailbox rows already exist. const skipSeed = args["skip-seed"] === "true"; const { readFileSync } = await import("node:fs"); const config = JSON.parse(stripJsonc(readFileSync("wrangler.jsonc", "utf8"))) as WranglerConfig; const block: WranglerBlock | undefined = targetEnv ? config.env?.[targetEnv] : config; if (!block) { console.error(`setup:cloud: no config block for env "${targetEnv}" in wrangler.jsonc.`); process.exit(1); } const worker = block.name ?? config.name; const r2 = block.r2_buckets?.find((b) => b.binding === "MAIL_OBJECTS")?.bucket_name; const d1Name = block.d1_databases?.find((d) => d.binding === "INDEX_DB")?.database_name; const mailFrom = block.vars?.MAIL_FROM_ADDRESS; // Every queue named anywhere in the env's block has to exist in the account before deploy — // wrangler creates none of them. Derived as the deduplicated union of producers, consumers and // their dead-letter queues rather than a hand-picked pair, so the next queue added to // wrangler.jsonc provisions itself and a self-hoster never hits a deploy that fails on a // queue nobody remembered to add here. const producerQueues = block.queues?.producers?.map((p) => p.queue) ?? []; const consumerQueues = block.queues?.consumers?.map((c) => c.queue) ?? []; const dlqNames = (block.queues?.consumers ?? []) .map((c) => c.dead_letter_queue) .filter((name): name is string => Boolean(name)); const allQueues = [...new Set([...producerQueues, ...consumerQueues, ...dlqNames].filter(Boolean))]; // Kept for the consumer-ownership check and the manifest: the inbound queue is the one whose // consumer a rename can strand, and the pair is what earlier manifests recorded. const queue = producerQueues[0]; const dlq = dlqNames[0]; // A deployment with no queue or no DLQ is not a shape this project has — a missing name means // the config was misread, not that the operator meant to run without ingest. if (!worker || !r2 || !queue || !dlq || !d1Name) { console.error("setup:cloud: could not resolve all resource names from wrangler.jsonc."); console.error({ worker, r2, queue, dlq, d1Name, queues: allQueues }); process.exit(1); } const workerName: string = worker; const queueName: string = queue; // Inbound mail is resolved against D1, so a deployed Worker with no mailbox row accepts nothing. // --apply therefore insists on seed args unless you explicitly opt out. if (apply && !skipSeed && !(seedDomain && seedAddress)) { console.error( "setup:cloud --apply needs --domain --address inbox@ so the first mailbox is seeded\n" + "in the same run — until one exists the deployed Worker has nothing to deliver to.\n" + "Pass --skip-seed only if this env's mailbox rows are already seeded.", ); process.exit(1); } // stderr is PIPED (not inherited) so that error.stderr is populated for the idempotency check // below; on real failures we print it before rethrowing so nothing is swallowed. function wrangler(argv: string[], opts: { capture?: boolean } = {}): string { return execFileSync("pnpm", ["wrangler", ...argv], { encoding: "utf8", stdio: opts.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "pipe"], }); } /** Runs a step, treating an "already exists" failure as success (idempotency). */ function runIdempotent(title: string, argv: string[]): void { console.log(`\n▸ ${title}\n $ pnpm wrangler ${argv.join(" ")}`); if (!apply) return; try { wrangler(argv); } catch (error) { const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" ? (error as { stderr: string }).stderr : ""; const haystack = `${error instanceof Error ? error.message : String(error)}\n${stderr}`; if ( /already exists|already created|already enabled|already taken|already in use|duplicate|409|11009/i.test( haystack, ) ) { console.log(" (already exists — skipping)"); } else { if (stderr) console.error(stderr); throw error; } } } console.log( `\nReccado setup:cloud — env: ${envLabel} · worker: ${worker}` + `\nmode: ${apply ? "APPLY (mutating Cloudflare)" : "dry run (no changes)"}\n`, ); // 1–4. Provision resources (idempotent). We do NOT use --update-config: the D1 id is written // into a generated, gitignored config (step 5) instead of editing the tracked wrangler.jsonc. runIdempotent("Create R2 bucket", ["r2", "bucket", "create", r2]); for (const queueToCreate of allQueues) { runIdempotent(`Create queue ${queueToCreate}`, ["queues", "create", queueToCreate]); } runIdempotent("Create D1 database", ["d1", "create", d1Name]); function buildAppForDeploy(): void { const displayPrefix = targetEnv ? `CLOUDFLARE_ENV=${targetEnv} ` : ""; console.log(`\n▸ Build app for deploy\n $ ${displayPrefix}pnpm run build`); if (!apply) return; execFileSync("pnpm", ["run", "build"], { stdio: "inherit", env: targetEnv ? { ...process.env, CLOUDFLARE_ENV: targetEnv } : process.env, }); } function patchBuiltWranglerConfig(generatedConfigPath: string): void { if (!apply) { console.log( `\n▸ Overlay generated config onto the build\n source: ${generatedConfigPath}\n target: ${BUILT_CONFIG_PATH}\n → would apply the real D1 id (and any vars/route other setup scripts wrote) before migrate/deploy`, ); return; } // The one overlay every deploy path uses (scripts/lib/built-config.ts): the // generated file wins for the fields it owns, wrangler.jsonc for the rest. patchBuiltConfig(targetEnv); } function collectConsumerWorkerNames(value: unknown, names = new Set()): Set { if (!value || typeof value !== "object") return names; if (Array.isArray(value)) { for (const item of value) collectConsumerWorkerNames(item, names); return names; } for (const [key, nested] of Object.entries(value)) { const normalizedKey = key.replace(/[_-]/g, "").toLowerCase(); if ( typeof nested === "string" && ["script", "scriptname", "service", "servicename", "worker", "workername"].includes( normalizedKey, ) ) { names.add(nested); } else { collectConsumerWorkerNames(nested, names); } } return names; } function assertQueueConsumerMatchesWorker(): void { console.log( `\n▸ Check Queue consumer ownership\n $ pnpm wrangler queues consumer list ${queueName} --json`, ); if (!apply) { console.log( ` → would abort if ${queueName} is already consumed by a Worker other than ${workerName}`, ); return; } const raw = wrangler(["queues", "consumer", "list", queueName, "--json"], { capture: true }); const consumerNames = [...collectConsumerWorkerNames(JSON.parse(raw))].filter( (name) => name !== queueName, ); const staleConsumers = consumerNames.filter((name) => name !== workerName); if (staleConsumers.length === 0) return; console.error( ` ${queueName} already has a consumer that is not ${workerName}: ${staleConsumers.join(", ")}\n` + " Cloudflare Queues support one Worker consumer per queue, so deploy would fail.\n" + " Remove the stale consumer, then rerun setup:cloud:\n" + staleConsumers .map((name) => ` pnpm wrangler queues consumer remove ${queueName} ${name}`) .join("\n"), ); process.exit(1); } let resolvedD1Id: string | undefined; // Computed unconditionally so the dry-run prints the same `--config` the apply path uses; the file // itself is only written in apply. At the repo ROOT (gitignored) so downstream scripts can still // use the same relative paths the tracked config would. const generatedConfigPath = `wrangler.generated.${envLabel}.json`; console.log(`\n▸ Resolve D1 database_id + render deploy config`); if (apply) { try { const list = JSON.parse(wrangler(["d1", "list", "--json"], { capture: true })) as Array<{ name: string; uuid?: string; database_id?: string; }>; const found = list.find((d) => d.name === d1Name); resolvedD1Id = found?.uuid ?? found?.database_id; } catch { // fall through to the guard below } if (!resolvedD1Id) { console.error( ` Could not resolve the id for D1 "${d1Name}" (check \`wrangler whoami\`). Aborting before deploy.`, ); process.exit(1); } // Update the existing generated file rather than re-snapshotting wrangler.jsonc over it: // a fresh snapshot silently erased what setup:sending / setup:domain had written there. const renderSource = existsSync(generatedConfigPath) ? generatedConfigPath : "wrangler.jsonc"; const full = JSON.parse(stripJsonc(readFileSync(renderSource, "utf8"))) as WranglerConfig; const targetBlock = targetEnv ? full.env?.[targetEnv] : full; const d1Entry = targetBlock?.d1_databases?.find((d) => d.binding === "INDEX_DB"); if (d1Entry) d1Entry.database_id = resolvedD1Id; writeFileSync(generatedConfigPath, `${JSON.stringify(full, null, 2)}\n`); console.log(` ${d1Name} → ${resolvedD1Id}`); console.log(` Wrote ${generatedConfigPath} (source of truth for build patching; gitignored).`); } else { console.log( ` → would resolve ${d1Name}'s id and write ${generatedConfigPath}, then patch dist/server/wrangler.json from it`, ); } const builtConfigFlag = ["--config", BUILT_CONFIG_PATH]; // 6. Build the app for the chosen env so deploy targets the TanStack/Vite output. buildAppForDeploy(); // 7. Patch the built worker config with the real Cloudflare bindings from the generated config. patchBuiltWranglerConfig(generatedConfigPath); // 8. Remote migrations (against the built config, now patched with the real id/bindings). runIdempotent("Apply D1 migrations (remote)", [ "d1", "migrations", "apply", d1Name, "--remote", ...builtConfigFlag, ]); // 9. Renames can leave a Queue wired to the old Worker name. Catch that before deploy's // trigger-registration step fails with a generic consumer-conflict error. assertQueueConsumerMatchesWorker(); // 10. Deploy the Worker from the patched build. runIdempotent("Deploy the Worker", ["deploy", ...builtConfigFlag]); // 11. First mailbox seed. Delegates to setup-mailbox.ts, which assigns a random mailbox_id on // insert and no-ops on an address that is already seeded — so this is safe to re-run. console.log(`\n▸ Seed the first mailbox`); if (seedDomain && seedAddress) { const mailboxArgs = [ "setup:mailbox", "--domain", seedDomain, "--address", seedAddress, ...(targetEnv ? ["--env", targetEnv] : []), ...(seedCatchAll ? ["--catch-all"] : []), ]; if (!apply) { console.log(` → would run: pnpm ${mailboxArgs.join(" ")} --apply`); } else { console.log(` Seeding ${seedAddress}…`); execFileSync("pnpm", [...mailboxArgs, "--apply"], { stdio: "inherit" }); } } else if (skipSeed) { console.log(" → (skipped via --skip-seed) seed later with setup:mailbox."); } else { console.log( " → no --domain/--address given; --apply would require them (or --skip-seed). Seed later with setup:mailbox.", ); } // Manifest (apply only): a non-sensitive record of what this env was provisioned with, so a later // run or `pnpm doctor` can compare intent against the live account. if (apply) { mkdirSync(".reccado", { recursive: true }); const manifest = { env: envLabel, worker, r2, queue, dlq, queues: allQueues, d1Name, d1Id: resolvedD1Id ?? null, mailFrom, mailbox: seedDomain && seedAddress ? { domain: seedDomain, address: seedAddress } : null, updatedAt: new Date().toISOString(), }; writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); console.log(`\nWrote ${manifestPath}`); } // Still-required footer (the irreducible domain/identity steps). console.log(`\n${"─".repeat(72)}`); console.log("Still required (domain / identity — not automatable here):\n"); console.log("1. Attach a custom domain before using the UI/API as a real inbox:"); console.log( ` pnpm setup:domain${targetEnv ? ` --env ${targetEnv}` : ""} --hostname app. --apply`, ); console.log( "\n2. The web login needs its machine-generated secret (entropy no human transcribes):", ); console.log( ` openssl rand -base64 32 | pnpm wrangler secret put BETTER_AUTH_SECRET${targetEnv ? ` --env ${targetEnv}` : ""}` + "\n The login page lives at /login; sign-in codes ride the transactional stream.", ); console.log("\n3. Email Routing must deliver to this Worker (DNS lives on your zone):"); if (mailFrom) { const domain = mailFrom.split("@")[1]; console.log( ` pnpm setup:routing --domain ${domain ?? ""}${targetEnv ? ` --env ${targetEnv}` : ""} --apply`, ); } console.log( `\nRe-check anytime: pnpm doctor --env ${targetEnv ?? "production"} --cloud --url https://app.\n`, ); if (!apply) { console.log("Dry run only. Re-run with --apply to execute against Cloudflare.\n"); }