From 80364c5099b2f4ebbcc457350b594e6242b22aa6 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Wed, 7 Oct 2026 10:33:21 +0000 Subject: [PATCH] feat: release 2.7.1 --- changelogs/2.7.1.md | 6 ++ command/auth.ts | 45 +++++++-- command/db.ts | 12 +-- command/main.ts | 39 +++++--- command/maintenance.ts | 2 +- command/restart.ts | 4 +- command/rollback.ts | 2 +- command/s3.ts | 14 +-- command/stop.ts | 2 +- command/up.ts | 14 ++- command/users.ts | 45 ++++++--- package.json | 2 +- tests/command/administration.test.ts | 133 ++++++++++++++++++++++++++- tests/command/auth.test.ts | 77 +++++++++++++++- tests/command/metadata.test.ts | 60 ++++++++++-- tests/command/startup.test.ts | 17 ++++ tests/command/up-api.test.ts | 36 ++++---- 17 files changed, 427 insertions(+), 83 deletions(-) create mode 100644 changelogs/2.7.1.md diff --git a/changelogs/2.7.1.md b/changelogs/2.7.1.md new file mode 100644 index 0000000..89f8cb2 --- /dev/null +++ b/changelogs/2.7.1.md @@ -0,0 +1,6 @@ +# 2.7.1 + +- Fix Ctrl+C cancellation during login. +- Persist login sessions by default; use `--session` for a temporary session. +- Simplify CLI wording and hide seven advanced commands from root help while keeping them callable and available in direct command help and completion. +- Support both user-key creation forms, `-w`/`-e`/`--expireDays` aliases, positional capabilities, and the legacy flags. diff --git a/command/auth.ts b/command/auth.ts index 917df14..f57bdaf 100644 --- a/command/auth.ts +++ b/command/auth.ts @@ -67,6 +67,7 @@ export function createLoginPrompt( }); class KuberBasicAuth extends Auth { private submitting = false; + private cancelling = false; override async initialize() { await super.initialize(); @@ -84,6 +85,33 @@ export function createLoginPrompt( await super.submit(); } + override async cancel() { + // Bun may close readline while processing Ctrl+C before Enquirer emits + // cancel. Enquirer's close handler must therefore run only once. + if (this.cancelling || this.state.closed) return; + this.cancelling = true; + const stop = (this as unknown as { stop?: () => void }).stop; + if (stop) { + ( + this as unknown as { + removeListener(event: string, listener: () => void): void; + } + ).removeListener("close", stop); + try { + // Bun's readline may already be closed by Ctrl+C. Enquirer's stop + // restores raw mode and removes its keypress handler before calling + // pause/close, which would otherwise throw ERR_USE_AFTER_CLOSE. + stop(); + } catch (error) { + // The terminal cleanup above has run; Bun can report + // ERR_USE_AFTER_CLOSE because readline was closed by Ctrl+C. + if ((error as { code?: string }).code !== "ERR_USE_AFTER_CLOSE") + throw error; + } + } + await super.cancel(); + } + override async render() { if (this.state.submitted) { // FormPrompt skips choice.format on submission and cancellation. @@ -100,8 +128,9 @@ export function createLoginPrompt( let session: LoginResponse; try { session = await super.run(); - } catch { - throw new Error("Login cancelled"); + } catch (error) { + if (this.state.cancelled) throw new Error("Login cancelled"); + throw error; } if (failure !== undefined) throw failure; return session; @@ -136,7 +165,7 @@ export async function loginUser( /** Share the CLI's credential prompt with onboarding without exposing passwords. */ export async function interactiveLogin( username = "", - persistent = false, + persistent: boolean | undefined = true, dependencies: { isTTY?: boolean; prompt?: typeof createLoginPrompt; @@ -148,7 +177,7 @@ export async function interactiveLogin( ); const session = await (dependencies.prompt ?? createLoginPrompt)( username.trim(), - persistent, + persistent ?? true, ).run(); console.log(`Logged in as ${session.user.username}`); return session; @@ -157,16 +186,16 @@ export async function interactiveLogin( export const login = defineCommand({ meta: { name: "login", - description: "Log in to kuber.astrxl.dev", + description: "Log in to the cluster", }, args: { - persist: { + session: { type: "boolean", - description: "Keep the login across reboots", + description: "Use a temporary session for this runtime", }, }, async run({ args }) { - await interactiveLogin(String(args._[0] ?? ""), Boolean(args.persist)); + await interactiveLogin(String(args._[0] ?? ""), !Boolean(args.session)); }, }); diff --git a/command/db.ts b/command/db.ts index cdcebb9..f6607e9 100644 --- a/command/db.ts +++ b/command/db.ts @@ -30,12 +30,12 @@ export function getDatabaseCredentials( const list = defineCommand({ meta: { name: "ls", - description: "List managed postgres claims", + description: "List database volumes used by this project", }, async run() { const claims = getComposePostgresClaims(await ctx().compose()); if (claims.length === 0) { - console.log("No managed postgres volumes declared"); + console.log("No database volumes declared"); return; } @@ -55,7 +55,7 @@ const list = defineCommand({ const creds = defineCommand({ meta: { name: "creds", - description: "Print postgres credentials for a service", + description: "Print database credentials for a service", }, async run({ args }) { const service = args._[0]; @@ -66,9 +66,7 @@ const creds = defineCommand({ (entry) => entry.service === service, ); if (!claim) { - throw new Error( - `Service ${service} does not declare a managed postgres volume.`, - ); + throw new Error(`Service ${service} does not declare a database volume.`); } const credentials = await getDatabaseCredentials(context.project, claim); @@ -93,7 +91,7 @@ const creds = defineCommand({ export const db = defineCommand({ meta: { name: "db", - description: "Inspect managed postgres databases", + description: "View project databases and credentials", }, subCommands: { creds, diff --git a/command/main.ts b/command/main.ts index d135847..06511a5 100644 --- a/command/main.ts +++ b/command/main.ts @@ -3,6 +3,7 @@ import { wrapCommandErrors } from "../lib/error"; function lazyCommand( load: () => Promise, + options: { hidden?: boolean } = {}, ): () => Promise { let pending: Promise | undefined; return () => @@ -11,6 +12,12 @@ function lazyCommand( // Keep the exported command objects pristine: tests and callers may // import them directly while the CLI needs wrapped handlers. const cliCommand = cloneCommand(command); + if (options.hidden) { + const definition = cliCommand as T & { + meta?: Record; + }; + definition.meta = { ...definition.meta, hidden: true }; + } wrapCommandErrors(cliCommand); return cliCommand; }) @@ -37,8 +44,8 @@ function cloneCommand(command: T): T { export const main = defineCommand({ meta: { name: "kuber", - version: "2.7.0", - description: "Docker Compose -> K8s translation layer", + version: "2.7.1", + description: "Deploy and manage projects on Kubernetes", }, args: { config: { @@ -48,21 +55,27 @@ export const main = defineCommand({ }, subCommands: { add: lazyCommand(() => import("./add").then((m) => m.add)), - audit: lazyCommand(() => import("./audit").then((m) => m.audit)), - ci: lazyCommand(() => import("./ci").then((m) => m.ci)), + audit: lazyCommand(() => import("./audit").then((m) => m.audit), { + hidden: true, + }), + ci: lazyCommand(() => import("./ci").then((m) => m.ci), { hidden: true }), db: lazyCommand(() => import("./db").then((m) => m.db)), down: lazyCommand(() => import("./down").then((m) => m.down)), export: lazyCommand(() => import("./export").then((m) => m.exportCommand)), exec: lazyCommand(() => import("./exec").then((m) => m.exec)), init: lazyCommand(() => import("./init").then((m) => m.init)), logs: lazyCommand(() => import("./logs").then((m) => m.logs)), - maintenance: lazyCommand(() => - import("./maintenance").then((m) => m.maintenance), + maintenance: lazyCommand( + () => import("./maintenance").then((m) => m.maintenance), + { hidden: true }, ), login: lazyCommand(() => import("./auth").then((m) => m.login)), - logout: lazyCommand(() => import("./auth").then((m) => m.logout)), - operations: lazyCommand(() => - import("./operations").then((m) => m.operations), + logout: lazyCommand(() => import("./auth").then((m) => m.logout), { + hidden: true, + }), + operations: lazyCommand( + () => import("./operations").then((m) => m.operations), + { hidden: true }, ), ps: lazyCommand(() => import("./ps").then((m) => m.ps)), restart: lazyCommand(() => import("./restart").then((m) => m.restart)), @@ -71,9 +84,13 @@ export const main = defineCommand({ start: lazyCommand(() => import("./start").then((m) => m.start)), stop: lazyCommand(() => import("./stop").then((m) => m.stop)), up: lazyCommand(() => import("./up").then((m) => m.up)), - users: lazyCommand(() => import("./users").then((m) => m.users)), + users: lazyCommand(() => import("./users").then((m) => m.users), { + hidden: true, + }), trust: lazyCommand(() => import("./trust").then((m) => m.trust)), - whoami: lazyCommand(() => import("./auth").then((m) => m.whoami)), + whoami: lazyCommand(() => import("./auth").then((m) => m.whoami), { + hidden: true, + }), }, }); diff --git a/command/maintenance.ts b/command/maintenance.ts index 359dccc..07baaa7 100644 --- a/command/maintenance.ts +++ b/command/maintenance.ts @@ -64,7 +64,7 @@ export async function runMaintenance( export const maintenance = defineCommand({ meta: { name: "maintenance", - description: "Toggle a global hostname maintenance override", + description: "Toggle maintenance page on hostnames", }, args: { host: { type: "positional", required: true, description: "DNS hostname" }, diff --git a/command/restart.ts b/command/restart.ts index 59734eb..9d0dba4 100644 --- a/command/restart.ts +++ b/command/restart.ts @@ -26,12 +26,12 @@ export function runRestart( export const restart = defineCommand({ meta: { name: "restart", - description: "Roll out a restart for managed deployments", + description: "Restart deployments", }, args: { deployment: { type: "positional", - description: "Deployment name to restart (defaults to all managed)", + description: "Deployment name to restart (defaults to all deployments)", required: false, }, }, diff --git a/command/rollback.ts b/command/rollback.ts index 6f8f192..4cf877c 100644 --- a/command/rollback.ts +++ b/command/rollback.ts @@ -36,7 +36,7 @@ export const rollback = defineCommand({ args: { deployment: { type: "positional", - description: "Deployment name to roll back (defaults to all managed)", + description: "Deployment name to roll back (defaults to all deployments)", required: false, }, }, diff --git a/command/s3.ts b/command/s3.ts index 0c6fe5c..0107d06 100644 --- a/command/s3.ts +++ b/command/s3.ts @@ -31,7 +31,9 @@ async function getServiceClaim(service: string): Promise { (entry) => entry.service === service, ); if (!claim) { - throw new Error(`Service ${service} does not declare a managed S3 volume.`); + throw new Error( + `Service ${service} does not declare an object storage volume.`, + ); } return claim; } @@ -39,12 +41,12 @@ async function getServiceClaim(service: string): Promise { const list = defineCommand({ meta: { name: "ls", - description: "List managed S3 claims", + description: "List object storage volumes used by this project", }, async run() { const claims = getComposeS3Claims(await ctx().compose()); if (claims.length === 0) { - console.log("No managed S3 volumes declared"); + console.log("No object storage volumes declared"); return; } @@ -63,7 +65,7 @@ const list = defineCommand({ const creds = defineCommand({ meta: { name: "creds", - description: "Print S3 environment variables for a service", + description: "Print object storage environment variables for a service", }, async run({ args }) { const service = args._[0]; @@ -82,7 +84,7 @@ const creds = defineCommand({ const ui = defineCommand({ meta: { name: "ui", - description: "Print the Garage UI URL for a service bucket", + description: "Print the object storage browser URL for a service bucket", }, async run({ args }) { const service = args._[0]; @@ -95,7 +97,7 @@ const ui = defineCommand({ export const s3 = defineCommand({ meta: { name: "s3", - description: "Inspect managed S3 storage", + description: "View project object storage settings", }, subCommands: { creds, diff --git a/command/stop.ts b/command/stop.ts index 88c4236..7ac8ffa 100644 --- a/command/stop.ts +++ b/command/stop.ts @@ -22,7 +22,7 @@ export function runStop( export const stop = defineCommand({ meta: { name: "stop", - description: "Scale managed deployments to zero", + description: "Stop all deployments", }, async run() { await new Listr([ diff --git a/command/up.ts b/command/up.ts index 6ea72e1..a18dcc9 100644 --- a/command/up.ts +++ b/command/up.ts @@ -668,7 +668,11 @@ function resourceOperationTaskTitle( resource: ResourceOperationTarget, ): string { const action = - phase === "apply" ? "Apply" : phase === "wait" ? "Wait for" : "Delete"; + phase === "apply" + ? "Applying" + : phase === "wait" + ? "Waiting for" + : "Deleting"; return `${action} ${resource.kind}/${resource.name}`; } @@ -980,13 +984,13 @@ export async function runUp( }, }, { - title: "Reconcile backing services", + title: "Set up backing services", rendererOptions: { bottomBar: 1, persistentOutput: true }, task: (taskCtx, task) => task.newListr( [ { - title: "Reconcile databases", + title: "Set up databases", enabled: () => getComposePostgresClaims(taskCtx.compose!).length > 0, task: async (_ctx, child) => { @@ -1008,7 +1012,7 @@ export async function runUp( }, }, { - title: "Reconcile S3 storage", + title: "Set up object storage", enabled: () => getComposeS3Claims(taskCtx.compose!).length > 0, task: async (_ctx, child) => { let response: Record; @@ -1054,7 +1058,7 @@ export async function runUp( }, }, { - title: "Reconcile resources", + title: "Apply resources", task: async (taskCtx, task) => { taskCtx.plan = await planResources( project, diff --git a/command/users.ts b/command/users.ts index f1fcedb..594bd46 100644 --- a/command/users.ts +++ b/command/users.ts @@ -342,7 +342,8 @@ const keys = defineCommand({ ls: defineCommand({ meta: { name: "ls", - description: "List all API keys (optional positional username filters the results)", + description: + "List all API keys (optional positional username filters the results)", }, async run({ args }) { console.log(await listApiKeys(args._[0])); @@ -352,7 +353,7 @@ const keys = defineCommand({ meta: { name: "create", description: - "Create an API key for a user (e.g. kuber users keys create alice --capabilities kubernetes:read --expires-days none)", + "Create an API key (e.g. kuber users keys create --capabilities kubernetes:write --workspace kuber-server --expireDays=none dmgnr; or kuber users keys create -w kuber-server -e none dmgnr kubernetes:write,kubernetes:read)", }, args: { username: { @@ -362,25 +363,47 @@ const keys = defineCommand({ }, capabilities: { type: "string", - required: true, + alias: "c", description: - "Comma-separated capabilities, e.g. kubernetes:read,kubernetes:write (also kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)", + "Comma-separated capabilities via --capabilities, or as the second positional argument; e.g. kubernetes:read,kubernetes:write (also kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)", + position: 1, }, workspace: { type: "string", + alias: "w", description: "Restrict the key to a workspace", }, "expires-days": { type: "string", - default: "90", - description: "Expiry in days (1-365), or none for a non-expiring key", + alias: "e", + description: + "Expiry in days (1-365), or none for a non-expiring key (also --expireDays)", + }, + expireDays: { + type: "string", + description: "Alias for --expires-days", }, }, async run({ args }) { - const days = - args["expires-days"] === "none" - ? undefined - : Number(args["expires-days"]); + if (args.expireDays !== undefined && args["expires-days"] !== undefined) + throw new Error( + "Provide only one of --expires-days and --expireDays", + ); + const expiry = args.expireDays ?? args["expires-days"] ?? "90"; + const positionalCapabilities = args._?.[1]; + if ( + args.capabilities !== undefined && + positionalCapabilities !== undefined + ) + throw new Error( + "Provide capabilities either with --capabilities or as the second positional argument, not both", + ); + const capabilities = args.capabilities ?? positionalCapabilities; + if (typeof capabilities !== "string" || !capabilities.trim()) + throw new Error( + "Provide capabilities with --capabilities or as the second positional argument", + ); + const days = expiry === "none" ? undefined : Number(expiry); if ( days !== undefined && (!Number.isSafeInteger(days) || days < 1 || days > 365) @@ -389,7 +412,7 @@ const keys = defineCommand({ "--expires-days must be an integer from 1 to 365, or none", ); const key = await createApiKey(requireUsername(args.username), { - capabilities: parseCapabilities(args.capabilities), + capabilities: parseCapabilities(capabilities), ...(args.workspace && { workspace: args.workspace }), ...(days !== undefined && { expiresAt: new Date( diff --git a/package.json b/package.json index b0dcaa3..fd51f1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "2.7.0", + "version": "2.7.1", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" diff --git a/tests/command/administration.test.ts b/tests/command/administration.test.ts index db2cf44..3cc13fa 100644 --- a/tests/command/administration.test.ts +++ b/tests/command/administration.test.ts @@ -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 ( + 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).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 = { diff --git a/tests/command/auth.test.ts b/tests/command/auth.test.ts index 3940f80..fdcc3a0 100644 --- a/tests/command/auth.test.ts +++ b/tests/command/auth.test.ts @@ -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 }; + 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]); + }); }); diff --git a/tests/command/metadata.test.ts b/tests/command/metadata.test.ts index fc02a6a..87e5d89 100644 --- a/tests/command/metadata.test.ts +++ b/tests/command/metadata.test.ts @@ -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>; subCommands?: Record; 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(); + }); }); diff --git a/tests/command/startup.test.ts b/tests/command/startup.test.ts index 776fe29..ca5c0ef 100644 --- a/tests/command/startup.test.ts +++ b/tests/command/startup.test.ts @@ -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); + }, +); diff --git a/tests/command/up-api.test.ts b/tests/command/up-api.test.ts index bbeb8d8..c23f336 100644 --- a/tests/command/up-api.test.ts +++ b/tests/command/up-api.test.ts @@ -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;