import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "../..");
async function defaultRun(command, args = [], options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? ROOT,
env: options.env ?? process.env,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve({ stdout, stderr, code });
else {
const error = new Error(stderr.trim() || `${command} exited ${code}`);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
}
});
});
}
function parseNodeVersion(text) {
const match = /v?(\d+)\.(\d+)\.(\d+)/.exec(text ?? "");
if (!match) return null;
return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), raw: match[0] };
}
async function safeRun(run, command, args = []) {
try {
return await run(command, args);
} catch (error) {
return { error, stdout: error?.stdout ?? "", stderr: error?.stderr ?? "" };
}
}
export async function inspectDependencies(options = {}) {
const run = options.run ?? defaultRun;
const entries = [];
const node = await safeRun(run, "node", ["--version"]);
const nodeVersion = parseNodeVersion(node.stdout || node.stderr);
entries.push({
name: "node",
required: true,
foundVersion: nodeVersion?.raw ?? null,
minimumVersion: "22",
status: nodeVersion && nodeVersion.major >= 22 ? "ok" : "missing",
installAction: "node"
});
const npm = await safeRun(run, "npm", ["--version"]);
const npmVersion = (npm.stdout || "").trim() || null;
entries.push({
name: "npm",
required: true,
foundVersion: npmVersion,
minimumVersion: "any",
status: npmVersion ? "ok" : "missing",
installAction: "node"
});
return entries;
}
async function canInstallNonInteractive(options, run) {
if ((options.uid ?? process.getuid?.() ?? 1000) === 0) return true;
const result = await safeRun(run, "sudo", ["-n", "true"]);
return !result.error;
}
export async function ensureDependencies(options = {}) {
const run = options.run ?? defaultRun;
const ask = options.ask;
const install = options.install ?? (async (action, installOptions = {}) => {
const args = [path.join(ROOT, "scripts/deploy/install-dependency.sh"), action];
if (installOptions.nonInteractive) {
args.push("--non-interactive");
}
await run("bash", args);
});
const print = options.print ?? ((message) => process.stdout.write(`${message}\n`));
let entries = await inspectDependencies(options);
const missing = entries.filter((entry) => entry.status !== "ok");
if (missing.length === 0) return entries;
for (const entry of missing) {
const action = entry.installAction;
const commandHint = `bash scripts/deploy/install-dependency.sh ${action}`;
if (options.nonInteractive) {
const allowed = await canInstallNonInteractive(options, run);
if (!allowed) {
throw new Error(
`Missing ${entry.name}. Non-interactive install requires root or passwordless sudo. Or run: ${commandHint}`
);
}
print(`Installing ${entry.name} via ${commandHint}`);
await install(action, { nonInteractive: true });
continue;
}
print(`缺少依赖:${entry.name}(需要 ${entry.minimumVersion},当前 ${entry.foundVersion ?? "未找到"})`);
print(`将执行:${commandHint}`);
const answer = String(await ask("是否安装?[y/N]:" )).trim().toLowerCase();
if (answer !== "y" && answer !== "yes") {
throw new Error(`Dependency ${entry.name} is required. Install with: ${commandHint}`);
}
await install(action, { nonInteractive: false });
}
entries = await inspectDependencies(options);
const stillMissing = entries.filter((entry) => entry.status !== "ok");
if (stillMissing.length > 0) {
throw new Error(
`Dependencies still missing after install: ${stillMissing.map((e) => e.name).join(", ")}`
);
}
return entries;
}