import { KUBER_VERSION } from "../shared/version"; import { spawn as spawnProcess } from "node:child_process"; import { join } from "node:path"; import { KUBER_API_BASE_URL } from "../const"; import { extractConfigArgument } from "./config"; type SemVer = { major: bigint; minor: bigint; patch: bigint; prerelease: string[]; }; const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const NUMERIC = /^(0|[1-9]\d*)$/; const INSTALL_TIMEOUT_SECONDS = 30; const gray = (line: string) => `\x1b[90m${line}\x1b[0m\n`; const reportToStderr = (line: string) => process.stderr.write(line); function parseVersion(value: string | null): SemVer | undefined { if (!value || value.length > 128) return; const match = SEMVER.exec(value); if (!match || match[0] !== value) return; const prerelease = match[4]?.split(".") ?? []; if (prerelease.some((part) => /^\d+$/.test(part) && !NUMERIC.test(part))) return; return { major: BigInt(match[1]!), minor: BigInt(match[2]!), patch: BigInt(match[3]!), prerelease, }; } /** A positive result means candidate is newer; build metadata has no precedence. */ export function compareVersions( candidate: string, current: string, ): number | undefined { const a = parseVersion(candidate); const b = parseVersion(current); if (!a || !b) return; for (const field of ["major", "minor", "patch"] as const) { if (a[field] !== b[field]) return a[field] > b[field] ? 1 : -1; } if (!a.prerelease.length || !b.prerelease.length) { return Number(!a.prerelease.length) - Number(!b.prerelease.length); } for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i++) { const left = a.prerelease[i]; const right = b.prerelease[i]; if (left === undefined || right === undefined) return left === undefined ? -1 : 1; if (left === right) continue; const leftNumeric = NUMERIC.test(left); const rightNumeric = NUMERIC.test(right); if (leftNumeric && rightNumeric) return BigInt(left) > BigInt(right) ? 1 : -1; if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; return left < right ? -1 : 1; } return 0; } export type VersionInstallRunner = (version: string) => Promise; type InstallProcess = { exited: Promise; kill(signal?: NodeJS.Signals): void; }; type InstallSpawn = ( argv: string[], options: { stdin: "ignore"; stdout: "ignore"; stderr: "ignore"; env: NodeJS.ProcessEnv; }, ) => InstallProcess; export async function installVersion( version: string, spawn: InstallSpawn = Bun.spawn, timeoutSeconds = INSTALL_TIMEOUT_SECONDS, ): Promise { // Defense in depth: never pass an unvalidated header to a subprocess. if ( !parseVersion(version) || !Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 300 ) return false; try { const child = spawn( [process.execPath, "i", "-g", "--no-cache", `@dmgnr/kuber@${version}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", env: process.env }, ); return await new Promise((resolve) => { // Allow a short grace period to reap a child that ignores termination. let timedOut = false; const deadline = setTimeout( () => { timedOut = true; try { child.kill(); } catch {} const force = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} resolve(false); }, 1_000); void child.exited.finally(() => clearTimeout(force)).catch(() => {}); }, timeoutSeconds * 1_000 - 1_000, ); void child.exited.then( (code) => { clearTimeout(deadline); resolve(!timedOut && code === 0); }, () => { clearTimeout(deadline); resolve(false); }, ); }); } catch { return false; } } const pendingUpdates = new Set>(); /** Wait for an update observed during this CLI invocation, including its notice. */ export async function waitForVersionUpdate(): Promise { await Promise.all(pendingUpdates); } export function createVersionObserver({ currentVersion = KUBER_VERSION, runner = installVersion, report = reportToStderr, }: { currentVersion?: string; runner?: VersionInstallRunner; report?: (line: string) => void; } = {}): (version: string | null) => void { let attempted = false; return (version) => { if ( attempted || !version || !parseVersion(version) || version === currentVersion ) return; attempted = true; // Defer install work beyond the response headers; never block body consumption. const pending = new Promise((resolve) => setTimeout(resolve, 0)).then( async () => { // The global observer must not install packages in tests or source-tree // development commands. Explicitly injected runners remain testable. if ( runner === installVersion && (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development" || process.argv[1]?.endsWith(".ts")) ) return; let success = false; try { success = await runner(version); } catch {} try { report( gray( `+ ${success ? "Updated to " : "New version available: "}${version}`, ), ); } catch { // A broken stderr must never affect an API request or command exit status. } }, ); pendingUpdates.add(pending); void pending.finally(() => pendingUpdates.delete(pending)); }; } export const observeServerVersion = createVersionObserver(); export const VERSION_REEXEC_MARKER = "KUBER_VERSION_REEXEC"; export type CommandExit = { code: number | null; signal: NodeJS.Signals | null; }; /** Only startup may install: normal responses must never replay a running command. */ export function needsVersionPreflight(args: string[]): boolean { if (args.some((arg) => ["--help", "-h", "--version", "-v"].includes(arg))) return false; const { rawArgs } = extractConfigArgument(args); const [command, subcommand] = rawArgs; if ( !command || command.startsWith("-") || [ "complete", "completion", "export", "add", "ui", "logout", "whoami", ].includes(command) ) return false; if (["db", "s3"].includes(command) && subcommand === "ls") return false; return true; } /** Capture only small administrative outputs, with a deadline and byte bound. */ async function capture(argv: string[]): Promise { return new Promise((resolve) => { let output = ""; let settled = false; const finish = (value?: string) => { if (settled) return; settled = true; clearTimeout(timer); resolve(value); }; const child = spawnProcess(argv[0]!, argv.slice(1), { stdio: ["ignore", "pipe", "ignore"], env: process.env, }); const timer = setTimeout(() => { child.kill("SIGKILL"); finish(); }, 5_000); child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); if (output.length > 4096) { child.kill("SIGKILL"); finish(); } }); child.on("error", () => finish()); child.on("close", (code) => finish(code === 0 ? output.trim() : undefined)); }); } export async function resolveInstalledVersion( version: string, run = capture, ): Promise { const bin = await run([process.execPath, "pm", "bin", "-g"]); if (!bin) return; const executable = join(bin, "kuber"); if ((await run([executable, "--version"])) !== version) return; return executable; } export async function rerunCommand( executable: string, args: string[], onStarted: () => void = () => {}, ): Promise { return new Promise((resolve, reject) => { const child = spawnProcess(executable, args, { cwd: process.cwd(), stdio: "inherit", env: { ...process.env, [VERSION_REEXEC_MARKER]: "1" }, }); child.once("spawn", onStarted); const signals: NodeJS.Signals[] = [ "SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", ]; const handlers = signals.map((signal) => { const handler = () => { child.kill(signal); }; process.on(signal, handler); return handler; }); const cleanup = () => signals.forEach((signal, index) => { process.off(signal, handlers[index]!); }); child.on("error", (error) => { cleanup(); reject(error); }); child.on("close", (code, signal) => { cleanup(); resolve({ code, signal }); }); }); } export function propagateCommandExit(result: CommandExit): void { if (result.signal) { process.kill(process.pid, result.signal); return; } process.exitCode = result.code ?? 1; } type PreflightDependencies = { currentVersion?: string; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; runner?: VersionInstallRunner; resolveExecutable?: typeof resolveInstalledVersion; rerun?: typeof rerunCommand; report?: (line: string) => void; timeoutMs?: number; }; /** Run before config imports, session reads, lazy command resolution, or hooks. */ export async function preflightVersion( args: string[], dependencies: PreflightDependencies = {}, ): Promise { const env = dependencies.env ?? process.env; if (env[VERSION_REEXEC_MARKER] || !needsVersionPreflight(args)) return; // Source/test invocations must never change the user's global installation. if ( !dependencies.runner && (env.NODE_ENV === "test" || env.NODE_ENV === "development" || process.argv[1]?.endsWith(".ts")) ) return; const controller = new AbortController(); let deadline: ReturnType | undefined; let version: string | null; try { const response = await Promise.race([ (dependencies.fetch ?? fetch)(`${KUBER_API_BASE_URL}/health`, { method: "GET", signal: controller.signal, redirect: "error", }), new Promise((_, reject) => { deadline = setTimeout(() => { controller.abort(); reject(new Error("Version preflight timed out")); }, dependencies.timeoutMs ?? 3_000); }), ]); version = response.headers.get("X-Kuber-Version"); void response.body?.cancel().catch(() => {}); } catch { return; } finally { clearTimeout(deadline); } if ( !version || !parseVersion(version) || version === (dependencies.currentVersion ?? KUBER_VERSION) ) return; const report = (success: boolean) => { try { (dependencies.report ?? reportToStderr)( gray( `+ ${success ? "Updated to " : "New version available: "}${version}`, ), ); } catch {} }; let executable: string | undefined; try { if (await (dependencies.runner ?? installVersion)(version)) executable = await ( dependencies.resolveExecutable ?? resolveInstalledVersion )(version); } catch {} if (!executable) { report(false); return; } // A spawn failure happens before any child handler can run; safely continue here. let replay: Promise; try { replay = (dependencies.rerun ?? rerunCommand)(executable, args, () => report(true), ); const result = await replay; return result; } catch { report(false); return; } }