feat: release 2.5.1

This commit is contained in:
2026-09-27 10:40:50 +00:00 Unverified
parent 46d97138dc
commit 5f7509a9c5
53 changed files with 2871 additions and 370 deletions
+31
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
parseLogTailLines,
runLogs,
type LogApiEvent,
type LogEventWriter,
@@ -81,4 +82,34 @@ describe("logs API command runner", () => {
);
expect(receivedSignal).toBe(controller.signal);
});
test.each([false, true])("sends -n with follow=%s", async (follow) => {
const paths: string[] = [];
const stream: LogsApiStream = async function* (path, _init, options) {
paths.push(path);
expect(options?.timeoutMs).toBe(follow ? 0 : undefined);
yield* [] as never[];
};
await runLogs("shop", "web", follow, undefined, stream, () => {}, 0);
expect(paths).toEqual([
`/workspaces/shop/logs?service=web${follow ? "&follow=true" : ""}&tailLines=0`,
]);
});
test("accepts only non-negative safe integer line counts", async () => {
expect(parseLogTailLines("25")).toBe(25);
expect(parseLogTailLines("0")).toBe(0);
for (const invalid of ["-1", "1.5", "2abc", "", "1e3", "9007199254740992"])
expect(() => parseLogTailLines(invalid)).toThrow("non-negative integer");
let called = false;
const stream: LogsApiStream = async function* () {
called = true;
yield* [] as never[];
};
await expect(
runLogs("shop", undefined, false, undefined, stream, () => {}, -1),
).rejects.toThrow("non-negative integer");
expect(called).toBe(false);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
describe("CLI module graph", () => {
test("does not resolve the Kubernetes client during CLI startup", () => {
const entry = join(import.meta.dir, "../../index.ts");
const probe = Bun.spawnSync([process.execPath, entry, "--help"], {
cwd: import.meta.dir,
env: process.env,
});
expect(probe.exitCode).toBe(0);
expect(new TextDecoder().decode(probe.stdout)).toContain("USAGE");
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
const entry = join(import.meta.dir, "../../index.ts");
function cli(...args: string[]) {
const result = Bun.spawnSync([process.execPath, entry, ...args], {
cwd: import.meta.dir,
env: { ...process.env, TERM: "dumb", NO_COLOR: "1" },
});
return {
status: result.exitCode,
output: new TextDecoder().decode(result.stdout),
error: new TextDecoder().decode(result.stderr),
};
}
describe("CLI startup", () => {
test("registers all original names as lazy commands", () => {
const probe = Bun.spawnSync([
process.execPath,
"-e",
`import { main } from ${JSON.stringify(join(import.meta.dir, "../../command/main.ts"))}; console.log(JSON.stringify(Object.entries(main.subCommands).map(([name, command]) => [name, typeof command])));`,
]);
expect(probe.exitCode).toBe(0);
const commands = JSON.parse(new TextDecoder().decode(probe.stdout)) as [
string,
string,
][];
expect(commands.map(([name]) => name)).toEqual([
"audit",
"ci",
"db",
"down",
"export",
"exec",
"logs",
"maintenance",
"login",
"logout",
"operations",
"ps",
"restart",
"rollback",
"s3",
"start",
"stop",
"up",
"users",
"trust",
"whoami",
]);
expect(commands.every(([, type]) => type === "function")).toBe(true);
});
test("root help retains descriptions, alias, and completion command", () => {
const result = cli("--help");
expect(result.status).toBe(0);
expect(result.output).toContain("rollback, fuck");
expect(result.output).toContain("complete");
expect(result.output).toContain("Create and start deployments");
const missingCommand = cli();
expect(missingCommand.status).toBe(1);
expect(missingCommand.output).toContain("complete");
});
test("alias and named subcommand have the same help", () => {
const alias = cli("fuck", "--help");
const named = cli("rollback", "--help");
expect(alias.status).toBe(0);
expect(alias.output).toBe(named.output);
expect(alias.output).toContain("DEPLOYMENT");
});
test("completion still resolves flags from nested commands", () => {
const result = cli("complete", "--", "logs", "--");
expect(result.status).toBe(0);
expect(result.output).toContain("--follow");
});
});
+86 -3
View File
@@ -45,9 +45,6 @@ describe("up API pipeline", () => {
join(root, ".kuberrc.ts"),
'export default { project: "shop" };\n',
);
const git = Bun.spawn(["git", "init", "-q", root]);
expect(await git.exited).toBe(0);
process.chdir(root);
process.env.XDG_CONFIG_HOME = configHome;
const identity = await resolveTrustIdentity("shop", root);
@@ -106,6 +103,92 @@ describe("up API pipeline", () => {
}
});
test("adopts before overlapping database and storage reconciliation, then renders merged env", async () => {
const root = await mkdtemp(join(tmpdir(), "kuber-up-api-"));
const previousCwd = process.cwd();
const order: string[] = [];
const started: string[] = [];
let bothReady!: () => void;
const bothStarted = new Promise<void>((resolve) => {
bothReady = resolve;
});
let releaseDatabase!: () => void;
let releaseStorage!: () => void;
const database = new Promise<void>((resolve) => {
releaseDatabase = resolve;
});
const storage = new Promise<void>((resolve) => {
releaseStorage = resolve;
});
try {
await writeFile(
join(root, "compose.yml"),
"services:\n app:\n image: nginx\n volumes:\n - postgresql:app\n - s3:assets\n",
);
await writeFile(
join(root, ".kuberrc.ts"),
'export default { project: "shop" };\n',
);
expect(await Bun.spawn(["git", "init", "-q", root]).exited).toBe(0);
process.chdir(root);
const trust = await resolveTrustIdentity("shop", root);
const request: ApiRequester = async <T>(path: string) => {
order.push(path);
if (path === "/workspaces/shop") throw new KuberApiError("missing", 404);
if (path === "/workspaces")
return {
metadata: { name: "shop", uid: "workspace", resourceVersion: "1" },
} as T;
if (path.endsWith("/adopt")) return { resourcesAdopted: 0 } as T;
if (path.endsWith("/databases")) {
started.push("database");
if (started.length === 2) bothReady();
await database;
return { app: { DATABASE_URL: "postgresql://app", SHARED: "database" } } as T;
}
if (path.endsWith("/storage")) {
started.push("storage");
if (started.length === 2) bothReady();
await storage;
return { app: { AWS_ACCESS_KEY_ID: "key", SHARED: "storage" } } as T;
}
if (path.endsWith("/plan")) return { desired: [], stale: [] } as T;
return {} as T;
};
const run = provideContext(() => runUp(false, request, { trust }));
try {
await bothStarted;
expect(started).toEqual(["database", "storage"]);
expect(order.indexOf("/workspaces/shop/adopt")).toBeLessThan(
order.indexOf("/workspaces/shop/databases"),
);
expect(order).not.toContain("/workspaces/shop/resources/plan");
releaseStorage();
await Promise.resolve();
expect(order).not.toContain("/workspaces/shop/resources/plan");
releaseDatabase();
const result = await run;
expect(result.serviceEnv).toEqual({
app: {
DATABASE_URL: "postgresql://app",
SHARED: "storage",
AWS_ACCESS_KEY_ID: "key",
},
});
expect(order.indexOf("/workspaces/shop/resources/plan")).toBeGreaterThan(
order.indexOf("/workspaces/shop/storage"),
);
} finally {
releaseStorage();
releaseDatabase();
await run.catch(() => {});
}
} finally {
process.chdir(previousCwd);
await rm(root, { recursive: true, force: true });
}
});
test("creates workspace metadata without embedding source blobs", async () => {
const calls: Array<{ path: string; init?: ApiRequestInit }> = [];
const request: ApiRequester = async <T>(