294 lines
9.6 KiB
JavaScript
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;
|
|
}
|
|
}
|