Files
kuber/lib/version-update.ts
T
2026-10-06 15:31:51 +00:00

185 lines
5.6 KiB
TypeScript

import { KUBER_VERSION } from "../shared/version";
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<boolean>;
type InstallProcess = {
exited: Promise<number>;
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<boolean> {
// 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<boolean>((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<Promise<void>>();
/** Wait for an update observed during this CLI invocation, including its notice. */
export async function waitForVersionUpdate(): Promise<void> {
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 || compareVersions(version, currentVersion) !== 1)
return;
attempted = true;
// Defer install work beyond the response headers; never block body consumption.
const pending = new Promise<void>((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();