diff --git a/command/main.ts b/command/main.ts index 701a5c6..981848f 100644 --- a/command/main.ts +++ b/command/main.ts @@ -15,7 +15,7 @@ import { up } from "./up"; export const main = defineCommand({ meta: { name: "kuber", - version: "1.2.1", + version: "1.2.2", description: "Docker Compose -> K8s translation layer", }, args: { diff --git a/index.ts b/index.ts index 27b0800..6eeeeef 100644 --- a/index.ts +++ b/index.ts @@ -4,25 +4,11 @@ import { createMain } from "citty"; import { initializeCompletion, main } from "./command/main"; import { provideContext } from "./lib/context"; import { extractConfigArgument } from "./lib/config"; -import z, { ZodError } from "zod"; - -function formatUnknownError(error: unknown): string { - if (error instanceof ZodError) return z.prettifyError(error); - - if (error instanceof Error) { - const stack = error.stack?.split("\n").slice(0, 3).join("\n"); - return stack && stack.trim().length > 0 ? stack : error.message; - } - - try { - return JSON.stringify(error, null, 2); - } catch { - return String(error); - } -} +import { formatUnknownError, wrapCommandErrors } from "./lib/error"; async function run() { await initializeCompletion(); + wrapCommandErrors(main); const cli = createMain(main); const { configPath, rawArgs } = extractConfigArgument(process.argv.slice(2)); await provideContext(() => cli({ rawArgs }), configPath); diff --git a/lib/error.ts b/lib/error.ts new file mode 100644 index 0000000..74c5b99 --- /dev/null +++ b/lib/error.ts @@ -0,0 +1,61 @@ +import z, { ZodError } from "zod"; + +type RunnableCommand = { + run?: (...args: any[]) => unknown; + subCommands?: unknown; +}; + +const WrappedCommand = Symbol("wrappedCommand"); + +type WrappedCommand = RunnableCommand & { + [WrappedCommand]?: true; +}; + +type ErrorHandler = (error: unknown) => void; + +export function formatUnknownError(error: unknown): string { + if (error instanceof ZodError) return z.prettifyError(error); + + if (error instanceof Error) { + const stack = error.stack?.split("\n").slice(0, 3).join("\n"); + return stack && stack.trim().length > 0 ? stack : error.message; + } + + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +} + +function reportCommandError(error: unknown): void { + console.error(formatUnknownError(error)); + process.exitCode = 1; +} + +export function wrapCommandErrors( + command: RunnableCommand, + handleError: ErrorHandler = reportCommandError, +): void { + const wrapped = command as WrappedCommand; + if (wrapped[WrappedCommand]) return; + wrapped[WrappedCommand] = true; + + if (command.run) { + const run = command.run; + command.run = async (...args) => { + try { + return await run(...args); + } catch (error) { + handleError(error); + } + }; + } + + if (!command.subCommands || typeof command.subCommands !== "object") return; + for (const subCommand of Object.values(command.subCommands)) { + if (subCommand && typeof subCommand === "object") { + wrapCommandErrors(subCommand as RunnableCommand, handleError); + } + } +} diff --git a/package.json b/package.json index c8ca910..a017cd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "1.2.1", + "version": "1.2.2", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" diff --git a/tests/lib/error.test.ts b/tests/lib/error.test.ts new file mode 100644 index 0000000..f562959 --- /dev/null +++ b/tests/lib/error.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, mock, test } from "bun:test"; +import z from "zod"; +import { formatUnknownError, wrapCommandErrors } from "../../lib/error"; + +describe("CLI error handling", () => { + test("formats Zod errors without exposing internal parser details", () => { + const error = z.object({ name: z.string() }).safeParse({ name: 1 }).error; + expect(formatUnknownError(error)).toContain("name"); + expect(formatUnknownError(error)).toContain("expected string"); + }); + + test("catches errors from nested command handlers before Citty sees them", async () => { + const handleError = mock((_error: unknown) => {}); + const command = { + subCommands: { + child: { + async run() { + throw new Error("command failed"); + }, + }, + }, + }; + + wrapCommandErrors(command, handleError); + await expect(command.subCommands.child.run()).resolves.toBeUndefined(); + expect(handleError).toHaveBeenCalledTimes(1); + expect(handleError.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(String(handleError.mock.calls[0]?.[0])).toContain("command failed"); + }); + + test("formats a dispatched command error once and exits with code 1", async () => { + const process = Bun.spawn(["bun", "index.ts", "exec"], { + cwd: `${import.meta.dir}/../..`, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([ + new Response(process.stderr).text(), + process.exited, + ]); + + expect(exitCode).toBe(1); + expect(stderr.match(/Deployment name is required/g)).toHaveLength(1); + }); +});