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
+3 -1
View File
@@ -25,7 +25,9 @@ export async function runExec(
): Promise<number> {
if (!deployment) throw new Error("Deployment name is required");
if (command.length === 0) throw new Error("Command is required");
const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
const tty = Boolean(
process.stdin.isTTY && process.stdout.isTTY && process.stdin.isRaw,
);
const session: ExecApiSession = await opener(
project,
{ deployment, command, tty, ...(tty ? terminalSize() : {}) },
+22
View File
@@ -49,6 +49,13 @@ function defaultWriter(): LogEventWriter {
};
}
export function parseLogTailLines(value: string): number {
const tailLines = Number(value);
if (!/^\d+$/.test(value) || !Number.isSafeInteger(tailLines))
throw new Error("-n must be a non-negative integer");
return tailLines;
}
export async function runLogs(
project: string,
service: string | undefined,
@@ -56,10 +63,17 @@ export async function runLogs(
signal?: AbortSignal,
stream: LogsApiStream = apiStreamNdjson,
write: LogEventWriter = defaultWriter(),
tailLines?: number,
): Promise<void> {
if (
tailLines !== undefined &&
(!Number.isSafeInteger(tailLines) || tailLines < 0)
)
throw new Error("-n must be a non-negative integer");
const query = new URLSearchParams();
if (service) query.set("service", service);
if (follow) query.set("follow", "true");
if (tailLines !== undefined) query.set("tailLines", String(tailLines));
const suffix = query.size ? `?${query}` : "";
for await (const event of stream<LogApiEvent>(
@@ -87,6 +101,11 @@ export const logs = defineCommand({
alias: "f",
description: "Follow log output",
},
tail: {
type: "string",
alias: "n",
description: "Show the last N lines per container",
},
},
async run({ args }) {
const service = args._[0];
@@ -100,6 +119,9 @@ export const logs = defineCommand({
service,
Boolean(args.follow),
controller.signal,
apiStreamNdjson,
defaultWriter(),
args.tail === undefined ? undefined : parseLogTailLines(args.tail),
);
} catch (error) {
if (!controller.signal.aborted) throw error;
+80 -44
View File
@@ -1,29 +1,43 @@
import tab from "@bomb.sh/tab/citty";
import { defineCommand } from "citty";
import { audit } from "./audit";
import { ci } from "./ci";
import { login, logout, whoami } from "./auth";
import { db } from "./db";
import { down } from "./down";
import { exec } from "./exec";
import { exportCommand } from "./export";
import { logs } from "./logs";
import { operations } from "./operations";
import { ps } from "./ps";
import { restart } from "./restart";
import { rollback } from "./rollback";
import { s3 } from "./s3";
import { start } from "./start";
import { stop } from "./stop";
import { up } from "./up";
import { users } from "./users";
import { trust } from "./trust";
import { maintenance } from "./maintenance";
import { wrapCommandErrors } from "../lib/error";
function lazyCommand<T extends object>(
load: () => Promise<T>,
): () => Promise<T> {
let pending: Promise<T> | undefined;
return () =>
(pending ??= load()
.then((command) => {
// Keep the exported command objects pristine: tests and callers may
// import them directly while the CLI needs wrapped handlers.
const cliCommand = cloneCommand(command);
wrapCommandErrors(cliCommand);
return cliCommand;
})
.catch((error) => {
pending = undefined;
throw error;
}));
}
function cloneCommand<T extends object>(command: T): T {
const clone = { ...command } as T & { subCommands?: unknown };
const subCommands = clone.subCommands;
if (subCommands && typeof subCommands === "object") {
clone.subCommands = Object.fromEntries(
Object.entries(subCommands).map(([name, child]) => [
name,
child && typeof child === "object" ? cloneCommand(child) : child,
]),
);
}
return clone;
}
export const main = defineCommand({
meta: {
name: "kuber",
version: "2.4.0",
version: "2.5.1",
description: "Docker Compose -> K8s translation layer",
},
args: {
@@ -33,33 +47,55 @@ export const main = defineCommand({
},
},
subCommands: {
audit,
ci,
db,
down,
export: exportCommand,
exec,
logs,
maintenance,
login,
logout,
operations,
ps,
restart,
rollback,
s3,
start,
stop,
up,
users,
trust,
whoami,
audit: lazyCommand(() => import("./audit").then((m) => m.audit)),
ci: lazyCommand(() => import("./ci").then((m) => m.ci)),
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)),
logs: lazyCommand(() => import("./logs").then((m) => m.logs)),
maintenance: lazyCommand(() =>
import("./maintenance").then((m) => m.maintenance),
),
login: lazyCommand(() => import("./auth").then((m) => m.login)),
logout: lazyCommand(() => import("./auth").then((m) => m.logout)),
operations: lazyCommand(() =>
import("./operations").then((m) => m.operations),
),
ps: lazyCommand(() => import("./ps").then((m) => m.ps)),
restart: lazyCommand(() => import("./restart").then((m) => m.restart)),
rollback: lazyCommand(() => import("./rollback").then((m) => m.rollback)),
s3: lazyCommand(() => import("./s3").then((m) => m.s3)),
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)),
trust: lazyCommand(() => import("./trust").then((m) => m.trust)),
whoami: lazyCommand(() => import("./auth").then((m) => m.whoami)),
},
});
let completion: ReturnType<typeof tab> | undefined;
type Completion = Awaited<
ReturnType<typeof import("@bomb.sh/tab/citty")["default"]>
>;
let completion: Promise<Completion> | undefined;
export function initializeCompletion() {
completion ??= tab(main);
completion ??= (async () => {
// tab inspects command objects directly, unlike citty's lazy resolver.
const commands = main.subCommands;
if (commands && typeof commands !== "function") {
main.subCommands = Object.fromEntries(
await Promise.all(
Object.entries(commands).map(async ([name, command]) => [
name,
typeof command === "function" ? await command() : await command,
]),
),
);
}
const { default: tab } = await import("@bomb.sh/tab/citty");
return tab(main);
})();
return completion;
}
+56 -31
View File
@@ -101,6 +101,7 @@ export type LiveResourceOperationOptions = {
type UpContext = {
compose?: ComposeSpecification;
snapshot?: WorkspaceSnapshot;
workspaceRoot?: string;
buildImages?: Record<string, string>;
serviceEnv?: Record<string, Record<string, string>>;
resources?: KubernetesResource[];
@@ -751,7 +752,14 @@ export async function runUp(
{
title: "Snapshot workspace",
task: async (taskCtx, task) => {
taskCtx.snapshot = await enumerateWorkspace(await getRepoRoot(cwd));
let workspaceRoot: string;
try {
workspaceRoot = await getRepoRoot(cwd);
} catch (error) {
workspaceRoot = cwd;
}
taskCtx.workspaceRoot = workspaceRoot;
taskCtx.snapshot = await enumerateWorkspace(workspaceRoot);
task.output = `${taskCtx.snapshot.manifest.files.length} files`;
},
},
@@ -774,7 +782,13 @@ export async function runUp(
},
stream: task.stdout(),
},
{ ...config, request, snapshot: taskCtx.snapshot },
{
...config,
request,
snapshot: taskCtx.snapshot,
workspaceRoot: taskCtx.workspaceRoot,
signal: task.signal,
},
);
taskCtx.buildImages = result.images;
await config.postBuild?.(result, await getHookContext());
@@ -822,38 +836,49 @@ export async function runUp(
},
},
{
title: "Reconcile databases",
enabled: async () =>
getComposePostgresClaims(await compose()).length > 0,
title: "Reconcile backing services",
task: async (taskCtx, task) => {
const response = await managementRequest<Record<string, unknown>>(
project,
request,
`${workspacePath}/databases`,
{ method: "POST", json: { compose: taskCtx.compose } },
);
let databaseEnv: Record<string, Record<string, string>> = {};
let storageEnv: Record<string, Record<string, string>> = {};
await task.newListr(
[
{
title: "Reconcile databases",
enabled: () =>
getComposePostgresClaims(taskCtx.compose!).length > 0,
task: async (_ctx, child) => {
const response = await managementRequest<Record<string, unknown>>(
project,
request,
`${workspacePath}/databases`,
{ method: "POST", json: { compose: taskCtx.compose } },
);
databaseEnv = operationEnvironment(response);
child.output = `${Object.keys(databaseEnv).length} services`;
},
},
{
title: "Reconcile S3 storage",
enabled: () =>
getComposeS3Claims(taskCtx.compose!).length > 0,
task: async (_ctx, child) => {
const response = await managementRequest<Record<string, unknown>>(
project,
request,
`${workspacePath}/storage`,
{ method: "POST", json: { compose: taskCtx.compose } },
);
storageEnv = operationEnvironment(response);
child.output = `${Object.keys(storageEnv).length} services`;
},
},
],
{ concurrent: true },
).run();
taskCtx.serviceEnv = mergeServiceEnv(
taskCtx.serviceEnv,
operationEnvironment(response),
mergeServiceEnv(taskCtx.serviceEnv, databaseEnv),
storageEnv,
);
task.output = `${Object.keys(taskCtx.serviceEnv).length} services`;
},
},
{
title: "Reconcile S3 storage",
enabled: async () => getComposeS3Claims(await compose()).length > 0,
task: async (taskCtx, task) => {
const response = await managementRequest<Record<string, unknown>>(
project,
request,
`${workspacePath}/storage`,
{ method: "POST", json: { compose: taskCtx.compose } },
);
taskCtx.serviceEnv = mergeServiceEnv(
taskCtx.serviceEnv,
operationEnvironment(response),
);
task.output = `${Object.keys(taskCtx.serviceEnv).length} services`;
},
},
{