351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
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<typeof deploymentPayload>} payload
|
|
* @param {{ token: string, request?: typeof fetch, wait?: (milliseconds: number) => Promise<void>, 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;
|
|
});
|