Rocky_Mountain_Vending/scripts/deploy-readiness.mjs

238 lines
6.8 KiB
JavaScript

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: "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 = [
{
label: "Manual asset delivery",
keys: ["NEXT_PUBLIC_MANUALS_BASE_URL", "NEXT_PUBLIC_THUMBNAILS_BASE_URL"],
note: "Falling back to the site's local /manuals and /thumbnails paths.",
},
]
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 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`)
}
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()