238 lines
6.8 KiB
JavaScript
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()
|