feat: release 2.7.1

This commit is contained in:
2026-10-07 10:33:21 +00:00 Unverified
parent 174faf01ed
commit 80364c5099
17 changed files with 427 additions and 83 deletions
+131 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import { runCommand } from "citty";
import { listAuditEvents } from "../../command/audit";
import { main } from "../../command/main";
import { getOperation, listOperations } from "../../command/operations";
@@ -15,6 +16,7 @@ import {
users,
} from "../../command/users";
import type { ApiRequestInit } from "../../lib/api";
import * as api from "../../lib/api";
type Call = { path: string; init?: ApiRequestInit };
@@ -298,15 +300,24 @@ describe("user administration commands", () => {
"kubernetes:read,kubernetes:write",
);
expect(create.args?.["expires-days"]?.description).toContain("none");
expect(create.args?.workspace?.alias).toBe("w");
expect(create.args?.["expires-days"]?.alias).toBe("e");
expect(create.meta?.description).toContain("--expireDays=none");
expect(ls.meta?.description).toContain("optional positional username");
await expect(
create.run?.({
args: { username: "alice", capabilities: "invalid", "expires-days": "90" },
args: {
_: [],
username: "alice",
capabilities: "invalid",
"expires-days": "90",
},
} as never),
).rejects.toThrow("kubernetes:read,kubernetes:write");
await expect(
create.run?.({
args: {
_: [],
username: "alice",
capabilities: "kubernetes:read",
"expires-days": "NaN",
@@ -314,6 +325,124 @@ describe("user administration commands", () => {
} as never),
).rejects.toThrow("1 to 365, or none");
});
test("parses both API key creation forms and retains username position", async () => {
const calls: Call[] = [];
const request = spyOn(api, "apiRequest").mockImplementation((async <T>(
path: string,
init?: ApiRequestInit,
) => {
calls.push({ path, init });
return (
init
? {
id: "fake-key-id-123456",
username: "dmgnr",
capabilities:
init.json &&
(init.json as { capabilities: string[] }).capabilities,
workspace: "kuber-server",
token: "fake-token-never-real",
disabled: false,
}
: { username: "dmgnr", roles: ["admin"], disabled: false }
) as T;
}) as typeof api.apiRequest);
const log = spyOn(console, "log").mockImplementation(() => {});
const keyCommand = (users.subCommands as Record<string, any>).keys;
const create = keyCommand.subCommands.create;
try {
await runCommand(users, {
rawArgs: [
"keys",
"create",
"--capabilities",
"kubernetes:write",
"--workspace",
"kuber-server",
"--expires-days",
"none",
"dmgnr",
],
});
expect(calls[1]?.path).toBe("/users/dmgnr/keys");
expect(calls[1]?.init?.json).toEqual({
capabilities: ["kubernetes:write"],
workspace: "kuber-server",
});
calls.length = 0;
await runCommand(users, {
rawArgs: [
"keys",
"create",
"--capabilities",
"kubernetes:read",
"--expires-days",
"30",
"dmgnr",
],
});
expect(
(calls[1]?.init?.json as { expiresAt: string }).expiresAt,
).toBeString();
calls.length = 0;
await runCommand(users, {
rawArgs: [
"keys",
"create",
"-w",
"kuber-server",
"-e",
"none",
"dmgnr",
"kubernetes:write,kubernetes:read",
],
});
expect(calls[1]?.path).toBe("/users/dmgnr/keys");
expect(calls[1]?.init?.json).toEqual({
capabilities: ["kubernetes:write", "kubernetes:read"],
workspace: "kuber-server",
});
await expect(
create.run?.({ args: { username: "dmgnr" } } as never),
).rejects.toThrow("Provide capabilities");
await expect(
create.run?.({
args: {
username: "dmgnr",
capabilities: "kubernetes:read",
_: ["dmgnr", "kubernetes:write"],
},
} as never),
).rejects.toThrow("not both");
await expect(
create.run?.({
args: {
username: "dmgnr",
capabilities: "kubernetes:read",
"expires-days": "0",
},
} as never),
).rejects.toThrow("1 to 365, or none");
calls.length = 0;
await runCommand(users, {
rawArgs: [
"keys",
"create",
"dmgnr",
"--capabilities",
"kubernetes:read",
],
});
expect(
(calls[1]?.init?.json as { expiresAt: string }).expiresAt,
).toBeString();
} finally {
request.mockRestore();
log.mockRestore();
}
});
});
const operation = {
+76 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { EventEmitter } from "node:events";
import { spawnSync } from "node:child_process";
import { Writable } from "node:stream";
import {
createLoginPrompt,
@@ -90,12 +91,63 @@ describe("BasicAuth login", () => {
const { result } = await start(prompt);
const rejected = result.catch((error: unknown) => error);
await credentials(prompt, "alice", "secret");
await prompt.cancel();
await Promise.all([prompt.cancel(), prompt.cancel()]);
expect(await rejected).toMatchObject({ message: "Login cancelled" });
expect(calls).toBe(0);
expect(prompt.values.password).toBe("");
});
test("preserves non-cancellation prompt initialization errors", async () => {
const error = new Error("terminal initialization failed");
const prompt = createLoginPrompt("", false, async () => session, {
show: false,
});
const enquirerPrototype = Object.getPrototypeOf(
Object.getPrototypeOf(prompt),
) as { run: () => Promise<unknown> };
const originalRun = enquirerPrototype.run;
enquirerPrototype.run = () => Promise.reject(error);
try {
await expect(prompt.run()).rejects.toBe(error);
} finally {
enquirerPrototype.run = originalRun;
}
});
test.skipIf(!Bun.which("python3"))(
"Ctrl+C in a real terminal cancels cleanly without authenticating",
() => {
const script = `import os, pty, select, subprocess, sys, time
master, slave = pty.openpty()
code = 'import { createLoginPrompt } from "./command/auth.ts"; const p = createLoginPrompt("alice", true, async () => { throw Error("API must not be called") }); p.run().catch(error => console.error(error.message))'
child = subprocess.Popen([sys.argv[1], "--eval", code], stdin=slave, stdout=slave, stderr=slave)
os.close(slave)
time.sleep(0.6)
os.write(master, b"\\x03")
output = b""
deadline = time.time() + 5
while time.time() < deadline and child.poll() is None:
ready, _, _ = select.select([master], [], [], 0.1)
if ready:
try: output += os.read(master, 4096)
except OSError: break
if child.poll() is None: child.kill()
child.wait()
sys.stdout.buffer.write(output)
sys.exit(0 if b"Login cancelled" in output and b"ERR_USE_AFTER_CLOSE" not in output and b"API must not be called" not in output else 1)`;
const result = spawnSync("python3", ["-c", script, process.execPath], {
cwd: process.cwd(),
encoding: "utf8",
timeout: 10_000,
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stdout).toContain("Login cancelled");
expect(result.stdout).not.toContain("ERR_USE_AFTER_CLOSE");
expect(result.stdout).not.toContain("API must not be called");
},
);
test.each(["submit", "cancel"] as const)(
"masks password while editing and never prints it on %s",
async (action) => {
@@ -177,4 +229,27 @@ describe("BasicAuth login", () => {
});
expect(result).toEqual(session);
});
test("interactive login persists by default and accepts temporary-session override", async () => {
const persistence: boolean[] = [];
for (const override of [undefined, false] as const) {
await interactiveLogin("alice", override, {
isTTY: true,
prompt: (_username, persistent) => {
persistence.push(persistent ?? true);
const prompt = createLoginPrompt(
"alice",
persistent ?? true,
async () => session,
{ show: false },
);
(prompt as unknown as EventEmitter).once("run", () => {
void prompt.submit();
});
return prompt;
},
});
}
expect(persistence).toEqual([true, false]);
});
});
+52 -8
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { renderUsage } from "citty";
import { login, logout, whoami } from "../../command/auth";
import { db } from "../../command/db";
import { down } from "../../command/down";
@@ -6,6 +7,7 @@ import { exec } from "../../command/exec";
import { exportCommand } from "../../command/export";
import { logs } from "../../command/logs";
import { initializeCompletion, main } from "../../command/main";
import { maintenance } from "../../command/maintenance";
import { ps } from "../../command/ps";
import { restart } from "../../command/restart";
import { rollback } from "../../command/rollback";
@@ -15,7 +17,7 @@ import { stop } from "../../command/stop";
import { up } from "../../command/up";
type Command = {
meta?: { name?: string; description?: string };
meta?: { name?: string; description?: string; hidden?: boolean };
args?: Record<string, Record<string, unknown>>;
subCommands?: Record<string, Command>;
run?: unknown;
@@ -23,21 +25,23 @@ type Command = {
describe("CLI command definitions", () => {
const commands = [
["kuber", "Deploy and manage projects on Kubernetes", main],
["up", "Create and start deployments", up],
["login", "Log in to kuber.astrxl.dev", login],
["login", "Log in to the cluster", login],
["logout", "Log out of kuber.astrxl.dev", logout],
["whoami", "Show the current kuber user", whoami],
["start", "Start deployments without rebuilding images", start],
["stop", "Scale managed deployments to zero", stop],
["restart", "Roll out a restart for managed deployments", restart],
["stop", "Stop all deployments", stop],
["restart", "Restart deployments", restart],
["rollback", "Roll deployments back to the previous release", rollback],
["s3", "Inspect managed S3 storage", s3],
["maintenance", "Toggle maintenance page on hostnames", maintenance],
["s3", "View project object storage settings", s3],
["down", "Delete managed resources except ingress and PVCs", down],
["ps", "List deployments", ps],
["logs", "Show deployment logs", logs],
["exec", "Execute a command inside a deployment pod", exec],
["export", "Write rendered manifests to a YAML file", exportCommand],
["db", "Inspect managed postgres databases", db],
["db", "View project databases and credentials", db],
] as const;
test.each(commands)(
@@ -45,8 +49,9 @@ describe("CLI command definitions", () => {
(name, description, command) => {
const definition = command as Command;
expect(definition.meta).toMatchObject({ name, description });
if (name !== "db" && name !== "s3")
if (name !== "kuber" && name !== "db" && name !== "s3")
expect(typeof definition.run).toBe("function");
if (name === "kuber") expect(definition.run).toBeUndefined();
},
);
@@ -79,7 +84,7 @@ describe("CLI command definitions", () => {
expect((main as Command).args?.config).toMatchObject({
type: "string",
});
expect((login as Command).args?.persist).toMatchObject({
expect((login as Command).args?.session).toMatchObject({
type: "boolean",
});
});
@@ -115,4 +120,43 @@ describe("CLI command definitions", () => {
Object.keys((await Promise.resolve(main.subCommands)) ?? {}),
).toContain("complete");
});
test("hides internal commands only from root help", async () => {
const usage = await renderUsage(main);
for (const name of [
"ci",
"whoami",
"logout",
"operations",
"maintenance",
"audit",
"users",
]) {
expect(usage).not.toContain(name);
expect(usage).not.toContain(`${name}|`);
}
for (const name of ["add", "login", "up"]) expect(usage).toContain(name);
});
test("does not mutate exported command metadata when loading hidden commands", async () => {
await renderUsage(main);
const { ci } = await import("../../command/ci");
const { whoami, logout } = await import("../../command/auth");
const { operations } = await import("../../command/operations");
const { maintenance } = await import("../../command/maintenance");
const { audit } = await import("../../command/audit");
const { users } = await import("../../command/users");
for (const command of [
ci,
whoami,
logout,
operations,
maintenance,
audit,
users,
])
expect(
(command as Command & { meta?: { hidden?: boolean } }).meta?.hidden,
).toBeUndefined();
});
});
+17
View File
@@ -22,3 +22,20 @@ test("init help starts in an empty directory without requiring Compose", async (
await rm(cwd, { recursive: true, force: true });
}
});
test.each(["users", "ci"])(
"hidden %s command remains available to CLI help",
async (command) => {
const process = Bun.spawn(
["bun", resolve(import.meta.dir, "../../index.ts"), command, "--help"],
{ stdout: "pipe", stderr: "pipe" },
);
const [status, out, err] = await Promise.all([
process.exited,
new Response(process.stdout).text(),
new Response(process.stderr).text(),
]);
expect(status).toBe(0);
expect(out + err).toContain(command);
},
);
+18 -18
View File
@@ -587,39 +587,39 @@ describe("up API pipeline", () => {
try {
await bothStarted;
const titles = rootTasks!.map(({ title }) => title);
expect(titles.indexOf("Reconcile backing services")).toBeLessThan(
expect(titles.indexOf("Set up backing services")).toBeLessThan(
titles.indexOf("Render manifests"),
);
expect(titles.indexOf("Render manifests")).toBeLessThan(
titles.indexOf("Reconcile resources"),
titles.indexOf("Apply resources"),
);
const backing = rootTasks!.find(
({ title }) => title === "Reconcile backing services",
({ title }) => title === "Set up backing services",
)!;
expect(backing.subtasks.map(({ title }) => title)).toEqual([
"Reconcile databases",
"Reconcile S3 storage",
"Set up databases",
"Set up object storage",
]);
const active = frames
.map(frameRows)
.filter(
(rows) =>
taskRow(rows, "Reconcile backing services") &&
taskRow(rows, "Reconcile databases") &&
taskRow(rows, "Reconcile S3 storage"),
taskRow(rows, "Set up backing services") &&
taskRow(rows, "Set up databases") &&
taskRow(rows, "Set up object storage"),
);
expect(active.length).toBeGreaterThan(0);
for (const rows of active) {
const parent = taskRow(rows, "Reconcile backing services");
expect(depth(taskRow(rows, "Reconcile databases"))).toBeGreaterThan(
const parent = taskRow(rows, "Set up backing services");
expect(depth(taskRow(rows, "Set up databases"))).toBeGreaterThan(
depth(parent),
);
expect(depth(taskRow(rows, "Reconcile S3 storage"))).toBeGreaterThan(
expect(depth(taskRow(rows, "Set up object storage"))).toBeGreaterThan(
depth(parent),
);
}
expect(
rootTasks!.filter(({ title }) => title === "Reconcile databases"),
rootTasks!.filter(({ title }) => title === "Set up databases"),
).toEqual([]);
expect(started).toEqual(["database", "storage"]);
expect(order.indexOf("/workspaces/shop/adopt")).toBeLessThan(
@@ -714,10 +714,10 @@ describe("up API pipeline", () => {
provideContext(() => runUp(false, request, { trust })),
).rejects.toThrow(detail);
const rows = frameRows(frames.at(-1)!);
expect(depth(taskRow(rows, "Reconcile databases"))).toBeGreaterThan(
depth(taskRow(rows, "Reconcile backing services")),
expect(depth(taskRow(rows, "Set up databases"))).toBeGreaterThan(
depth(taskRow(rows, "Set up backing services")),
);
expect(taskRow(rows, "Reconcile databases")).toMatch(/✖/);
expect(taskRow(rows, "Set up databases")).toMatch(/✖/);
const visible = rows.join(" ").replace(/\s+/g, " ");
expect(visible.split(detail)).toHaveLength(2);
expect(calls).not.toContain("/workspaces/shop/resources/plan");
@@ -1359,8 +1359,8 @@ describe("up API pipeline", () => {
expect(operationCalls).toBe(1);
expect(listr.tasks[0]?.subtasks).toHaveLength(2);
expect(listr.tasks[0]?.subtasks.map((task) => task.title)).toEqual([
"Apply Service/web",
"Apply Deployment/api",
"Applying Service/web",
"Applying Deployment/api",
]);
}, 1_000);
@@ -1388,7 +1388,7 @@ describe("up API pipeline", () => {
]);
await single.run();
expect(single.tasks[0]?.subtasks.map(({ title }) => title)).toEqual([
"Apply Service/web",
"Applying Service/web",
]);
let called = false;