353 lines
9.1 KiB
JavaScript
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()
|