Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { defineCommand, runMain } from "citty";
import { CliError } from "./lib/errors.js";
import { defineCommand } from "citty";
import { runCli } from "./lib/run.js";
import { VERSION } from "./lib/client.js";
import { stderr } from "./lib/output.js";

const main = defineCommand({
meta: {
Expand All @@ -24,11 +23,4 @@ const main = defineCommand({
},
});

runMain(main).catch((err: unknown) => {
if (err instanceof CliError) {
stderr(`Error: ${err.message}`);
process.exit(err.exitCode);
}
stderr(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});
void runCli(main);
47 changes: 47 additions & 0 deletions src/lib/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { runCommand, runMain, type CommandDef } from "citty";
import { CliError } from "./errors.js";
import { stderr } from "./output.js";

/**
* Entry point wrapper around citty.
*
* citty's `runMain` swallows every error itself: for anything that isn't its own
* usage error it prints the error WITH a stack trace, prints the message a second
* time, and always exits 1 — so a `CliError` printed twice with a stack and its
* `exitCode` was ignored (and the `.catch` after `runMain` never ran). We hand
* `--help` / `--version` to `runMain` (it renders those well) and run everything
* else through `runCommand` so we own error output.
*/
export async function runCli(
main: CommandDef,
rawArgs: string[] = process.argv.slice(2),
exit: (code: number) => void = (code) => process.exit(code)
): Promise<void> {
const wantsMeta =
rawArgs.includes("--help") ||
rawArgs.includes("-h") ||
(rawArgs.length === 1 && rawArgs[0] === "--version");
if (wantsMeta) {
await runMain(main, { rawArgs });
return;
}

try {
await runCommand(main, { rawArgs });
} catch (err) {
if (err instanceof CliError) {
stderr(`Error: ${err.message}`);
exit(err.exitCode);
return;
}
// citty's own usage errors (missing argument, unknown command) surface before
// any command body runs, so replaying through runMain is side-effect free and
// gets citty's usage rendering.
if (err instanceof Error && err.name === "CLIError") {
await runMain(main, { rawArgs });
return;
}
stderr(`Error: ${err instanceof Error ? err.message : String(err)}`);
exit(1);
}
}
85 changes: 85 additions & 0 deletions tests/lib/run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { defineCommand } from "citty";
import { runCli } from "../../src/lib/run.js";
import { CliError } from "../../src/lib/errors.js";

const makeMain = (run: () => unknown) =>
defineCommand({
meta: { name: "t", version: "1.2.3", description: "t" },
subCommands: {
boom: defineCommand({ meta: { name: "boom" }, run }),
needs: defineCommand({
meta: { name: "needs" },
args: { plan: { type: "positional", required: true } },
run() {},
}),
},
});

describe("runCli", () => {
let err = "";
let out = "";

beforeEach(() => {
err = "";
out = "";
vi.spyOn(process.stderr, "write").mockImplementation((s: any) => {
err += s;
return true;
});
vi.spyOn(process.stdout, "write").mockImplementation((s: any) => {
out += s;
return true;
});
});
afterEach(() => vi.restoreAllMocks());

it("prints a CliError once, without a stack trace, and exits with its exitCode", async () => {
const exit = vi.fn();
await runCli(
makeMain(() => {
throw new CliError("nope", 3);
}),
["boom"],
exit
);
expect(err).toBe("Error: nope\n");
expect(err).not.toContain(" at ");
expect(exit).toHaveBeenCalledOnce();
expect(exit).toHaveBeenCalledWith(3);
});

it("reports an unexpected error as one line and exits 1", async () => {
const exit = vi.fn();
await runCli(
makeMain(() => {
throw new TypeError("kaput");
}),
["boom"],
exit
);
expect(err).toBe("Error: kaput\n");
expect(exit).toHaveBeenCalledWith(1);
});

it("does not exit on success", async () => {
const exit = vi.fn();
await runCli(makeMain(() => {}), ["boom"], exit);
expect(exit).not.toHaveBeenCalled();
expect(err).toBe("");
});

it("does not re-run the command when it fails (no side-effect replay)", async () => {
const run = vi.fn(() => {
throw new CliError("x", 1);
});
await runCli(makeMain(run), ["boom"], vi.fn());
expect(run).toHaveBeenCalledTimes(1);
});

it("leaves citty's usage rendering for a missing argument", async () => {
const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
await runCli(makeMain(() => {}), ["needs"], vi.fn());
expect(exit).toHaveBeenCalledWith(1);
});
});
Loading