Ctrl K
ComunidadGuías…

scripts/setup-cloud.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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.<env>.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<string, WranglerBlock> };

function parseArgs(argv: string[]): Record<string, string> {
	const args: Record<string, string> = {};
	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 <d> --address inbox@<d> 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<string>()): Set<string> {
	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.<your-domain> --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 ?? "<your-domain>"}${targetEnv ? ` --env ${targetEnv}` : ""} --apply`,
	);
}
console.log(
	`\nRe-check anytime:  pnpm doctor --env ${targetEnv ?? "production"} --cloud --url https://app.<your-domain>\n`,
);

if (!apply) {
	console.log("Dry run only. Re-run with --apply to execute against Cloudflare.\n");
}