230 lines
7.0 KiB
TypeScript
230 lines
7.0 KiB
TypeScript
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<boolean>;
|
|
|
|
type InstallProcess = {
|
|
exited: Promise<number>;
|
|
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<boolean> {
|
|
// 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();
|