import { KUBER_VERSION } from "../shared/version"; import { readlinkSync, statSync } from "node:fs"; 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); // This process owns the install result when the invoking CLI has already exited. // It opens only the original terminal (never the parent's pipe or stdout), and // checks its identity before writing in case the pty path has been recycled. const TTY_HELPER = ` const { openSync, closeSync, fstatSync, writeSync, constants } = require('node:fs'); const [version, seconds, path, dev, ino, rdev, uid] = process.argv.slice(1); let success = false; try { const child = Bun.spawn(['timeout', '--signal=TERM', '--kill-after=2s', seconds + 's', 'bun', 'i', '-g', '--no-cache', '@dmgnr/kuber@' + version], { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }); success = (await child.exited) === 0; } catch {} try { const fd = openSync(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const stat = fstatSync(fd); if (stat.isCharacterDevice() && String(stat.dev) === dev && String(stat.ino) === ino && String(stat.rdev) === rdev && String(stat.uid) === uid) { writeSync(fd, '\\x1b[90m+ ' + (success ? 'Updated to ' : 'New version available: ') + version + '\\x1b[0m\\n'); } } finally { closeSync(fd); } } catch {} `; function originalTerminal(): string[] | undefined { if (!process.stderr.isTTY) return; try { const path = readlinkSync("/proc/self/fd/2"); if (!/^\/dev\/pts\/[0-9]+$/.test(path)) return; const stat = statSync(path); if (!stat.isCharacterDevice()) return; return [ path, String(stat.dev), String(stat.ino), String(stat.rdev), String(stat.uid), ]; } catch { return; } } function installWithTerminalReport(version: string, tty: string[]): void { const child = Bun.spawn( [ process.execPath, "-e", TTY_HELPER, version, String(INSTALL_TIMEOUT_SECONDS), ...tty, ], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }, ); child.unref(); } 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; unref?(): void; }; type InstallSpawn = ( argv: string[], options: { stdin: "ignore"; stdout: "ignore"; stderr: "ignore"; detached: true; }, ) => 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; const child = spawn( [ "timeout", "--signal=TERM", "--kill-after=2s", `${timeoutSeconds}s`, "bun", "i", "-g", "--no-cache", `@dmgnr/kuber@${version}`, ], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }, ); // The detached timeout owns its bounded lifetime; it must not keep a short // CLI invocation alive. Its result can still be observed while the parent lives. child.unref?.(); return child.exited.then((code) => code === 0); } 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 || compareVersions(version, currentVersion) !== 1) return; attempted = true; // Defer install work beyond the response headers; never block body consumption. setTimeout(() => { // 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; if (runner === installVersion && report === reportToStderr) { const tty = originalTerminal(); if (tty) { try { installWithTerminalReport(version, tty); } catch { try { report(gray(`+ New version available: ${version}`)); } catch {} } return; } } void Promise.resolve() .then(() => runner(version)) .then( (success) => { report( gray( `+ ${success ? "Updated to " : "New version available: "}${version}`, ), ); }, () => report(gray(`+ New version available: ${version}`)), ) .catch(() => { // A broken stderr must never affect an API request or command exit status. }); }, 0); }; } export const observeServerVersion = createVersionObserver();