import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { unzipSync } from "fflate"; import { createHash } from "node:crypto"; const defaultRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); export const deploymentProject = { name: "minegeo", project: "prj_St6KMC5H8PakQxe3UCQxJa5b8gWy", teamId: "team_03U5cmiAfXJvpuxDDh33CqOT", productionUrl: "https://minegeo.vercel.app", }; export function deploymentFiles(root = defaultRoot) { const files = []; const rootReal = fs.realpathSync(root); let privateTerms = []; try { const remote = execFileSync( "git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], }, ).trim(); const hostname = remote.includes("://") ? new URL(remote).hostname : remote.split("@").pop().split(":")[0]; privateTerms = [remote, hostname] .filter(Boolean) .map((value) => value.toLowerCase()); } catch { /* A source snapshot can be deployed without Git. */ } function add(full, relative) { const real = fs.realpathSync(full); if (real !== rootReal && !real.startsWith(rootReal + path.sep)) throw new Error(`Deployment file escapes the project: ${relative}`); let data = fs.readFileSync(full); if (relative === "next.config.ts") data = Buffer.from( data.toString().replace(/^\s*allowedDevOrigins:.*\r?\n/gm, ""), ); if (relative === "package.json") { const pkg = JSON.parse(data); pkg.scripts = { prebuild: "node scripts/assemble-desktop-release.mjs && node scripts/clean-build.mjs", build: "next build", start: "next start", }; delete pkg.repository; delete pkg.bugs; delete pkg.homepage; data = Buffer.from(JSON.stringify(pkg, null, 2)); } if (relative === "tsconfig.json") { const config = JSON.parse(data); config.include = [ "next-env.d.ts", "next.config.ts", "proxy.ts", "app/**/*.ts", "app/**/*.tsx", "components/**/*.tsx", "lib/**/*.ts", "shared/**/*.ts", ".next/types/**/*.ts", ]; data = Buffer.from(JSON.stringify(config, null, 2)); } const contents = relative.endsWith(".jar") ? Object.values(unzipSync(data)) : [data]; if ( contents.some((content) => privateTerms.some((term) => Buffer.from(content).toString("utf8").toLowerCase().includes(term), ), ) ) throw new Error( `Private Git server reference found in ${relative}. Remove it before deploying.`, ); if (relative.startsWith("public/desktop/") && relative.endsWith(".exe") && data.length > 10 * 1024 * 1024) { const chunkSize = 6 * 1024 * 1024; const parts = Math.ceil(data.length / chunkSize); for (let index = 0; index < parts; index++) { files.push({ file: `.desktop-release/part-${index}`, data: data.subarray(index * chunkSize, (index + 1) * chunkSize).toString("base64"), encoding: "base64" }); } files.push({ file: ".desktop-release/manifest.json", data: Buffer.from(JSON.stringify({ name: path.basename(relative), parts, bytes: data.length, sha256: createHash("sha256").update(data).digest("hex") })).toString("base64"), encoding: "base64" }); return; } files.push({ file: relative, data: data.toString("base64"), encoding: "base64", }); } function walk(relative) { const dir = path.join(root, relative); if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if ( entry.name.startsWith(".") || entry.name === "node_modules" || (relative === "public" && ["generated", "downloads", "artifacts", "build"].includes(entry.name)) ) continue; const file = path.posix.join(relative, entry.name); if (entry.isDirectory()) walk(file); else add(path.join(root, file), file); } } for (const directory of ["app", "components", "lib", "shared", "public"]) walk(directory); for (const file of [ "package.json", "package-lock.json", "tsconfig.json", "next.config.ts", "next-env.d.ts", "proxy.ts", "postcss.config.mjs", "vercel.json", "scripts/clean-build.mjs", "scripts/assemble-desktop-release.mjs", ]) { if (fs.existsSync(path.join(root, file))) add(path.join(root, file), file); } if ( !files.some((file) => file.file === "package.json") || !files.some((file) => file.file === "app/layout.tsx") ) throw new Error("Run this command from a complete MineGeo project."); return files; } export function deploymentPayload(files, preview = false) { return { name: deploymentProject.name, project: process.env.VERCEL_PROJECT_ID || deploymentProject.project, ...(preview ? {} : { target: "production" }), files, projectSettings: { framework: "nextjs", nodeVersion: "22.x", buildCommand: "npm run build", skipGitConnectDuringLink: true, }, }; } function accessToken() { if (process.env.VERCEL_TOKEN) return process.env.VERCEL_TOKEN; const authPaths = process.platform === "win32" ? [ path.join( process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"), "com.vercel.cli/Data/auth.json", ), ] : [ path.join( process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local/share"), "com.vercel.cli/auth.json", ), path.join( os.homedir(), "Library/Application Support/com.vercel.cli/auth.json", ), ]; for (const file of authPaths) { try { const token = JSON.parse(fs.readFileSync(file, "utf8")).token; if (typeof token === "string") return token; } catch { /* No CLI login saved here. */ } } throw new Error( "Vercel authentication is needed once: run vercel login, or put VERCEL_TOKEN in .env.deploy.local. Then run npm run deploy again. The Codex Vercel plugin login is separate from CLI/script authentication.", ); } /** * @param {ReturnType} payload * @param {{ token: string, request?: typeof fetch, wait?: (milliseconds: number) => Promise, log?: (message: string) => void }} options */ export async function deploy( payload, { token, request = fetch, wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), log = console.log, }, ) { if (!token) throw new Error("A Vercel access token is required."); const team = process.env.VERCEL_ORG_ID || deploymentProject.teamId; async function api(endpoint, body) { const response = await request( `https://api.vercel.com${endpoint}${endpoint.includes("?") ? "&" : "?"}teamId=${encodeURIComponent(team)}`, { method: body ? "POST" : "GET", headers: { Authorization: `Bearer ${token}`, ...(body ? { "Content-Type": "application/json" } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(120000), }, ); const data = await response.json(); if (!response.ok) throw new Error( `Vercel ${response.status}: ${data.error?.message || "Deployment request failed"}`, ); return data; } const files = []; for (const file of payload.files) { const data = Buffer.from(file.data || "", "base64"); const isDesktopReleasePart = file.file.startsWith(".desktop-release/part-"); if ( file.encoding !== "base64" || (data.length <= 10 * 1024 * 1024 && !isDesktopReleasePart) ) { files.push(file); continue; } const sha = createHash("sha1").update(data).digest("hex"); const response = await request( `https://api.vercel.com/v2/files?teamId=${encodeURIComponent(team)}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/octet-stream", "Content-Length": String(data.length), "x-vercel-digest": sha, }, body: data, signal: AbortSignal.timeout(300000), }, ); if (!response.ok) throw new Error( `Vercel file upload failed (${response.status}): ${file.file}`, ); files.push({ file: file.file, sha, size: data.length }); log(`Uploaded release: ${file.file}`); } let deployment = await api("/v13/deployments", { ...payload, files }); if (!deployment.id) throw new Error("Vercel did not return a deployment ID."); log( `Uploaded ${payload.files.length} site files in one request. Waiting for Vercel…`, ); let previousState; for (let attempts = 0; attempts < 180; attempts++) { const state = deployment.readyState || deployment.status || deployment.state; if (state !== previousState) { log(`Build: ${state}`); previousState = state; } if (state === "READY") { const url = payload.target === "production" && payload.project === deploymentProject.project ? deploymentProject.productionUrl : `https://${deployment.url}`; log(`Deployed: ${url}`); return deployment; } if (["ERROR", "CANCELED"].includes(state)) throw new Error( `Deployment ${state.toLowerCase()}: ${deployment.errorMessage || deployment.error?.message || "See the build log in Vercel."}`, ); await wait(5000); deployment = await api( `/v13/deployments/${encodeURIComponent(deployment.id)}`, ); } throw new Error( `Build is still running. Check deployment ${deployment.id} in Vercel.`, ); } async function main() { const args = process.argv.slice(2); if (args.includes("--help")) { console.log( "npm run deploy [-- --preview | --dry-run]\nOne upload of all site files, with no Git metadata or local secrets.\nAuthenticate once using vercel login or VERCEL_TOKEN in .env.deploy.local.", ); return; } const envPath = path.join(defaultRoot, ".env.deploy.local"); if (fs.existsSync(envPath)) process.loadEnvFile(envPath); const files = deploymentFiles(); const manifestIndex = args.indexOf("--manifest"); if (manifestIndex !== -1) { if (!args[manifestIndex + 1]) throw new Error("--manifest requires a file path"); fs.writeFileSync( path.resolve(args[manifestIndex + 1]), JSON.stringify(files), ); } if (args.includes("--dry-run")) { console.log( JSON.stringify({ files: files.length, bytes: files.reduce( (sum, file) => sum + Buffer.from(file.data, "base64").length, 0, ), privacyScan: "passed", gitMetadata: false, localSecrets: false, target: args.includes("--preview") ? "preview" : "production", }), ); return; } await deploy(deploymentPayload(files, args.includes("--preview")), { token: accessToken(), }); } if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) main().catch((error) => { console.error(error.message); process.exitCode = 1; });