import { spawnSync } from "node:child_process" import { existsSync } from "node:fs" import path from "node:path" import process from "node:process" import dotenv from "dotenv" const REQUIRED_ENV_GROUPS = [ { label: "Core site", keys: ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_SITE_DOMAIN", "NEXT_PUBLIC_CONVEX_URL"], }, { label: "Voice and chat", keys: ["LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "XAI_API_KEY", "VOICE_ASSISTANT_SITE_URL"], }, { label: "Manual asset delivery", keys: ["R2_MANUALS_BUCKET", "R2_THUMBNAILS_BUCKET"], }, { label: "Admin and auth", keys: ["ADMIN_EMAIL", "ADMIN_PASSWORD", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"], }, { label: "Stripe", keys: ["STRIPE_SECRET_KEY", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "STRIPE_WEBHOOK_SECRET"], }, ] const OPTIONAL_ENV_GROUPS = [] const IGNORED_HANDOFF_ENV = { GHL_API_TOKEN: "not used by the current code path", ADMIN_API_TOKEN: "not used by the current code path", ADMIN_UI_ENABLED: "not used by the current code path", USESEND_API_KEY: "not used by the current code path", USESEND_BASE_URL: "not used by the current code path", CONVEX_URL: "use NEXT_PUBLIC_CONVEX_URL instead", CONVEX_SELF_HOSTED_URL: "not used by the current code path", CONVEX_SELF_HOSTED_ADMIN_KEY: "not used by the current code path", } function parseArgs(argv) { const args = { envFile: ".env.local", build: false, allowDirty: false, } for (let index = 0; index < argv.length; index += 1) { const token = argv[index] if (token === "--build") { args.build = true continue } if (token === "--allow-dirty") { args.allowDirty = true continue } if (token === "--env-file") { args.envFile = argv[index + 1] index += 1 continue } } return args } function runShell(command, options = {}) { const result = spawnSync(command, { shell: true, cwd: process.cwd(), encoding: "utf8", stdio: options.inherit ? "inherit" : "pipe", }) if (result.error) { throw result.error } return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", } } function readValue(name) { return String(process.env[name] ?? "").trim() } function hasVoiceRecordingConfig() { return [ readValue("VOICE_RECORDING_ACCESS_KEY_ID") || readValue("CLOUDFLARE_R2_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY"), readValue("VOICE_RECORDING_SECRET_ACCESS_KEY") || readValue("CLOUDFLARE_R2_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_KEY"), readValue("VOICE_RECORDING_ENDPOINT") || readValue("CLOUDFLARE_R2_ENDPOINT"), readValue("VOICE_RECORDING_BUCKET"), ].every(Boolean) } function hasManualStorageCredentials() { return [ readValue("CLOUDFLARE_R2_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY_ID") || readValue("AWS_ACCESS_KEY"), readValue("CLOUDFLARE_R2_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_ACCESS_KEY") || readValue("AWS_SECRET_KEY"), ].every(Boolean) } function hasManualStorageEndpoint() { return Boolean( readValue("MANUALS_STORAGE_ENDPOINT") || readValue("S3_ENDPOINT_URL") || readValue("CLOUDFLARE_R2_ENDPOINT"), ) } function heading(title) { console.log(`\n== ${title} ==`) } function main() { const args = parseArgs(process.argv.slice(2)) const failures = [] const warnings = [] const envFilePath = path.resolve(process.cwd(), args.envFile) if (args.envFile && existsSync(envFilePath)) { dotenv.config({ path: envFilePath, override: false }) } else if (args.envFile) { warnings.push(`Env file not found: ${envFilePath}`) } heading("Repository") const branchResult = runShell("git rev-parse --abbrev-ref HEAD") const branch = branchResult.status === 0 ? branchResult.stdout.trim() : "unknown" console.log(`Branch: ${branch}`) if (branch !== "main") { failures.push(`Release branch must be main. Current branch is ${branch}.`) } const remoteResult = runShell("git remote get-url origin") if (remoteResult.status === 0) { console.log(`Origin: ${remoteResult.stdout.trim()}`) } else { failures.push("Unable to resolve git remote origin.") } const statusResult = runShell("git status --short") const worktreeStatus = statusResult.stdout.trim() if (worktreeStatus) { console.log("Worktree: dirty") if (!args.allowDirty) { failures.push("Git worktree is dirty. Deploy only from a clean reviewed commit on main.") } } else { console.log("Worktree: clean") } console.log(`Dockerfile: ${existsSync(path.join(process.cwd(), "Dockerfile")) ? "present" : "missing"}`) console.log(`next.config.mjs: ${existsSync(path.join(process.cwd(), "next.config.mjs")) ? "present" : "missing"}`) if (!existsSync(path.join(process.cwd(), "Dockerfile"))) { failures.push("Dockerfile is missing from the repository root.") } if (!existsSync(path.join(process.cwd(), "next.config.mjs"))) { failures.push("next.config.mjs is missing from the repository root.") } heading("Environment") for (const group of REQUIRED_ENV_GROUPS) { const missingKeys = group.keys.filter((key) => !readValue(key)) if (missingKeys.length === 0) { console.log(`${group.label}: ok`) continue } failures.push(`${group.label} missing: ${missingKeys.join(", ")}`) console.log(`${group.label}: missing ${missingKeys.join(", ")}`) } for (const group of OPTIONAL_ENV_GROUPS) { const missingKeys = group.keys.filter((key) => !readValue(key)) if (missingKeys.length === 0) { console.log(`${group.label}: ok`) continue } warnings.push(`${group.label} missing: ${missingKeys.join(", ")}. ${group.note}`) console.log(`${group.label}: fallback in use`) } if (!hasManualStorageCredentials()) { failures.push("Manual asset storage credentials are incomplete. Set R2/S3 access key and secret env vars before release.") } else if (!hasManualStorageEndpoint()) { failures.push("Manual asset storage endpoint is incomplete. Set S3_ENDPOINT_URL or CLOUDFLARE_R2_ENDPOINT before release.") } else { console.log("Manual asset storage credentials: present") } const recordingRequested = readValue("VOICE_RECORDING_ENABLED").toLowerCase() if (recordingRequested === "true" && !hasVoiceRecordingConfig()) { failures.push( "Voice recording is enabled but recording storage is incomplete. Set VOICE_RECORDING_BUCKET plus access key, secret, and endpoint env vars.", ) } else if (hasVoiceRecordingConfig()) { console.log("Voice recordings: storage config present") } else { warnings.push("Voice recording storage env vars are not configured yet.") console.log("Voice recordings: storage config missing") } for (const [key, note] of Object.entries(IGNORED_HANDOFF_ENV)) { if (readValue(key)) { warnings.push(`${key} is present but ${note}.`) } } heading("Build") if (args.build) { const buildResult = runShell("pnpm build", { inherit: true }) if (buildResult.status !== 0) { failures.push("pnpm build failed.") } else { console.log("pnpm build: ok") } } else { console.log("Build skipped. Re-run with --build for full preflight.") } heading("Summary") if (warnings.length > 0) { console.log("Warnings:") for (const warning of warnings) { console.log(`- ${warning}`) } } else { console.log("Warnings: none") } if (failures.length > 0) { console.log("Failures:") for (const failure of failures) { console.log(`- ${failure}`) } process.exit(1) } console.log("All staging preflight checks passed.") } main()