feat: release 2.7.1
This commit is contained in:
@@ -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.
|
||||
+37
-8
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+5
-7
@@ -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,
|
||||
|
||||
+28
-11
@@ -3,6 +3,7 @@ import { wrapCommandErrors } from "../lib/error";
|
||||
|
||||
function lazyCommand<T extends object>(
|
||||
load: () => Promise<T>,
|
||||
options: { hidden?: boolean } = {},
|
||||
): () => Promise<T> {
|
||||
let pending: Promise<T> | undefined;
|
||||
return () =>
|
||||
@@ -11,6 +12,12 @@ function lazyCommand<T extends object>(
|
||||
// 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<string, unknown>;
|
||||
};
|
||||
definition.meta = { ...definition.meta, hidden: true };
|
||||
}
|
||||
wrapCommandErrors(cliCommand);
|
||||
return cliCommand;
|
||||
})
|
||||
@@ -37,8 +44,8 @@ function cloneCommand<T extends object>(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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+8
-6
@@ -31,7 +31,9 @@ async function getServiceClaim(service: string): Promise<S3Claim> {
|
||||
(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<S3Claim> {
|
||||
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,
|
||||
|
||||
+1
-1
@@ -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([
|
||||
|
||||
+9
-5
@@ -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<string, unknown>;
|
||||
@@ -1054,7 +1058,7 @@ export async function runUp(
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Reconcile resources",
|
||||
title: "Apply resources",
|
||||
task: async (taskCtx, task) => {
|
||||
taskCtx.plan = await planResources(
|
||||
project,
|
||||
|
||||
+34
-11
@@ -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(
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user