Files
boss/local-agent/boss-agent-ota-runner.mjs
2026-05-17 02:20:08 +08:00

294 lines
9.6 KiB
JavaScript

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
function nonEmpty(value) {
const text = String(value ?? "").trim();
return text || undefined;
}
function boolConfig(value, defaultValue) {
if (value === undefined || value === null || value === "") return defaultValue;
return value !== false && value !== "false" && value !== "0";
}
function positiveNumber(value, defaultValue) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : defaultValue;
}
function safeFileName(value, fallback = "boss-agent-mac-latest.zip") {
const base = path.basename(String(value ?? "").trim());
return base && base !== "." && base !== "/" ? base : fallback;
}
function resolveControlPlaneUrl(config) {
return String(config.controlPlaneUrl ?? "https://boss.hyzq.net").replace(/\/$/, "");
}
function resolveDownloadUrl(controlPlaneUrl, downloadUrl, deviceId) {
const url = new URL(downloadUrl || "/api/v1/boss-agent/ota/package", controlPlaneUrl);
if (deviceId && !url.searchParams.get("deviceId")) {
url.searchParams.set("deviceId", deviceId);
}
return url;
}
function deviceHeaders(config, runtime) {
const token = nonEmpty(runtime?.issuedToken) ?? nonEmpty(config.token);
return {
...(token ? { "x-boss-device-token": token } : {}),
...(nonEmpty(config.deviceId) ? { "x-boss-device-id": nonEmpty(config.deviceId) } : {}),
};
}
function compareVersions(left, right) {
const lhs = String(left ?? "").trim();
const rhs = String(right ?? "").trim();
if (!rhs) return false;
return lhs !== rhs;
}
function nowIso() {
return new Date().toISOString();
}
function writeRuntimeStatus(runtime, status) {
if (runtime) {
runtime.lastBossAgentOtaStatus = {
...status,
checkedAt: nowIso(),
};
}
return runtime?.lastBossAgentOtaStatus ?? status;
}
export function getBossAgentOtaRunnerConfig(env = process.env, config = {}) {
const enabled = boolConfig(config.bossAgentOtaEnabled ?? env.BOSS_AGENT_OTA_ENABLED, true);
const currentVersion =
nonEmpty(config.bossAgentVersion) ??
nonEmpty(env.BOSS_AGENT_VERSION) ??
"dev";
const installRoot = path.resolve(
nonEmpty(config.bossAgentInstallRoot) ??
nonEmpty(env.BOSS_AGENT_INSTALL_ROOT) ??
path.join(os.homedir(), "boss-agent", "current"),
);
const downloadDir = path.resolve(
nonEmpty(config.bossAgentOtaDownloadDir) ??
nonEmpty(env.BOSS_AGENT_OTA_DOWNLOAD_DIR) ??
path.join(os.homedir(), "boss-agent", "updates"),
);
const checkIntervalMs = positiveNumber(
config.bossAgentOtaCheckIntervalMs ?? env.BOSS_AGENT_OTA_CHECK_INTERVAL_MS,
300_000,
);
const autoInstall = boolConfig(config.bossAgentOtaAutoInstall ?? env.BOSS_AGENT_OTA_AUTO_INSTALL, false);
const launchInstallerCommand =
nonEmpty(config.bossAgentOtaLaunchInstallerCommand) ??
nonEmpty(env.BOSS_AGENT_OTA_LAUNCH_INSTALLER_COMMAND) ??
(process.platform === "darwin" ? "open" : "");
const launchInstallerArgs = Array.isArray(config.bossAgentOtaLaunchInstallerArgs)
? config.bossAgentOtaLaunchInstallerArgs.map(String)
: [];
return {
enabled,
currentVersion,
installRoot,
downloadDir,
checkIntervalMs,
autoInstall,
launchInstallerCommand,
launchInstallerArgs,
};
}
export async function checkBossAgentOtaUpdate(config = {}, runtime = {}) {
const runnerConfig = getBossAgentOtaRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
return writeRuntimeStatus(runtime, {
enabled: false,
currentVersion: runnerConfig.currentVersion,
hasUpdate: false,
latest: null,
message: "BOSS_AGENT_OTA_DISABLED",
});
}
const controlPlaneUrl = resolveControlPlaneUrl(config);
const url = new URL("/api/v1/boss-agent/ota", controlPlaneUrl);
url.searchParams.set("deviceId", nonEmpty(config.deviceId) ?? "");
url.searchParams.set("currentVersion", runnerConfig.currentVersion);
try {
const response = await fetch(url, {
method: "GET",
headers: deviceHeaders(config, runtime),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload?.ok) {
throw new Error(payload?.message ?? `BOSS_AGENT_OTA_CHECK_FAILED:${response.status}`);
}
const latest = payload.latest ?? null;
const hasUpdate = Boolean(
latest &&
compareVersions(runnerConfig.currentVersion, latest.version) &&
payload.hasUpdate !== false,
);
return writeRuntimeStatus(runtime, {
enabled: true,
currentVersion: runnerConfig.currentVersion,
hasUpdate,
latest,
message: hasUpdate ? "BOSS_AGENT_OTA_AVAILABLE" : "BOSS_AGENT_OTA_UP_TO_DATE",
});
} catch (error) {
return writeRuntimeStatus(runtime, {
enabled: true,
currentVersion: runnerConfig.currentVersion,
hasUpdate: false,
latest: null,
error: error instanceof Error ? error.message : String(error),
message: "BOSS_AGENT_OTA_CHECK_ERROR",
});
}
}
async function downloadArchive(config, runtime, latest, runnerConfig) {
const controlPlaneUrl = resolveControlPlaneUrl(config);
const url = resolveDownloadUrl(controlPlaneUrl, latest.downloadUrl, nonEmpty(config.deviceId));
const response = await fetch(url, {
method: "GET",
headers: deviceHeaders(config, runtime),
});
if (!response.ok) {
throw new Error(`BOSS_AGENT_OTA_DOWNLOAD_FAILED:${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const actualSha256 = createHash("sha256").update(buffer).digest("hex");
const expectedSha256 = nonEmpty(latest.sha256);
if (expectedSha256 && actualSha256.toLowerCase() !== expectedSha256.toLowerCase()) {
throw new Error("BOSS_AGENT_OTA_CHECKSUM_MISMATCH");
}
const version = nonEmpty(latest.version) ?? "latest";
const stageDir = path.join(runnerConfig.downloadDir, version);
await rm(stageDir, { recursive: true, force: true });
await mkdir(stageDir, { recursive: true });
const archivePath = path.join(stageDir, safeFileName(latest.fileName));
await writeFile(archivePath, buffer);
return {
version,
stageDir,
archivePath,
sha256: actualSha256,
sizeBytes: buffer.length,
};
}
async function writeInstallerWrapper(downloaded, runnerConfig) {
const installCommandPath = path.join(downloaded.stageDir, "install.command");
const extractDir = path.join(downloaded.stageDir, "extracted");
const script = `#!/bin/zsh
set -euo pipefail
ARCHIVE=${JSON.stringify(downloaded.archivePath)}
EXTRACT_DIR=${JSON.stringify(extractDir)}
INSTALL_ROOT=${JSON.stringify(runnerConfig.installRoot)}
rm -rf "$EXTRACT_DIR"
mkdir -p "$EXTRACT_DIR"
ditto -x -k "$ARCHIVE" "$EXTRACT_DIR"
PACKAGE_DIR="$(find "$EXTRACT_DIR" -maxdepth 1 -type d -name 'boss-agent-mac-runtime-*' | head -n 1)"
if [[ -z "$PACKAGE_DIR" || ! -x "$PACKAGE_DIR/install.command" ]]; then
echo "boss-agent OTA package is invalid: install.command not found" >&2
exit 1
fi
BOSS_AGENT_INSTALL_ROOT="$INSTALL_ROOT" "$PACKAGE_DIR/install.command"
`;
await writeFile(installCommandPath, script, "utf8");
await chmod(installCommandPath, 0o755);
return installCommandPath;
}
function launchInstaller(command, args, installCommandPath) {
return new Promise((resolve) => {
if (!command) {
resolve({ ok: false, error: "BOSS_AGENT_OTA_LAUNCH_COMMAND_MISSING" });
return;
}
const child = spawn(command, [...args, installCommandPath], {
detached: true,
stdio: "ignore",
});
child.on("error", (error) => {
resolve({ ok: false, error: error.message });
});
child.on("spawn", () => {
child.unref();
resolve({ ok: true });
});
});
}
export async function applyBossAgentOtaUpdate(config = {}, runtime = {}, options = {}) {
const runnerConfig = getBossAgentOtaRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
const result = {
status: "failed",
error: "BOSS_AGENT_OTA_DISABLED",
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
}
try {
const status = await checkBossAgentOtaUpdate(config, runtime);
if (!status.hasUpdate || !status.latest) {
const result = {
status: "skipped",
reason: "BOSS_AGENT_OTA_UP_TO_DATE",
currentVersion: runnerConfig.currentVersion,
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
}
const downloaded = await downloadArchive(config, runtime, status.latest, runnerConfig);
const installCommandPath = await writeInstallerWrapper(downloaded, runnerConfig);
const shouldLaunch = options.launchInstaller ?? runnerConfig.autoInstall;
const launch = shouldLaunch
? await launchInstaller(
runnerConfig.launchInstallerCommand,
runnerConfig.launchInstallerArgs,
installCommandPath,
)
: { ok: false, skipped: true };
const result = {
status: shouldLaunch && launch.ok ? "installer_launched" : "staged",
version: downloaded.version,
archivePath: downloaded.archivePath,
stageDir: downloaded.stageDir,
installCommandPath,
sha256: downloaded.sha256,
sizeBytes: downloaded.sizeBytes,
launch,
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
} catch (error) {
const result = {
status: "failed",
error: error instanceof Error ? error.message : String(error),
completedAt: nowIso(),
};
if (runtime) runtime.lastBossAgentOtaApply = result;
return result;
}
}