404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
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<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 ||
|
|
!parseVersion(version) ||
|
|
version === currentVersion
|
|
)
|
|
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();
|
|
|
|
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<string | undefined> {
|
|
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<string | undefined> {
|
|
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<CommandExit> {
|
|
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<CommandExit | undefined> {
|
|
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<typeof setTimeout> | 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<never>((_, 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<CommandExit>;
|
|
try {
|
|
replay = (dependencies.rerun ?? rerunCommand)(executable, args, () =>
|
|
report(true),
|
|
);
|
|
const result = await replay;
|
|
return result;
|
|
} catch {
|
|
report(false);
|
|
return;
|
|
}
|
|
}
|