feat: release 2.7.0
This commit is contained in:
+11
-4
@@ -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
|
# 2.7.0-rc3
|
||||||
|
|
||||||
- Use source-first Enquirer Select prompts with conditional image, CNB, or template choices, plus app-path AutoComplete.
|
- 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.
|
- 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.
|
- 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.
|
- Show aggregate context-upload progress in an `Uploading context` child task, deduplicating shared blobs and accounting for resumed uploads.
|
||||||
- Support `build: auto:<GitHub HTTPS .cnb URI>` with optional `#sha256=<hex>` pinning. The real `kilterset/bun` 1.0.0 package was fetched and validated for Linux ARM64; its architecture mismatch was correctly rejected for AMD64.
|
- Support `build: auto:<GitHub HTTPS .cnb URI>` with optional `#sha256=<hex>` 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.
|
- Live CNB Kubernetes Job execution and registry push remain unverified.
|
||||||
|
|
||||||
# 2.7.0-rc1
|
# 2.7.0-rc1 (historical)
|
||||||
|
|
||||||
## Added
|
## 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.
|
- 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.
|
- 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
|
## Fixed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ function cloneCommand<T extends object>(command: T): T {
|
|||||||
export const main = defineCommand({
|
export const main = defineCommand({
|
||||||
meta: {
|
meta: {
|
||||||
name: "kuber",
|
name: "kuber",
|
||||||
version: "2.7.0-rc3",
|
version: "2.7.0",
|
||||||
description: "Docker Compose -> K8s translation layer",
|
description: "Docker Compose -> K8s translation layer",
|
||||||
},
|
},
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
+4
-1
@@ -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());
|
await grantTrust(await current());
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import { initializeCompletion, main } from "./command/main";
|
|||||||
import { provideContext } from "./lib/context";
|
import { provideContext } from "./lib/context";
|
||||||
import { extractConfigArgument } from "./lib/config";
|
import { extractConfigArgument } from "./lib/config";
|
||||||
import { formatUnknownError, wrapCommandErrors } from "./lib/error";
|
import { formatUnknownError, wrapCommandErrors } from "./lib/error";
|
||||||
import { waitForVersionUpdate } from "./lib/version-update";
|
import { preflightVersion, propagateCommandExit } from "./lib/version-update";
|
||||||
|
|
||||||
async function run() {
|
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.
|
// citty resolves every preceding lazy command while looking up an alias.
|
||||||
if (rawArgs[0] === "fuck") rawArgs[0] = "rollback";
|
if (rawArgs[0] === "fuck") rawArgs[0] = "rollback";
|
||||||
if (
|
if (
|
||||||
@@ -36,9 +42,7 @@ async function run() {
|
|||||||
await provideContext(() => cli({ rawArgs }), configPath);
|
await provideContext(() => cli({ rawArgs }), configPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
run()
|
run().catch((e) => {
|
||||||
.catch((e) => {
|
|
||||||
console.error(formatUnknownError(e));
|
console.error(formatUnknownError(e));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
})
|
});
|
||||||
.finally(waitForVersionUpdate);
|
|
||||||
|
|||||||
+1
-4
@@ -2,7 +2,6 @@ import { KUBER_API_BASE_URL } from "../const";
|
|||||||
import type { ApiProblemDetails } from "../shared/api";
|
import type { ApiProblemDetails } from "../shared/api";
|
||||||
import { KUBER_VERSION_HEADER } from "../shared/version";
|
import { KUBER_VERSION_HEADER } from "../shared/version";
|
||||||
import { readSession, type KuberSession } from "./session";
|
import { readSession, type KuberSession } from "./session";
|
||||||
import { observeServerVersion } from "./version-update";
|
|
||||||
|
|
||||||
export type { ApiProblemDetails } from "../shared/api";
|
export type { ApiProblemDetails } from "../shared/api";
|
||||||
|
|
||||||
@@ -195,9 +194,7 @@ async function sendRequest(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
(options.onServerVersion ?? observeServerVersion)(
|
options.onServerVersion?.(response.headers.get(KUBER_VERSION_HEADER));
|
||||||
response.headers.get(KUBER_VERSION_HEADER),
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Version discovery must never interrupt an API request.
|
// Version discovery must never interrupt an API request.
|
||||||
}
|
}
|
||||||
|
|||||||
+220
-1
@@ -1,4 +1,8 @@
|
|||||||
import { KUBER_VERSION } from "../shared/version";
|
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 = {
|
type SemVer = {
|
||||||
major: bigint;
|
major: bigint;
|
||||||
@@ -146,7 +150,12 @@ export function createVersionObserver({
|
|||||||
} = {}): (version: string | null) => void {
|
} = {}): (version: string | null) => void {
|
||||||
let attempted = false;
|
let attempted = false;
|
||||||
return (version) => {
|
return (version) => {
|
||||||
if (attempted || !version || compareVersions(version, currentVersion) !== 1)
|
if (
|
||||||
|
attempted ||
|
||||||
|
!version ||
|
||||||
|
!parseVersion(version) ||
|
||||||
|
version === currentVersion
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
attempted = true;
|
attempted = true;
|
||||||
// Defer install work beyond the response headers; never block body consumption.
|
// Defer install work beyond the response headers; never block body consumption.
|
||||||
@@ -182,3 +191,213 @@ export function createVersionObserver({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const observeServerVersion = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dmgnr/kuber",
|
"name": "@dmgnr/kuber",
|
||||||
"version": "2.7.0-rc3",
|
"version": "2.7.0",
|
||||||
"description": "Docker Compose to Kubernetes translation layer",
|
"description": "Docker Compose to Kubernetes translation layer",
|
||||||
"bin": {
|
"bin": {
|
||||||
"kuber": "dist/index.js"
|
"kuber": "dist/index.js"
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ describe("CLI module graph", () => {
|
|||||||
expect(new TextDecoder().decode(probe.stdout)).toContain("USAGE");
|
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 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+/);
|
const args = (scripts.build as string).split(/\s+/);
|
||||||
expect(args).toContain("--env");
|
expect(args).toContain("--env");
|
||||||
expect(args[args.indexOf("--env") + 1]).toBe("disable");
|
expect(args[args.indexOf("--env") + 1]).toBe("disable");
|
||||||
@@ -31,8 +33,10 @@ describe("CLI module graph", () => {
|
|||||||
expect(new TextDecoder().decode(build.stderr)).toBe("");
|
expect(new TextDecoder().decode(build.stderr)).toBe("");
|
||||||
expect(build.exitCode).toBe(0);
|
expect(build.exitCode).toBe(0);
|
||||||
const bundle = readFileSync(join(outdir, "index.js"), "utf8");
|
const bundle = readFileSync(join(outdir, "index.js"), "utf8");
|
||||||
expect(bundle).toMatch(/process\.env\.NODE_ENV\s*===\s*["']test["']/);
|
expect(bundle).toContain("NODE_ENV");
|
||||||
expect(bundle).toMatch(/process\.env\.NODE_ENV\s*===\s*["']development["']/);
|
expect(bundle).toContain('"development"');
|
||||||
|
expect(bundle).toContain('"test"');
|
||||||
|
expect(bundle).toContain("KUBER_VERSION_REEXEC");
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(outdir, { recursive: true, force: true });
|
rmSync(outdir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
|
import { runCommand } from "citty";
|
||||||
import { KuberApiError, type ApiRequestInit } from "../../lib/api";
|
import { KuberApiError, type ApiRequestInit } from "../../lib/api";
|
||||||
import { getTrustPath, readTrust, updateTrust } from "../../lib/trust";
|
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 originalConfig = process.env.XDG_CONFIG_HOME;
|
||||||
const identity = { project: "demo", fingerprint: "a".repeat(64) };
|
const identity = { project: "demo", fingerprint: "a".repeat(64) };
|
||||||
@@ -28,6 +34,31 @@ function requester(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("trust command", () => {
|
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 () => {
|
test("grants, reports, and revokes the current namespace fingerprint", async () => {
|
||||||
process.env.XDG_CONFIG_HOME = `/tmp/kuber-trust-command-${crypto.randomUUID()}`;
|
process.env.XDG_CONFIG_HOME = `/tmp/kuber-trust-command-${crypto.randomUUID()}`;
|
||||||
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
|
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
|
||||||
|
|||||||
@@ -1,62 +1,326 @@
|
|||||||
import { expect, test } from "bun:test";
|
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 updaterUrl = new URL("../../lib/version-update.ts", import.meta.url).href;
|
||||||
const entryUrl = new URL("../../index.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 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;
|
const errorUrl = new URL("../../lib/error.ts", import.meta.url).href;
|
||||||
|
|
||||||
test.each([
|
test("replayed command receives parent signals and preserves its resulting status", async () => {
|
||||||
["success", "return true", "+ Updated to 2.7.0"],
|
const cwd = await mkdtemp(join(tmpdir(), "kuber-version-signal-"));
|
||||||
["command error", "return true", "+ Updated to 2.7.0"],
|
try {
|
||||||
["failure", "throw new Error('offline')", "+ New version available: 2.7.0"],
|
const executable = join(cwd, "kuber");
|
||||||
[
|
await writeFile(
|
||||||
"timeout",
|
executable,
|
||||||
"return await installVersion(version, () => ({ exited: new Promise(() => {}), kill: () => {} }), 1)",
|
`#!${process.execPath}\nprocess.on('SIGTERM', () => process.exit(23)); console.log('ready'); setInterval(() => {}, 1000);`,
|
||||||
"+ New version available: 2.7.0",
|
);
|
||||||
],
|
await chmod(executable, 0o755);
|
||||||
])(
|
const script = `const {rerunCommand,propagateCommandExit} = await import(${JSON.stringify(updaterUrl)}); propagateCommandExit(await rerunCommand(${JSON.stringify(executable)},[]));`;
|
||||||
"CLI waits for a %s update and preserves its exit status",
|
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||||
async (_name, outcome, message) => {
|
cwd,
|
||||||
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"], {
|
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
stderr: "pipe",
|
stderr: "pipe",
|
||||||
});
|
});
|
||||||
const timer = setTimeout(() => child.kill(), 5_000);
|
const timer = setTimeout(() => child.kill("SIGKILL"), 5_000);
|
||||||
try {
|
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,
|
child.exited,
|
||||||
new Response(child.stdout).text(),
|
new Response(child.stdout).text(),
|
||||||
new Response(child.stderr).text(),
|
new Response(child.stderr).text(),
|
||||||
]);
|
]);
|
||||||
expect(code).toBe(_name === "command error" ? 1 : 7);
|
expect({ code, out, err }).toMatchObject({ code: 19 });
|
||||||
expect(Date.now() - started).toBeGreaterThanOrEqual(
|
expect(JSON.parse(out)).toEqual({
|
||||||
_name === "timeout" ? 900 : 70,
|
args: ["--config", "custom.ts", "fuck"],
|
||||||
);
|
marker: "1",
|
||||||
expect(stdout).toBe("");
|
});
|
||||||
expect(stderr).toBe(
|
expect(err).toBe(
|
||||||
`${_name === "command error" ? "Error: command failed\n" : ""}\x1b[90m${message}\x1b[0m\n`,
|
"HEALTH:https://kuber.astrxl.dev/api/v2/health:GET:false\nINSTALL:@dmgnr/[email protected]\n\x1b[90m+ Updated to 1.0.0\x1b[0m\n",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
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(),
|
||||||
|
]);
|
||||||
|
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 {
|
||||||
|
await rm(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
10_000,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe("server version updates", () => {
|
|||||||
expect(signals).toEqual([undefined, "SIGKILL"]);
|
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 runs: string[] = [];
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const observe = createVersionObserver({
|
const observe = createVersionObserver({
|
||||||
@@ -129,15 +129,7 @@ describe("server version updates", () => {
|
|||||||
},
|
},
|
||||||
report: (line) => lines.push(line),
|
report: (line) => lines.push(line),
|
||||||
});
|
});
|
||||||
for (const version of [
|
for (const version of [null, "garbage", "2.6.1-rc3"]) observe(version);
|
||||||
null,
|
|
||||||
"garbage",
|
|
||||||
"2.6.1-rc3",
|
|
||||||
"2.6.0",
|
|
||||||
"2.6.1-rc2",
|
|
||||||
"2.6.1-rc3+build",
|
|
||||||
])
|
|
||||||
observe(version);
|
|
||||||
await tick();
|
await tick();
|
||||||
expect(runs).toEqual([]);
|
expect(runs).toEqual([]);
|
||||||
expect(lines).toEqual([]);
|
expect(lines).toEqual([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user