feat: release 2.7.0
This commit is contained in:
+220
-1
@@ -1,4 +1,8 @@
|
||||
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;
|
||||
@@ -146,7 +150,12 @@ export function createVersionObserver({
|
||||
} = {}): (version: string | null) => void {
|
||||
let attempted = false;
|
||||
return (version) => {
|
||||
if (attempted || !version || compareVersions(version, currentVersion) !== 1)
|
||||
if (
|
||||
attempted ||
|
||||
!version ||
|
||||
!parseVersion(version) ||
|
||||
version === currentVersion
|
||||
)
|
||||
return;
|
||||
attempted = true;
|
||||
// Defer install work beyond the response headers; never block body consumption.
|
||||
@@ -182,3 +191,213 @@ export function createVersionObserver({
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user