Skip to content

Commit a045c50

Browse files
committed
fix: address bot review feedback
- Replace process.exit() in OutputError handler with throw + Stricli intercept in app.ts (BugBot: kills host process in library mode) - Wrap withTelemetry in try/catch in sentry() to convert all internal errors (AuthError, OutputError, etc.) to SentryError (Seer + BugBot) - Set process.exitCode=1 in bin wrapper catch handler (BugBot)
1 parent c8c1f72 commit a045c50

File tree

7 files changed

+92
-108
lines changed

7 files changed

+92
-108
lines changed

script/bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ const result = await build({
207207
// Write the CLI bin wrapper (tiny — shebang + version check + dispatch)
208208
const BIN_WRAPPER = `#!/usr/bin/env node
209209
if(parseInt(process.versions.node)<22){console.error("Error: sentry requires Node.js 22 or later (found "+process.version+").\\n\\nEither upgrade Node.js, or install the standalone binary instead:\\n curl -fsSL https://cli.sentry.dev/install | bash\\n");process.exit(1)}
210-
require('./index.cjs')._cli().catch(()=>{});
210+
require('./index.cjs')._cli().catch(()=>{process.exitCode=1});
211211
`;
212212
await Bun.write("./dist/bin.cjs", BIN_WRAPPER);
213213

src/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
AuthError,
4242
CliError,
4343
getExitCode,
44+
OutputError,
4445
stringifyUnknown,
4546
} from "./lib/errors.js";
4647
import { error as errorColor, warning } from "./lib/formatters/colors.js";
@@ -151,6 +152,12 @@ const customText: ApplicationText = {
151152
return text_en.exceptionWhileParsingArguments(exc, ansiColor);
152153
},
153154
exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => {
155+
// OutputError: data was already rendered to stdout — just re-throw
156+
// so the exit code propagates without Stricli printing an error message.
157+
if (exc instanceof OutputError) {
158+
throw exc;
159+
}
160+
154161
// Re-throw AuthError for auto-login flow in bin.ts
155162
// Don't capture to Sentry - it's an expected state (user not logged in or token expired), not an error
156163
// Note: skipAutoAuth is checked in bin.ts, not here — all auth errors must escape Sentry capture

src/cli.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
112112
const { ExitCode, run } = await import("@stricli/core");
113113
const { app } = await import("./app.js");
114114
const { buildContext } = await import("./context.js");
115-
const { AuthError, formatError, getExitCode } = await import(
115+
const { AuthError, OutputError, formatError, getExitCode } = await import(
116116
"./lib/errors.js"
117117
);
118118
const { error, warning } = await import("./lib/formatters/colors.js");
@@ -372,6 +372,12 @@ export async function runCli(cliArgs: string[]): Promise<void> {
372372
await executor(helpArgs);
373373
}
374374
} catch (err) {
375+
// OutputError: data was already rendered to stdout by the command wrapper.
376+
// Just set exitCode silently — no stderr message needed.
377+
if (err instanceof OutputError) {
378+
process.exitCode = err.exitCode;
379+
return;
380+
}
375381
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
376382
process.exitCode = getExitCode(err);
377383
return;

src/index.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export class SentryError extends Error {
9898
*/
9999
function sentry(...input: string[]): Promise<unknown>;
100100
function sentry(...input: [...string[], SentryOptions]): Promise<unknown>;
101+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: library entry orchestrates env isolation, output capture, telemetry, and error conversion in one function
101102
async function sentry(...input: (string | SentryOptions)[]): Promise<unknown> {
102103
// Detect trailing options object
103104
let args: string[];
@@ -157,11 +158,25 @@ async function sentry(...input: (string | SentryOptions)[]): Promise<unknown> {
157158
const { buildContext } = await import("./context.js");
158159
const { withTelemetry } = await import("./lib/telemetry.js");
159160

160-
await withTelemetry(
161-
// biome-ignore lint/suspicious/noExplicitAny: fakeProcess duck-types the process interface
162-
async (span) => run(app, args, buildContext(fakeProcess as any, span)),
163-
{ libraryMode: true }
164-
);
161+
try {
162+
await withTelemetry(
163+
// biome-ignore lint/suspicious/noExplicitAny: fakeProcess duck-types the process interface
164+
async (span) => run(app, args, buildContext(fakeProcess as any, span)),
165+
{ libraryMode: true }
166+
);
167+
} catch (thrown) {
168+
// Stricli catches command errors and writes them to stderr + sets exitCode.
169+
// But some errors (AuthError, OutputError) are re-thrown through Stricli.
170+
// Convert any that escape into SentryError for a consistent library API.
171+
const stderrStr = stderrChunks.join("");
172+
const exitCode = fakeProcess.exitCode !== 0 ? fakeProcess.exitCode : 1;
173+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences use ESC (0x1b)
174+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
175+
const message =
176+
stderrStr.replace(ANSI_RE, "").trim() ||
177+
(thrown instanceof Error ? thrown.message : String(thrown));
178+
throw new SentryError(message, exitCode, stderrStr);
179+
}
165180

166181
// Flush telemetry (no beforeExit handler in library mode)
167182
try {

src/lib/command.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -483,10 +483,10 @@ export function buildCommand<
483483
: undefined;
484484

485485
// OutputError handler: render data through the output system, then
486-
// exit with the error's code. Stricli overwrites process.exitCode = 0
487-
// after successful returns, so process.exit() is the only way to
488-
// preserve a non-zero code. This lives in the framework — commands
489-
// simply `throw new OutputError(data)`.
486+
// re-throw so the exit code propagates. Stricli's
487+
// exceptionWhileRunningCommand intercepts OutputError and re-throws
488+
// it without formatting, so both bin.ts and index.ts can set
489+
// exitCode from the caught error.
490490
const handleOutputError = (err: unknown): never => {
491491
if (err instanceof OutputError && outputConfig) {
492492
// Only render if there's actual data to show
@@ -498,7 +498,7 @@ export function buildCommand<
498498
renderer
499499
);
500500
}
501-
process.exit(err.exitCode);
501+
throw err;
502502
}
503503
throw err;
504504
};

test/commands/cli/fix.test.ts

Lines changed: 14 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,15 @@
33
*
44
* Output goes through the structured CommandOutput/OutputError path, which
55
* renders via the `output` config (stdout). For failure paths, `OutputError`
6-
* triggers `process.exit()` — tests mock it to capture the exit code.
6+
* is thrown — tests catch it and check the exitCode property.
77
*
88
* Tests run non-TTY so plain mode applies: markdown is parsed and ANSI
99
* stripped. Headings render without `###`, underscores are unescaped,
1010
* and code spans have backticks stripped.
1111
*/
1212

1313
import { Database } from "bun:sqlite";
14-
import {
15-
afterEach,
16-
beforeEach,
17-
describe,
18-
expect,
19-
mock,
20-
spyOn,
21-
test,
22-
} from "bun:test";
14+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
2315
import { chmodSync, statSync } from "node:fs";
2416
import { join } from "node:path";
2517
import { fixCommand } from "../../../src/commands/cli/fix.js";
@@ -29,6 +21,7 @@ import {
2921
generatePreMigrationTableDDL,
3022
initSchema,
3123
} from "../../../src/lib/db/schema.js";
24+
import { OutputError } from "../../../src/lib/errors.js";
3225
import { useTestConfigDir } from "../../helpers.js";
3326

3427
/**
@@ -77,24 +70,11 @@ function createDatabaseWithMissingTables(
7770
).run();
7871
}
7972

80-
/**
81-
* Thrown by the mock `process.exit()` to halt execution without actually
82-
* exiting the process. The `code` field captures the requested exit code.
83-
*/
84-
class MockExitError extends Error {
85-
code: number;
86-
constructor(code: number) {
87-
super(`process.exit(${code})`);
88-
this.code = code;
89-
}
90-
}
91-
9273
/**
9374
* Create a mock Stricli context that captures stdout output.
9475
*
9576
* The `buildCommand` wrapper renders structured output to `context.stdout`.
96-
* For failure paths (OutputError), it calls `process.exit()` — the mock
97-
* intercepts this and throws MockExitError to halt execution.
77+
* For failure paths, `OutputError` is thrown after data is rendered.
9878
*/
9979
function createContext() {
10080
const stdoutChunks: string[] = [];
@@ -120,30 +100,24 @@ const getTestDir = useTestConfigDir("fix-test-");
120100

121101
/**
122102
* Run the fix command with the given flags and return captured output.
123-
* Mocks `process.exit()` so OutputError paths don't terminate the test.
103+
* Catches `OutputError` to extract the exit code (data is already rendered
104+
* to stdout before the throw).
124105
*/
125106
async function runFix(dryRun: boolean) {
126107
const { context, getOutput } = createContext();
127108

128109
let exitCode = 0;
129-
const originalExit = process.exit;
130-
process.exit = ((code?: number) => {
131-
exitCode = code ?? 0;
132-
throw new MockExitError(code ?? 0);
133-
}) as typeof process.exit;
134110

135111
try {
136112
const func = await fixCommand.loader();
137113
await func.call(context, { "dry-run": dryRun, json: false });
138114
// Successful return — exitCode stays 0
139115
} catch (err) {
140-
if (err instanceof MockExitError) {
141-
exitCode = err.code;
116+
if (err instanceof OutputError) {
117+
exitCode = err.exitCode;
142118
} else {
143119
throw err;
144120
}
145-
} finally {
146-
process.exit = originalExit;
147121
}
148122

149123
return {
@@ -491,25 +465,7 @@ describe("sentry cli fix", () => {
491465
describe("sentry cli fix — ownership detection", () => {
492466
const getOwnershipTestDir = useTestConfigDir("fix-ownership-test-");
493467

494-
let exitMock: { restore: () => void; exitCode: number };
495-
496-
beforeEach(() => {
497-
const originalExit = process.exit;
498-
const state = {
499-
exitCode: 0,
500-
restore: () => {
501-
process.exit = originalExit;
502-
},
503-
};
504-
process.exit = ((code?: number) => {
505-
state.exitCode = code ?? 0;
506-
throw new MockExitError(code ?? 0);
507-
}) as typeof process.exit;
508-
exitMock = state;
509-
});
510-
511468
afterEach(() => {
512-
exitMock.restore();
513469
closeDatabase();
514470
});
515471

@@ -521,13 +477,16 @@ describe("sentry cli fix — ownership detection", () => {
521477
async function runFixWithUid(dryRun: boolean, getuid: () => number) {
522478
const { context, getOutput } = createContext();
523479
const getuidSpy = spyOn(process, "getuid").mockImplementation(getuid);
524-
exitMock.exitCode = 0;
480+
481+
let exitCode = 0;
525482

526483
try {
527484
const func = await fixCommand.loader();
528485
await func.call(context, { "dry-run": dryRun, json: false });
529486
} catch (err) {
530-
if (!(err instanceof MockExitError)) {
487+
if (err instanceof OutputError) {
488+
exitCode = err.exitCode;
489+
} else {
531490
throw err;
532491
}
533492
} finally {
@@ -536,7 +495,7 @@ describe("sentry cli fix — ownership detection", () => {
536495

537496
return {
538497
output: getOutput(),
539-
exitCode: exitMock.exitCode,
498+
exitCode,
540499
};
541500
}
542501

test/lib/command.test.ts

Lines changed: 38 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
buildRouteMap,
1515
type CommandContext,
1616
run,
17+
text_en,
1718
} from "@stricli/core";
1819
import {
1920
applyLoggingFlags,
@@ -1278,9 +1279,6 @@ describe("buildCommand return-based output", () => {
12781279
});
12791280

12801281
test("OutputError renders data and exits with error code", async () => {
1281-
let exitCalledWith: number | undefined;
1282-
const originalExit = process.exit;
1283-
12841282
const command = buildCommand<
12851283
{ json: boolean; fields?: string[] },
12861284
[],
@@ -1301,37 +1299,34 @@ describe("buildCommand return-based output", () => {
13011299
routes: { test: command },
13021300
docs: { brief: "Test app" },
13031301
});
1304-
const app = buildApplication(routeMap, { name: "test" });
1302+
// Re-throw OutputError from Stricli's error handler (matches production app.ts)
1303+
const app = buildApplication(routeMap, {
1304+
name: "test",
1305+
localization: {
1306+
loadText: () => ({
1307+
...text_en,
1308+
exceptionWhileRunningCommand: (exc: unknown) => {
1309+
if (exc instanceof OutputError) throw exc;
1310+
return text_en.exceptionWhileRunningCommand(exc, false);
1311+
},
1312+
}),
1313+
},
1314+
});
13051315
const ctx = createTestContext();
13061316

1307-
// Mock process.exit — must throw to prevent fall-through since
1308-
// the real process.exit() is typed as `never`
1309-
class MockExit extends Error {
1310-
code: number;
1311-
constructor(code: number) {
1312-
super(`process.exit(${code})`);
1313-
this.code = code;
1314-
}
1315-
}
1316-
process.exit = ((code?: number) => {
1317-
exitCalledWith = code;
1318-
throw new MockExit(code ?? 0);
1319-
}) as typeof process.exit;
1320-
1317+
// OutputError is now thrown (not process.exit) — catch it
13211318
try {
13221319
await run(app, ["test"], ctx as TestContext);
1323-
} finally {
1324-
process.exit = originalExit;
1320+
expect.unreachable("Expected OutputError to be thrown");
1321+
} catch (err) {
1322+
expect(err).toBeInstanceOf(OutputError);
1323+
expect((err as OutputError).exitCode).toBe(1);
13251324
}
1326-
expect(exitCalledWith).toBe(1);
1327-
// Output was rendered BEFORE exit
1325+
// Output was rendered BEFORE the throw
13281326
expect(ctx.output.join("")).toContain("Error: not found");
13291327
});
13301328

13311329
test("OutputError renders JSON in --json mode", async () => {
1332-
let exitCalledWith: number | undefined;
1333-
const originalExit = process.exit;
1334-
13351330
const command = buildCommand<
13361331
{ json: boolean; fields?: string[] },
13371332
[],
@@ -1352,27 +1347,29 @@ describe("buildCommand return-based output", () => {
13521347
routes: { test: command },
13531348
docs: { brief: "Test app" },
13541349
});
1355-
const app = buildApplication(routeMap, { name: "test" });
1350+
// Re-throw OutputError from Stricli's error handler (matches production app.ts)
1351+
const app = buildApplication(routeMap, {
1352+
name: "test",
1353+
localization: {
1354+
loadText: () => ({
1355+
...text_en,
1356+
exceptionWhileRunningCommand: (exc: unknown) => {
1357+
if (exc instanceof OutputError) throw exc;
1358+
return text_en.exceptionWhileRunningCommand(exc, false);
1359+
},
1360+
}),
1361+
},
1362+
});
13561363
const ctx = createTestContext();
13571364

1358-
class MockExit extends Error {
1359-
code: number;
1360-
constructor(code: number) {
1361-
super(`process.exit(${code})`);
1362-
this.code = code;
1363-
}
1364-
}
1365-
process.exit = ((code?: number) => {
1366-
exitCalledWith = code;
1367-
throw new MockExit(code ?? 0);
1368-
}) as typeof process.exit;
1369-
1365+
// OutputError is now thrown (not process.exit) — catch it
13701366
try {
13711367
await run(app, ["test", "--json"], ctx as TestContext);
1372-
} finally {
1373-
process.exit = originalExit;
1368+
expect.unreachable("Expected OutputError to be thrown");
1369+
} catch (err) {
1370+
expect(err).toBeInstanceOf(OutputError);
1371+
expect((err as OutputError).exitCode).toBe(1);
13741372
}
1375-
expect(exitCalledWith).toBe(1);
13761373
const jsonOutput = JSON.parse(ctx.output.join(""));
13771374
expect(jsonOutput).toEqual({ error: "not found" });
13781375
});

0 commit comments

Comments
 (0)