#!/usr/bin/env tsx /** * `pnpm doctor` — diagnoses a Reccado checkout and tells you the exact command to * fix whatever is incomplete, instead of failing opaquely at `pnpm dev` or deploy. * * Default run is offline and deterministic (toolchain + local dev + config placeholders). * Pass `--cloud` to add remote checks (auth, D1 exists + id match, every declared Queue exists and * its DLQs are consumed, every sending domain publishes its lifecycle events to the events queue, * required secrets, the tg-hq forum topic registry agrees with D1's topic mappings, and — with `--url` — that the deployed login page and /api/health answer). * Exhaustive R2/queue/Email-Routing *binding* wiring lives in `pnpm verify:cf`. * * Usage: * pnpm doctor # local + config checks for the default (production) config * pnpm doctor --env dev # inspect the env.dev block instead * pnpm doctor --env dev --cloud --url https://… # remote: D1, secrets, auth endpoints * pnpm -s doctor:json # machine-readable output (-s drops pnpm's banner; exit 1 on any fail) */ import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { generatedConfigPathFor } from "./lib/built-config"; import { applyGeneratedOverlay, deployedVars, type WranglerConfig as DeployWranglerConfig, effectiveBlock, parseDomainList, } from "./lib/deploy-config"; import { classifyDmarc, lookupTxt } from "./lib/dmarc"; import { type AccountQueue, crossCheckSendingDomains, describeFeedbackVerdict, EMAIL_SENDING_EVENT_TYPES, evaluateFeedbackSubscription, MIN_WRANGLER_FOR_EMAIL_SENDING, parseQueueListTable, parseSendingDomainsTable, parseSubscriptionListJson, } from "./lib/event-subscriptions"; import { compareTopicRegistry, parseTopicRegistry, type TopicMapping, } from "./lib/telegram-topic-registry"; type Status = "pass" | "warn" | "fail" | "info"; type Check = { id: string; status: Status; message: string; fix?: string }; const SYMBOL: Record = { pass: "✓", warn: "!", fail: "✗", info: "·" }; const PROD_D1_PLACEHOLDER = ""; const DEV_D1_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"; const EXAMPLE_FROM = "noreply@mail.example.com"; 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, ""); } /** Compares dotted numeric versions. Returns -1/0/1 for ab. */ function compareVersions(a: string, b: string): number { const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0); const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { const diff = (pa[i] ?? 0) - (pb[i] ?? 0); if (diff !== 0) return diff > 0 ? 1 : -1; } return 0; } function parseDotEnv(content: string): Map { const map = new Map(); for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const sep = trimmed.indexOf("="); if (sep === -1) continue; map.set(trimmed.slice(0, sep).trim(), trimmed.slice(sep + 1).trim()); } return map; } const checks: Check[] = []; const add = (c: Check) => checks.push(c); const args = parseArgs(process.argv.slice(2)); const targetEnv = args.env; // undefined => top-level (production) config const asJson = args.json === "true"; // --- Toolchain --------------------------------------------------------------- const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { engines?: { node?: string }; packageManager?: string; }; const requiredNode = (pkg.engines?.node ?? "").replace(/[^\d.]/g, ""); const currentNode = process.versions.node; if (requiredNode && compareVersions(currentNode, requiredNode) < 0) { add({ id: "node.version", status: "fail", message: `Node ${currentNode} is below required >=${requiredNode}.`, fix: `Install Node >=${requiredNode} (see .node-version / your version manager).`, }); } else { add({ id: "node.version", status: "pass", message: `Node ${currentNode} satisfies engines.node.`, }); } const pinnedPnpm = pkg.packageManager?.startsWith("pnpm@") ? pkg.packageManager.slice("pnpm@".length) : undefined; try { const pnpmVersion = execFileSync("pnpm", ["--version"], { encoding: "utf8" }).trim(); if (pinnedPnpm && pnpmVersion !== pinnedPnpm) { add({ id: "pnpm.version", status: "warn", message: `pnpm ${pnpmVersion} differs from pinned pnpm@${pinnedPnpm}.`, fix: "Run `corepack enable` so the repo-pinned pnpm is used automatically.", }); } else { add({ id: "pnpm.version", status: "pass", message: `pnpm ${pnpmVersion} matches packageManager.`, }); } } catch { add({ id: "pnpm.version", status: "fail", message: "pnpm is not runnable.", fix: "Enable it with `corepack enable` or install pnpm.", }); } try { const wranglerVersion = execFileSync("pnpm", ["wrangler", "--version"], { encoding: "utf8" }) .trim() .split(/\s+/) .find((t) => /^\d+\.\d+/.test(t)); if (wranglerVersion && compareVersions(wranglerVersion, "4.0.0") >= 0) { add({ id: "wrangler.version", status: "pass", message: `Wrangler ${wranglerVersion} (4.x).` }); } else { add({ id: "wrangler.version", status: "warn", message: `Wrangler ${wranglerVersion ?? "unknown"} — this repo assumes 4.x.`, fix: "Update the `wrangler` devDependency to ^4.x.", }); } } catch { add({ id: "wrangler.version", status: "fail", message: "Wrangler is not runnable.", fix: "Run `pnpm install` to install the pinned wrangler.", }); } // --- Local dev --------------------------------------------------------------- if (!existsSync(".dev.vars")) { add({ id: "devvars.present", status: "info", message: ".dev.vars is absent — `pnpm dev` will generate a minimal one automatically.", }); } else { const devVars = parseDotEnv(readFileSync(".dev.vars", "utf8")); add({ id: "devvars.present", status: "pass", message: ".dev.vars exists." }); const secret = devVars.get("BETTER_AUTH_SECRET")?.trim(); if (!secret) { add({ id: "devvars.auth-bypass", status: "pass", message: "BETTER_AUTH_SECRET is unset locally — local-dev bypass is active.", }); } else if (secret.length < 32) { add({ id: "devvars.auth-bypass", status: "fail", message: "BETTER_AUTH_SECRET is set but shorter than 32 characters — the issuer refuses to start.", fix: "Generate a real secret: openssl rand -base64 32", }); } else { add({ id: "devvars.auth-bypass", status: "pass", message: "BETTER_AUTH_SECRET is set — real Better Auth sessions locally.", }); } } const migrationFiles = existsSync("migrations/d1") ? readdirSync("migrations/d1").filter((f) => f.endsWith(".sql")) : []; if (migrationFiles.length > 0) { add({ id: "migrations.present", status: "pass", message: `${migrationFiles.length} D1 migration file(s) found.`, }); } else { add({ id: "migrations.present", status: "fail", message: "No D1 migration files under migrations/d1.", fix: "Restore migrations/d1/*.sql (the schema source of truth).", }); } // --- Config (wrangler.jsonc) ------------------------------------------------- type WranglerConfig = { name?: string; workers_dev?: boolean; routes?: Array<{ pattern: string; custom_domain?: boolean }>; vars?: { MAIL_FROM_ADDRESS?: 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 }>; env?: Record; }; const wrangler = JSON.parse(stripJsonc(readFileSync("wrangler.jsonc", "utf8"))) as WranglerConfig; const block: WranglerConfig | undefined = targetEnv ? wrangler.env?.[targetEnv] : wrangler; const envLabel = targetEnv ?? "production (top-level)"; if (!block) { add({ id: "config.env", status: "fail", message: `No config block for env "${targetEnv}" in wrangler.jsonc.`, fix: "Pass a valid --env (e.g. dev) or omit it for the default config.", }); } else { const d1Id = block.d1_databases?.find((d) => d.binding === "INDEX_DB")?.database_id ?? ""; const isPlaceholder = d1Id === PROD_D1_PLACEHOLDER || d1Id === DEV_D1_PLACEHOLDER || d1Id === ""; if (!isPlaceholder) { add({ id: "config.d1-id", status: "pass", message: `INDEX_DB database_id is set for ${envLabel}.`, }); } else if (targetEnv) { add({ id: "config.d1-id", status: "warn", message: `INDEX_DB database_id is a placeholder for ${envLabel} — fine locally, not for a remote deploy.`, fix: `pnpm setup:cloud --env ${targetEnv} --domain --address inbox@ --apply (writes a gitignored generated config; don't edit wrangler.jsonc)`, }); } else { add({ id: "config.d1-id", status: "warn", message: `INDEX_DB database_id is a placeholder for ${envLabel} — local dev is fine, but a remote deploy will fail until it is set.`, fix: "pnpm setup:cloud --domain --address inbox@ --apply (writes a gitignored generated config; don't edit wrangler.jsonc)", }); } const from = block.vars?.MAIL_FROM_ADDRESS; if (from && from !== EXAMPLE_FROM) { add({ id: "config.mail-from", status: "pass", message: `MAIL_FROM_ADDRESS set for ${envLabel}.`, }); } else { add({ id: "config.mail-from", status: "warn", message: `MAIL_FROM_ADDRESS is still the ${EXAMPLE_FROM} example for ${envLabel}.`, fix: "Set vars.MAIL_FROM_ADDRESS to a verified Email Sending address.", }); } if (block.workers_dev === false) { add({ id: "config.public-host", status: "pass", message: `${envLabel} disables workers.dev; use a custom domain for the deployed UI/API.`, }); } else if (targetEnv === "dev") { add({ id: "config.public-host", status: "info", message: "dev keeps workers.dev available for remote smoke tests; use a custom domain for the authenticated UI/API.", fix: `Attach a custom domain with pnpm setup:domain --env dev --hostname app.`, }); } else { add({ id: "config.public-host", status: "warn", message: `${envLabel} still exposes workers.dev.`, fix: `Set workers_dev=false and attach a custom domain with pnpm setup:domain${targetEnv ? ` --env ${targetEnv}` : ""} --hostname app.`, }); } const customDomainRoutes = block.routes?.filter((route) => route.custom_domain).map((route) => route.pattern) ?? []; if (customDomainRoutes.length > 0) { add({ id: "config.custom-domain", status: "pass", message: `Custom domain route configured: ${customDomainRoutes.join(", ")}.`, }); } else { add({ id: "config.custom-domain", status: targetEnv ? "info" : "warn", message: "No custom domain route is configured in tracked wrangler.jsonc.", fix: `Use pnpm setup:domain${targetEnv ? ` --env ${targetEnv}` : ""} --hostname app. (writes a gitignored generated config; doesn't edit wrangler.jsonc).`, }); } } // --- Config that will actually deploy ------------------------------------------ // // `pnpm run deploy[:dev]` ships wrangler.jsonc with the generated file's owned // fields overlaid (scripts/lib/deploy-config.ts). Every check below that reasons // about "the deployed config" reads it through the same function, so doctor and // the deploy cannot disagree about what ships. const generatedPath = generatedConfigPathFor(targetEnv); let generatedConfig: DeployWranglerConfig | undefined; try { generatedConfig = existsSync(generatedPath) ? (JSON.parse(stripJsonc(readFileSync(generatedPath, "utf8"))) as DeployWranglerConfig) : undefined; } catch { add({ id: "config.deploy-overlay", status: "fail", message: `${generatedPath} exists but is not valid JSON — every deploy of ${envLabel} will refuse to run.`, fix: `Fix or delete ${generatedPath}, then re-run the setup script that wrote it.`, }); } let deployVars: Record = {}; if (block) { const trackedFull = wrangler as DeployWranglerConfig; try { deployVars = deployedVars(trackedFull, generatedConfig, targetEnv); if (generatedConfig) { const overlay = applyGeneratedOverlay({ base: effectiveBlock(trackedFull, targetEnv), generated: effectiveBlock(generatedConfig, targetEnv), tracked: effectiveBlock(trackedFull, targetEnv), }); // A generated var that overrides a DIFFERENT tracked value is the drift a // reader of wrangler.jsonc cannot see; one that only adds is the normal case. const conflicting = overlay.applied.filter( (change) => change.field.startsWith("vars.") && change.from !== undefined, ); const ignoredNote = overlay.ignored.length > 0 ? ` Ignored there (wrangler.jsonc wins): ${overlay.ignored.join(", ")}.` : ""; add( conflicting.length > 0 ? { id: "config.deploy-overlay", status: "warn", message: `${generatedPath} overrides wrangler.jsonc on deploy: ${conflicting.map((c) => `${c.field} ${JSON.stringify(c.from)} -> ${JSON.stringify(c.to)}`).join("; ")}.${ignoredNote}`, fix: "The generated value is what ships. Make the two agree (or delete the var from one file) so wrangler.jsonc does not mislead.", } : { id: "config.deploy-overlay", status: overlay.ignored.length > 0 ? "info" : "pass", message: `Deploys overlay ${generatedPath} (${overlay.applied.length} owned field(s) applied, none contradicting wrangler.jsonc).${ignoredNote}`, }, ); } else { add({ id: "config.deploy-overlay", status: "info", message: `No ${generatedPath} — deploys ship wrangler.jsonc as built.`, }); } } catch (error) { add({ id: "config.deploy-overlay", status: "fail", message: `Could not compute the deployed config: ${error instanceof Error ? error.message : error}.`, }); } } function addAll(items: Check[]): void { for (const c of items) add(c); } /** Confirms the target env's INDEX_DB database exists in the account and its id matches config. */ function checkD1Remote(): Check[] { const entry = block?.d1_databases?.find((d) => d.binding === "INDEX_DB"); if (!entry?.database_name) return []; try { const list = JSON.parse( execFileSync("pnpm", ["wrangler", "d1", "list", "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }), ) as Array<{ name: string; uuid?: string; database_id?: string }>; const found = list.find((d) => d.name === entry.database_name); if (!found) { return [ { id: "cloud.d1", status: "fail", message: `D1 "${entry.database_name}" not found in this account.`, fix: `pnpm setup:cloud${targetEnv ? ` --env ${targetEnv}` : ""} --domain --address inbox@ --apply`, }, ]; } const realId = found.uuid ?? found.database_id; const isPlaceholder = entry.database_id === PROD_D1_PLACEHOLDER || entry.database_id === DEV_D1_PLACEHOLDER; if (!isPlaceholder && realId && realId !== entry.database_id) { return [ { id: "cloud.d1", status: "warn", message: `D1 "${entry.database_name}" exists but its id (${realId}) differs from wrangler.jsonc (${entry.database_id}).`, fix: "Update the INDEX_DB database_id, or deploy with the generated wrangler config.", }, ]; } return [ { id: "cloud.d1", status: "pass", message: `D1 "${entry.database_name}" exists in the account.`, }, ]; } catch { return [{ id: "cloud.d1", status: "warn", message: "Could not list D1 databases (auth?)." }]; } } // `queues list` may or may not accept --json depending on the wrangler in play, so // the first page decides which surface to read and the rest follow it: no repeated // failures. let queueListJson = true; function runQueueList(page: number): string | null { const argv = ["wrangler", "queues", "list", "--page", String(page)]; if (queueListJson) { try { return execFileSync("pnpm", [...argv, "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } catch { queueListJson = false; } } try { return execFileSync("pnpm", argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } catch { return null; } } function parseQueueListJson(raw: string): AccountQueue[] | null { const start = raw.indexOf("["); if (start === -1) return null; try { const parsed = JSON.parse(raw.slice(start)) as unknown; if (!Array.isArray(parsed)) return null; return parsed.map((entry: Record) => ({ id: String(entry.queue_id ?? entry.id ?? ""), name: String(entry.queue_name ?? entry.name ?? ""), consumers: Array.isArray(entry.consumers) ? entry.consumers.length : Number(entry.consumers_total_count ?? 0), })); } catch { return null; } } /** * Queue name → the account's row for it, or null if it can't be read. The id * matters as much as the consumer count: an event subscription's destination is * a queue id, so nothing can tell "subscribed to us" from "subscribed to the * other environment" without this mapping. */ function listAccountQueues(): Map | null { const byName = new Map(); // Paginated: a queue that merely sits on page 2 would otherwise be reported as // a missing one, which is a deploy-breaking verdict. for (let page = 1; page <= 10; page += 1) { const raw = runQueueList(page); if (raw === null) return page === 1 ? null : byName; const rows = parseQueueListJson(raw) ?? parseQueueListTable(raw); if (rows.length === 0) break; for (const row of rows) { if (row.name) byName.set(row.name, row); } } return byName; } /** * Queues are the one binding wrangler never creates for you: deploy fails outright * on a queue the config names and the account lacks, and `pnpm doctor` existed * precisely so that failure arrives here instead. The declared set is the same * union setup:cloud provisions — producers, consumers and their dead-letter queues, * deduplicated — read the same way so the two cannot drift. * * A DLQ with no consumer is not a deploy failure but is worse in production: dead * messages sit there unread until Queues retention drops them (~4 days) and the * evidence of what broke is gone. */ function checkQueuesRemote(): Check[] { const dlqs = (block?.queues?.consumers ?? []) .map((consumer) => consumer.dead_letter_queue) .filter((name): name is string => Boolean(name)); const declared = [ ...new Set([ ...(block?.queues?.producers ?? []).map((producer) => producer.queue), ...(block?.queues?.consumers ?? []).map((consumer) => consumer.queue), ...dlqs, ]), ].filter(Boolean); if (declared.length === 0) return []; const account = listAccountQueues(); if (!account) { return [ { id: "cloud.queues", status: "warn", message: "Could not list Queues (auth?).", fix: "Run `pnpm wrangler login`, then re-run with --cloud.", }, ]; } const out: Check[] = []; const missing = declared.filter((name) => !account.has(name)); if (missing.length > 0) { out.push({ id: "cloud.queues", status: "fail", message: `Queue(s) declared for ${envLabel} but absent from this account: ${missing.join(", ")} — deploy will fail.`, fix: `pnpm setup:cloud${targetEnv ? ` --env ${targetEnv}` : ""} --domain --address inbox@ --apply (or pnpm wrangler queues create ${missing[0]})`, }); } else { out.push({ id: "cloud.queues", status: "pass", message: `All ${declared.length} queue(s) declared for ${envLabel} exist in the account.`, }); } // Only DLQs that exist can be judged on their consumers; the missing ones are // already a fail above, and saying "all DLQs have a consumer" about queues that // are not there would be the false reassurance this check exists to remove. const existingDlqs = dlqs.filter((name) => account.has(name)); const orphanDlqs = existingDlqs.filter((name) => account.get(name)?.consumers === 0); if (orphanDlqs.length > 0) { out.push({ id: "cloud.queues.dlq", status: "warn", message: `Dead-letter queue(s) with no consumer: ${orphanDlqs.join(", ")} — dead messages expire unread (~4 days) and leave no tombstone.`, fix: `Declare each DLQ under queues.consumers (the tombstone handler), then deploy — a consumer declared but never deployed looks exactly like this from the account side.`, }); } else if (existingDlqs.length > 0) { out.push({ id: "cloud.queues.dlq", status: "pass", message: `All ${existingDlqs.length} dead-letter queue(s) have a consumer.`, }); } return out; } /** * Whether every sending domain Reccado owns can actually report what happened to * the mail it sends. * * Enabling Email Sending on a domain gives it the ability to send; only an event * subscription gives it the ability to *answer*. Nothing joined those two facts, * so the account ran for weeks with four enabled sending domains and one * subscription, and three features went on reading "no delivery event" as * evidence about the message rather than about the channel. * * Two live provider lists are crossed here, never a flag written at setup time: * a flag records what someone intended once, and the failure being caught is * precisely reality drifting from that intention. * * Existence alone is a false pass, so three things are checked: the subscription * exists, its event-type set covers all six, and its destination is this * environment's queue. A subscription with only `message.delivered` selected * satisfies a naive check while suppression stays dark. * * Scope is the sending domains sitting under a zone Reccado has registered in * D1. An account may host sending domains for other projects entirely, and * failing a Reccado diagnostic over someone else's subdomain would be noise that * teaches the operator to skip the whole check. */ function checkSendingFeedbackRemote(): Check[] { const queueName = block?.queues?.producers?.find( (producer) => producer.binding === "EMAIL_EVENTS_QUEUE", )?.queue; if (!queueName) return []; const account = listAccountQueues(); const queueId = account?.get(queueName)?.id; if (!queueId) { // checkQueuesRemote already reports a missing or unreadable queue; repeating // it as a second failure would double-count one fault. return [ { id: "cloud.sending-feedback", status: "warn", message: `Could not resolve the id of queue ${queueName}, so its event subscriptions cannot be checked.`, fix: "Run `pnpm wrangler login`, then re-run with --cloud.", }, ]; } const ours = listRegisteredDomains(); if (ours === null) { return [ { id: "cloud.sending-feedback", status: "warn", message: "Could not read the registered domains from D1, so sending feedback is unchecked.", fix: `pnpm d1:migrate:${targetEnv ?? "prod"}, or check Cloudflare auth.`, }, ]; } let sendingDomains: Array<{ zone: string; name: string; enabled: boolean }>; try { sendingDomains = parseSendingDomainsTable( execFileSync("pnpm", ["wrangler", "email", "sending", "list"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }), ); } catch { return [ { id: "cloud.sending-feedback", status: "warn", message: "Could not list Email Sending domains (auth? open-beta command changed?).", fix: "pnpm wrangler email sending list", }, ]; } const mine = sendingDomains.filter( (domain) => domain.enabled && ours.has(domain.zone.trim().toLowerCase()), ); if (mine.length === 0) { return [ { id: "cloud.sending-feedback", status: "info", message: ours.size === 0 ? "No domains are registered yet, so there is no sending feedback to check." : "No enabled Email Sending domain belongs to a registered domain yet.", fix: `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain --apply`, }, ]; } let subscriptions: ReturnType; try { subscriptions = parseSubscriptionListJson( execFileSync("pnpm", ["wrangler", "queues", "subscription", "list", queueName, "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }), ); } catch { subscriptions = null; } if (!subscriptions) { return [ { id: "cloud.sending-feedback", status: "warn", message: `Could not read the event subscriptions on ${queueName}.`, fix: `pnpm wrangler queues subscription list ${queueName} --json — needs wrangler >= ${MIN_WRANGLER_FOR_EMAIL_SENDING}.`, }, ]; } const broken: string[] = []; for (const domain of mine) { const verdict = evaluateFeedbackSubscription({ sendingDomain: domain.name, expectedQueueId: queueId, subscriptions, }); if (verdict.state !== "live") { broken.push(describeFeedbackVerdict(domain.name, queueName, verdict)); } } if (broken.length > 0) { return [ { id: "cloud.sending-feedback", status: "fail", message: broken.join(" "), fix: `pnpm setup:sending${targetEnv ? ` --env ${targetEnv}` : ""} --domain --subdomain