Rocky_Mountain_Vending/scripts/deploy-readiness.mjs

353 lines
9.1 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: "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 canonicalizeDomain(input) {
return String(input || "")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^\/\//, "")
.replace(/\/.*$/, "")
.replace(/:\d+$/, "")
.replace(/\.$/, "")
}
function isValidHttpUrl(value) {
if (!value) {
return false
}
try {
const url = new URL(value)
return url.protocol === "https:" || url.protocol === "http:"
} catch {
return false
}
}
function isValidHostname(value) {
const host = canonicalizeDomain(value)
return /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(host)
}
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`)
}
const convexUrl = readValue("NEXT_PUBLIC_CONVEX_URL")
if (!isValidHttpUrl(convexUrl)) {
failures.push(
"NEXT_PUBLIC_CONVEX_URL is malformed. It must be a full http(s) URL."
)
}
const siteDomain =
readValue("MANUALS_TENANT_DOMAIN") || readValue("NEXT_PUBLIC_SITE_DOMAIN")
if (!isValidHostname(siteDomain)) {
failures.push(
"NEXT_PUBLIC_SITE_DOMAIN (or MANUALS_TENANT_DOMAIN) is malformed. It must be a valid hostname."
)
}
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")
const copyCheckResult = runShell("pnpm copy:check", { inherit: true })
if (copyCheckResult.status !== 0) {
failures.push("pnpm copy:check failed.")
} else {
console.log("pnpm copy:check: ok")
}
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()