diff --git a/changelogs/2.7.0.md b/changelogs/2.7.0.md index 1cb4201..19b85b2 100644 --- a/changelogs/2.7.0.md +++ b/changelogs/2.7.0.md @@ -1,17 +1,24 @@ +# 2.7.0 + +- Before side effects, check the exact server version and install/restart once when it differs; this includes older and newer server versions. A silent preflight health or network failure leaves the current command running without a notice. Install, resolve, or spawn failures show a gray availability notice, then continue the current command. +- Trust revocation and status checks do not accidentally re-grant trust. +- Includes the 2.7.0 release-candidate features and fixes listed below. +- Auto builds always use CNB Buildpacks with no BuildKit fallback; ordinary Dockerfile builds retain the BuildKit path. Live CNB Kubernetes Job execution and registry push remain unverified. + # 2.7.0-rc3 - Use source-first Enquirer Select prompts with conditional image, CNB, or template choices, plus app-path AutoComplete. - Render `kuber init` and `kuber add` previews as YAML Snippets, preserving literal existing `name: ...` lines; omit blank optional values and default replicas to 1. - Use real BasicAuth login, handle ambiguous trust identities safely, and fix type resolution. -# 2.7.0-rc2 +# 2.7.0-rc2 (historical) -- Simplify the cross-platform passive updater with explicit inherited environment and `process.execPath`, a bounded wait for the install outcome before CLI exit, and no TTY workaround or external timeout helper. +- Earlier release-candidate passive updater implementation; superseded by the stable pre-command exact server-version check above. - Show aggregate context-upload progress in an `Uploading context` child task, deduplicating shared blobs and accounting for resumed uploads. - Support `build: auto:` with optional `#sha256=` pinning. The real `kilterset/bun` 1.0.0 package was fetched and validated for Linux ARM64; its architecture mismatch was correctly rejected for AMD64. - Live CNB Kubernetes Job execution and registry push remain unverified. -# 2.7.0-rc1 +# 2.7.0-rc1 (historical) ## Added @@ -25,7 +32,7 @@ - Begin generated Compose files with `managedBy: kuber # See https://npmx.dev/@dmgnr/kuber for documentation.` and accept the marker in kuber's Compose schema. - Show aggregate context-upload bytes and percentage as a separate task during `kuber up`, deduplicating shared blobs across workspaces and accounting for resumed uploads. -- Simplify the passive updater to launch the current Bun executable with the inherited runtime environment, without Linux terminal helpers or an external `timeout` command. Wait for the bounded install outcome and its success or available-version notice before CLI exit; suppress automatic installs in tests and source-tree development. +- Historical passive updater implementation, superseded by the stable pre-command exact server-version check above. ## Fixed diff --git a/command/main.ts b/command/main.ts index a8c174c..d135847 100644 --- a/command/main.ts +++ b/command/main.ts @@ -37,7 +37,7 @@ function cloneCommand(command: T): T { export const main = defineCommand({ meta: { name: "kuber", - version: "2.7.0-rc3", + version: "2.7.0", description: "Docker Compose -> K8s translation layer", }, args: { diff --git a/command/trust.ts b/command/trust.ts index ad7c053..dab3bb3 100644 --- a/command/trust.ts +++ b/command/trust.ts @@ -105,7 +105,10 @@ export const trust = defineCommand({ }, }), }, - async run() { + async run({ args }) { + // citty runs a command's handler after its selected subcommand handler. + // Only a bare `trust` invocation has no positional arguments here. + if (args._.length > 0) return; await grantTrust(await current()); }, }); diff --git a/index.ts b/index.ts index 08e0ae8..2867c0d 100644 --- a/index.ts +++ b/index.ts @@ -5,10 +5,16 @@ import { initializeCompletion, main } from "./command/main"; import { provideContext } from "./lib/context"; import { extractConfigArgument } from "./lib/config"; import { formatUnknownError, wrapCommandErrors } from "./lib/error"; -import { waitForVersionUpdate } from "./lib/version-update"; +import { preflightVersion, propagateCommandExit } from "./lib/version-update"; async function run() { - const { configPath, rawArgs } = extractConfigArgument(process.argv.slice(2)); + const originalArgs = process.argv.slice(2); + const replay = await preflightVersion(originalArgs); + if (replay) { + propagateCommandExit(replay); + return; + } + const { configPath, rawArgs } = extractConfigArgument(originalArgs); // citty resolves every preceding lazy command while looking up an alias. if (rawArgs[0] === "fuck") rawArgs[0] = "rollback"; if ( @@ -36,9 +42,7 @@ async function run() { await provideContext(() => cli({ rawArgs }), configPath); } -run() - .catch((e) => { - console.error(formatUnknownError(e)); - process.exitCode = 1; - }) - .finally(waitForVersionUpdate); +run().catch((e) => { + console.error(formatUnknownError(e)); + process.exitCode = 1; +}); diff --git a/lib/api.ts b/lib/api.ts index 9248042..c794da7 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2,7 +2,6 @@ import { KUBER_API_BASE_URL } from "../const"; import type { ApiProblemDetails } from "../shared/api"; import { KUBER_VERSION_HEADER } from "../shared/version"; import { readSession, type KuberSession } from "./session"; -import { observeServerVersion } from "./version-update"; export type { ApiProblemDetails } from "../shared/api"; @@ -195,9 +194,7 @@ async function sendRequest( }, ); try { - (options.onServerVersion ?? observeServerVersion)( - response.headers.get(KUBER_VERSION_HEADER), - ); + options.onServerVersion?.(response.headers.get(KUBER_VERSION_HEADER)); } catch { // Version discovery must never interrupt an API request. } diff --git a/lib/version-update.ts b/lib/version-update.ts index 894ea89..9154e0a 100644 --- a/lib/version-update.ts +++ b/lib/version-update.ts @@ -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 { + 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 { + 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 { + 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 { + 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 | 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((_, 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; + try { + replay = (dependencies.rerun ?? rerunCommand)(executable, args, () => + report(true), + ); + const result = await replay; + return result; + } catch { + report(false); + return; + } +} diff --git a/package.json b/package.json index f2a27bd..b0dcaa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "2.7.0-rc3", + "version": "2.7.0", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" diff --git a/tests/command/module-graph.test.ts b/tests/command/module-graph.test.ts index 0a3a4a8..e19361c 100644 --- a/tests/command/module-graph.test.ts +++ b/tests/command/module-graph.test.ts @@ -14,9 +14,11 @@ describe("CLI module graph", () => { expect(new TextDecoder().decode(probe.stdout)).toContain("USAGE"); }); - test("production build retains the passive updater's runtime environment guard", () => { + test("production build retains the startup updater's runtime environment guard", () => { const root = join(import.meta.dir, "../.."); - const { scripts } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const { scripts } = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), + ); const args = (scripts.build as string).split(/\s+/); expect(args).toContain("--env"); expect(args[args.indexOf("--env") + 1]).toBe("disable"); @@ -31,8 +33,10 @@ describe("CLI module graph", () => { expect(new TextDecoder().decode(build.stderr)).toBe(""); expect(build.exitCode).toBe(0); const bundle = readFileSync(join(outdir, "index.js"), "utf8"); - expect(bundle).toMatch(/process\.env\.NODE_ENV\s*===\s*["']test["']/); - expect(bundle).toMatch(/process\.env\.NODE_ENV\s*===\s*["']development["']/); + expect(bundle).toContain("NODE_ENV"); + expect(bundle).toContain('"development"'); + expect(bundle).toContain('"test"'); + expect(bundle).toContain("KUBER_VERSION_REEXEC"); } finally { rmSync(outdir, { recursive: true, force: true }); } diff --git a/tests/command/trust.test.ts b/tests/command/trust.test.ts index 00e11e9..b94c1b6 100644 --- a/tests/command/trust.test.ts +++ b/tests/command/trust.test.ts @@ -1,8 +1,14 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { rm } from "node:fs/promises"; +import { runCommand } from "citty"; import { KuberApiError, type ApiRequestInit } from "../../lib/api"; import { getTrustPath, readTrust, updateTrust } from "../../lib/trust"; -import { grantTrust, revokeTrust, statusTrust } from "../../command/trust"; +import { + grantTrust, + revokeTrust, + statusTrust, + trust, +} from "../../command/trust"; const originalConfig = process.env.XDG_CONFIG_HOME; const identity = { project: "demo", fingerprint: "a".repeat(64) }; @@ -28,6 +34,31 @@ function requester( } describe("trust command", () => { + test("citty dispatch of bare trust runs the parent grant handler", async () => { + await expect(runCommand(trust, { rawArgs: [] })).rejects.toThrow(); + }); + + test.each(["status", "revoke"])( + "citty dispatch of %s does not run the parent grant handler", + async (subcommand) => { + const command = { + ...trust, + subCommands: Object.fromEntries( + Object.entries(trust.subCommands!).map(([name, child]) => [ + name, + { ...child, run: async () => {} }, + ]), + ), + }; + + // Without a provided app context, a parent grant would reject in + // current(). Successful dispatch proves citty did not grant afterward. + await expect( + runCommand(command, { rawArgs: [subcommand] }), + ).resolves.toBeDefined(); + }, + ); + test("grants, reports, and revokes the current namespace fingerprint", async () => { process.env.XDG_CONFIG_HOME = `/tmp/kuber-trust-command-${crypto.randomUUID()}`; const calls: Array<{ path: string; init?: ApiRequestInit }> = []; diff --git a/tests/lib/version-update-process.test.ts b/tests/lib/version-update-process.test.ts index 1e6cf5a..9d81ff2 100644 --- a/tests/lib/version-update-process.test.ts +++ b/tests/lib/version-update-process.test.ts @@ -1,62 +1,326 @@ import { expect, test } from "bun:test"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + needsVersionPreflight, + preflightVersion, + resolveInstalledVersion, +} from "../../lib/version-update"; const updaterUrl = new URL("../../lib/version-update.ts", import.meta.url).href; const entryUrl = new URL("../../index.ts", import.meta.url).href; const mainUrl = new URL("../../command/main.ts", import.meta.url).href; +const contextUrl = new URL("../../lib/context.ts", import.meta.url).href; const errorUrl = new URL("../../lib/error.ts", import.meta.url).href; -test.each([ - ["success", "return true", "+ Updated to 2.7.0"], - ["command error", "return true", "+ Updated to 2.7.0"], - ["failure", "throw new Error('offline')", "+ New version available: 2.7.0"], - [ - "timeout", - "return await installVersion(version, () => ({ exited: new Promise(() => {}), kill: () => {} }), 1)", - "+ New version available: 2.7.0", - ], -])( - "CLI waits for a %s update and preserves its exit status", - async (_name, outcome, message) => { - const script = ` - import { mock } from "bun:test"; - const { createVersionObserver, installVersion } = await import(${JSON.stringify(updaterUrl)}); - const observe = createVersionObserver({ currentVersion: "2.6.1", runner: async (version) => { - if (${JSON.stringify(_name)} !== "timeout") await new Promise(resolve => setTimeout(resolve, 80)); - ${outcome}; - }}); - mock.module("citty", () => ({ createMain: () => async () => { - observe("2.7.0"); - observe("2.8.0"); - if (${JSON.stringify(_name)} === "command error") throw new Error("command failed"); - process.exitCode = 7; - }})); - mock.module(${JSON.stringify(mainUrl)}, () => ({ main: {}, initializeCompletion: async () => {} })); - mock.module(${JSON.stringify(errorUrl)}, () => ({ formatUnknownError: String, wrapCommandErrors: () => {} })); - await import(${JSON.stringify(entryUrl)}); - `; - const started = Date.now(); - const child = Bun.spawn([process.execPath, "-e", script, "login"], { +test("replayed command receives parent signals and preserves its resulting status", async () => { + const cwd = await mkdtemp(join(tmpdir(), "kuber-version-signal-")); + try { + const executable = join(cwd, "kuber"); + await writeFile( + executable, + `#!${process.execPath}\nprocess.on('SIGTERM', () => process.exit(23)); console.log('ready'); setInterval(() => {}, 1000);`, + ); + await chmod(executable, 0o755); + const script = `const {rerunCommand,propagateCommandExit} = await import(${JSON.stringify(updaterUrl)}); propagateCommandExit(await rerunCommand(${JSON.stringify(executable)},[]));`; + const child = Bun.spawn([process.execPath, "-e", script], { + cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); - const timer = setTimeout(() => child.kill(), 5_000); + const timer = setTimeout(() => child.kill("SIGKILL"), 5_000); try { - const [code, stdout, stderr] = await Promise.all([ + const reader = child.stdout.getReader(); + const first = await reader.read(); + expect(new TextDecoder().decode(first.value)).toContain("ready"); + child.kill("SIGTERM"); + expect(await child.exited).toBe(23); + expect(await new Response(child.stderr).text()).toBe(""); + reader.releaseLock(); + } finally { + clearTimeout(timer); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}, 10_000); + +test("packaged CLI preflights, installs and replays before importing a user config", async () => { + const cwd = await mkdtemp(join(tmpdir(), "kuber-packaged-version-")); + try { + const bundle = join(cwd, "index.js"); + const built = await Bun.build({ + entrypoints: [new URL("../../index.ts", import.meta.url).pathname], + target: "bun", + outdir: cwd, + }); + expect(built.success).toBe(true); + const executable = join(cwd, "kuber"); + await writeFile( + executable, + `#!${process.execPath}\nif (process.argv[2] === '--version') {console.log('1.0.0');} else {console.log(JSON.stringify({args:process.argv.slice(2),marker:process.env.KUBER_VERSION_REEXEC}));process.exitCode=19;}`, + ); + await chmod(executable, 0o755); + const preload = join(cwd, "preload.ts"); + await writeFile( + preload, + ` + import {mock} from 'bun:test'; + import {spawn as realSpawn} from 'node:child_process'; + const spawn = realSpawn; + mock.module('node:child_process', () => ({spawn: (exe,args,options) => { + if (args.join(' ') === 'pm bin -g') return spawn(process.execPath,['-e',${JSON.stringify(`console.log(${JSON.stringify(cwd)})`)}],options); + return spawn(exe,args,options); + }})); + const originalSpawn = Bun.spawn; + Bun.spawn = (args,options) => { + if (args[1] === 'i' && args[2] === '-g') {console.error('INSTALL:' + args.at(-1)); return {exited:Promise.resolve(0),kill(){}};} + return originalSpawn(args,options); + }; + globalThis.fetch = async (url,options) => { + console.error('HEALTH:' + url + ':' + options.method + ':' + !!options.headers); + return new Response('',{headers:{'X-Kuber-Version':'1.0.0'}}); + }; + `, + ); + await writeFile( + join(cwd, "custom.ts"), + "throw Error('user config ran before replay');", + ); + const child = Bun.spawn( + [ + process.execPath, + "--preload", + preload, + bundle, + "--config", + "custom.ts", + "fuck", + ], + { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + NODE_ENV: "production", + KUBER_VERSION_REEXEC: "", + }, + }, + ); + const [code, out, err] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect({ code, out, err }).toMatchObject({ code: 19 }); + expect(JSON.parse(out)).toEqual({ + args: ["--config", "custom.ts", "fuck"], + marker: "1", + }); + expect(err).toBe( + "HEALTH:https://kuber.astrxl.dev/api/v2/health:GET:false\nINSTALL:@dmgnr/kuber@1.0.0\n\x1b[90m+ Updated to 1.0.0\x1b[0m\n", + ); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}, 20_000); + +test("offline and informational commands skip the health check", () => { + for (const args of [ + [], + ["complete"], + ["--version"], + ["up", "-h"], + ["users", "--help"], + ["exec", "-v"], + ["export"], + ["add"], + ["db", "ls"], + ["s3", "ls"], + ["ui"], + ]) + expect(needsVersionPreflight(args)).toBe(false); + for (const args of [ + ["login"], + ["init"], + ["exec"], + ["ci"], + ["up"], + ["down"], + ["trust"], + ["users"], + ["--config", "custom.ts", "fuck"], + ]) + expect(needsVersionPreflight(args)).toBe(true); +}); + +test("preflight has a bounded deadline even if a fetch seam ignores abort", async () => { + let signal: AbortSignal | null | undefined; + expect( + await preflightVersion(["up"], { + runner: async () => { + throw Error("unexpected install"); + }, + fetch: ((_url: unknown, options: RequestInit) => { + signal = options.signal; + return new Promise(() => {}); + }) as typeof fetch, + timeoutMs: 5, + }), + ).toBeUndefined(); + expect(signal?.aborted).toBe(true); +}); + +test("global resolver uses Bun's configured bin and verifies the exact version", async () => { + for (const reported of [undefined, "1.0.0", "2.7.0+other", "2.7.0"]) { + const calls: string[][] = []; + const result = await resolveInstalledVersion("2.7.0", async (args) => { + calls.push(args); + return calls.length === 1 ? "/custom/global/bin" : reported; + }); + expect(calls).toEqual([ + [process.execPath, "pm", "bin", "-g"], + ["/custom/global/bin/kuber", "--version"], + ]); + expect(result).toBe( + reported === "2.7.0" ? "/custom/global/bin/kuber" : undefined, + ); + } +}); + +test.each([ + "success", + "older", + "metadata", + "failure", + "rejection", + "wrong executable", + "spawn failure", + "equal", + "invalid", + "absent", + "unavailable", + "marker", + "signal", +])( + "startup synchronization: %s", + async (scenario) => { + const cwd = await mkdtemp(join(tmpdir(), "kuber-version-process-")); + const executable = join(cwd, "kuber"); + const version = + scenario === "older" + ? "1.0.0" + : scenario === "metadata" + ? "2.6.1+server" + : scenario === "equal" + ? "2.6.1" + : scenario === "invalid" + ? "v2.7.0" + : "2.7.0"; + const success = ["success", "older", "metadata", "signal"].includes( + scenario, + ); + try { + await writeFile( + executable, + `#!${process.execPath}\n + const { preflightVersion } = await import(${JSON.stringify(updaterUrl)}); + await preflightVersion(process.argv.slice(2), { runner: async () => { throw Error('recursive install'); } }); + console.log('child:' + JSON.stringify({args:process.argv.slice(2),cwd:process.cwd(),marker:process.env.KUBER_VERSION_REEXEC,path:process.env.PATH,custom:process.env.KUBER_TEST_CUSTOM})); + ${scenario === "signal" ? "process.kill(process.pid, 'SIGTERM')" : "process.exitCode = 17"}; + `, + ); + await chmod(executable, 0o755); + const script = ` + import { mock } from 'bun:test'; + const original = await import(${JSON.stringify(updaterUrl)}); + const preflight = original.preflightVersion; + mock.module(${JSON.stringify(updaterUrl)}, () => ({ ...original, + preflightVersion: args => preflight(args, { + currentVersion: '2.6.1', + fetch: async (url, options) => { + console.log('health:' + url + ':' + options.method + ':' + !!options.headers); + if (${JSON.stringify(scenario)} === 'unavailable') throw Error('offline'); + return new Response('', {headers:${scenario === "absent" ? "{}" : `{'X-Kuber-Version':${JSON.stringify(version)}}`}}); + }, + runner: async version => { console.log('install:' + version); ${scenario === "rejection" ? "throw Error('failed')" : `return ${scenario !== "failure"}`}; }, + resolveExecutable: async version => {console.log('verify:' + version); return ${scenario === "wrong executable" ? "undefined" : JSON.stringify(scenario === "spawn failure" ? join(cwd, "missing") : executable)};}, + }) + })); + mock.module(${JSON.stringify(mainUrl)}, () => ({main:{run:({rawArgs}) => {console.log('mutate:' + JSON.stringify(rawArgs)); process.exitCode = 7;}},initializeCompletion:async () => {}})); + mock.module(${JSON.stringify(contextUrl)}, () => ({provideContext:async (run, config) => {console.log('context:' + config); await run();}})); + mock.module(${JSON.stringify(errorUrl)}, () => ({formatUnknownError:String,wrapCommandErrors:() => {}})); + process.argv = [process.execPath, 'test-cli.js', '--config', 'custom.ts', 'fuck']; + await import(${JSON.stringify(entryUrl)}); + `; + const child = Bun.spawn([process.execPath, "-e", script], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + KUBER_TEST_CUSTOM: "preserved", + KUBER_VERSION_REEXEC: scenario === "marker" ? "1" : "", + }, + }); + const [code, out, err] = await Promise.all([ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); - expect(code).toBe(_name === "command error" ? 1 : 7); - expect(Date.now() - started).toBeGreaterThanOrEqual( - _name === "timeout" ? 900 : 70, - ); - expect(stdout).toBe(""); - expect(stderr).toBe( - `${_name === "command error" ? "Error: command failed\n" : ""}\x1b[90m${message}\x1b[0m\n`, + if (success) { + expect(code).toBe(scenario === "signal" ? 143 : 17); + expect(out).not.toContain("context:"); + expect(out).not.toContain("mutate:"); + expect(out.split("child:")).toHaveLength(2); + const payload = JSON.parse(out.split("child:")[1]!); + expect(payload).toEqual({ + args: ["--config", "custom.ts", "fuck"], + cwd, + marker: "1", + path: process.env.PATH, + custom: "preserved", + }); + expect(err).toBe(`\x1b[90m+ Updated to ${version}\x1b[0m\n`); + } else { + expect({ code, out, err }).toMatchObject({ code: 7 }); + expect(out.split("mutate:")).toHaveLength(2); + expect(out).toContain('context:custom.ts\nmutate:["rollback"]'); + expect(out).not.toContain("child:"); + expect(err).toBe( + [ + "failure", + "rejection", + "wrong executable", + "spawn failure", + ].includes(scenario) + ? `\x1b[90m+ New version available: ${version}\x1b[0m\n` + : "", + ); + } + expect(out.split("install:")).toHaveLength( + success || + [ + "failure", + "rejection", + "wrong executable", + "spawn failure", + ].includes(scenario) + ? 2 + : 1, ); + if (scenario !== "marker") + expect(out).toStartWith( + "health:https://kuber.astrxl.dev/api/v2/health:GET:false\n", + ); } finally { - clearTimeout(timer); + await rm(cwd, { recursive: true, force: true }); } }, + 10_000, ); diff --git a/tests/lib/version-update.test.ts b/tests/lib/version-update.test.ts index 14811cb..99c50ca 100644 --- a/tests/lib/version-update.test.ts +++ b/tests/lib/version-update.test.ts @@ -118,7 +118,7 @@ describe("server version updates", () => { expect(signals).toEqual([undefined, "SIGKILL"]); }); - test("ignores malformed, equal, and older headers without attempting an install", async () => { + test("ignores malformed and exactly equal headers without attempting an install", async () => { const runs: string[] = []; const lines: string[] = []; const observe = createVersionObserver({ @@ -129,15 +129,7 @@ describe("server version updates", () => { }, report: (line) => lines.push(line), }); - for (const version of [ - null, - "garbage", - "2.6.1-rc3", - "2.6.0", - "2.6.1-rc2", - "2.6.1-rc3+build", - ]) - observe(version); + for (const version of [null, "garbage", "2.6.1-rc3"]) observe(version); await tick(); expect(runs).toEqual([]); expect(lines).toEqual([]);