diff --git a/package.json b/package.json index 5d54f00..efa3fd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@decodo/cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Official CLI for the Decodo APIs", "license": "MIT", "type": "module", diff --git a/src/cli/services/configure-commander-exit.ts b/src/cli/services/configure-commander-exit.ts new file mode 100644 index 0000000..b5cfbff --- /dev/null +++ b/src/cli/services/configure-commander-exit.ts @@ -0,0 +1,18 @@ +import type { Command, CommanderError } from "commander"; +import { EXIT } from "../../platform/constants.js"; + +function applyCommanderExit( + command: Command, + handler: (err: CommanderError) => never +): void { + command.exitOverride(handler); + for (const subcommand of command.commands) { + applyCommanderExit(subcommand, handler); + } +} + +export function configureCommanderExit(program: Command): void { + applyCommanderExit(program, (err: CommanderError) => { + process.exit(err.exitCode === 0 ? EXIT.OK : EXIT.USAGE); + }); +} diff --git a/src/index.ts b/src/index.ts index 2fa95b3..7aa2b60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { createCommands } from "./cli/register.js"; +import { configureCommanderExit } from "./cli/services/configure-commander-exit.js"; import { handleCliError } from "./platform/services/handle-cli-error.js"; function readVersion(): string { @@ -31,6 +32,8 @@ async function main(): Promise { program.addCommand(command); } + configureCommanderExit(program); + await program.parseAsync(process.argv); } diff --git a/tests/cli/services/configure-commander-exit.test.ts b/tests/cli/services/configure-commander-exit.test.ts new file mode 100644 index 0000000..71d0a0a --- /dev/null +++ b/tests/cli/services/configure-commander-exit.test.ts @@ -0,0 +1,57 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureCommanderExit } from "../../../src/cli/services/configure-commander-exit.js"; + +describe("configureCommanderExit", () => { + let exitCode: number | undefined; + + beforeEach(() => { + exitCode = undefined; + + vi.spyOn(process, "exit").mockImplementation((code) => { + exitCode = code as number; + throw new Error(`process.exit:${code}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("maps parse errors to exit code 2", async () => { + const program = new Command().option("--known", "a known flag"); + configureCommanderExit(program); + + await expect( + program.parseAsync(["--unknown"], { from: "user" }) + ).rejects.toThrow("process.exit:2"); + + expect(exitCode).toBe(2); + }); + + it("maps help to exit code 0", async () => { + const program = new Command() + .name("test-cli") + .option("--known", "a known flag"); + configureCommanderExit(program); + + await expect( + program.parseAsync(["--help"], { from: "user" }) + ).rejects.toThrow("process.exit:0"); + + expect(exitCode).toBe(0); + }); + + it("maps subcommand parse errors to exit code 2", async () => { + const program = new Command().addCommand( + new Command("search").argument("", "search query") + ); + configureCommanderExit(program); + + await expect( + program.parseAsync(["search"], { from: "user" }) + ).rejects.toThrow("process.exit:2"); + + expect(exitCode).toBe(2); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index e90bd01..27f3849 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -5,28 +5,56 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; const rootDir = dirname(fileURLToPath(import.meta.url)); +const cliPath = join(rootDir, "..", "build", "esm", "index.js"); const packageJson = JSON.parse( readFileSync(join(rootDir, "..", "package.json"), "utf8") ) as { version: string }; +function runCli(args: string[]): { exitCode: number; stderr: string } { + try { + execFileSync(process.execPath, [cliPath, ...args], { encoding: "utf8" }); + return { exitCode: 0, stderr: "" }; + } catch (err) { + const execErr = err as { status?: number; stderr?: string }; + return { + exitCode: execErr.status ?? 1, + stderr: execErr.stderr ?? "", + }; + } +} + describe("cli", () => { it("prints the package version with --version", () => { - const output = execFileSync( - process.execPath, - [join(rootDir, "..", "build", "esm", "index.js"), "--version"], - { encoding: "utf8" } - ).trim(); + const output = execFileSync(process.execPath, [cliPath, "--version"], { + encoding: "utf8", + }).trim(); expect(output).toBe(packageJson.version); }); it("shows verbose flag in root help", () => { - const output = execFileSync( - process.execPath, - [join(rootDir, "..", "build", "esm", "index.js"), "--help"], - { encoding: "utf8" } - ); + const output = execFileSync(process.execPath, [cliPath, "--help"], { + encoding: "utf8", + }); expect(output).toContain("-v, --verbose"); }); + + it.each([ + ["unknown flag", ["--bad-flag"], 2], + ["unknown command", ["nosuchcmd"], 2], + ["missing required arg", ["search"], 2], + ["invalid choice", ["search", "q", "--engine", "yahoo"], 2], + ])("exits with code 2 on %s", (_label, args, expectedExit) => { + const { exitCode } = runCli(args); + expect(exitCode).toBe(expectedExit); + }); + + it.each([ + ["--version", 0], + ["--help", 0], + ])("exits with code 0 for %s", (flag, expectedExit) => { + const { exitCode } = runCli([flag]); + expect(exitCode).toBe(expectedExit); + }); });