Skip to content

Commit b45b9db

Browse files
committed
fix(help): show help when user passes help as positional arg (#CLI-MN)
When users type `sentry log list help` (or similar) intending to see help, the bare word "help" was parsed as a project slug positional argument, causing confusing errors like "Project 'help' not found". Intercept `help` and `-h` in positional args inside the `buildCommand` wrapper so it applies to ALL commands. When detected, show the command's help via the existing introspection system (same output as `sentry help <command>`) and print a yellow tip about `--help`. Changes: - Store `commandPrefix` on `SentryContext` (set by Stricli's `forCommand` callback) so `buildCommand` knows which command is running - Add `maybeShowHelpAndExit()` in `buildCommand` that detects help tokens in positional args and renders help via `introspectCommand()` - Dynamic import of `help.js` avoids circular dependency Fixes CLI-MN
1 parent 131003b commit b45b9db

File tree

3 files changed

+229
-1
lines changed

3 files changed

+229
-1
lines changed

src/context.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export interface SentryContext extends CommandContext {
2020
readonly stdout: Writer;
2121
readonly stderr: Writer;
2222
readonly stdin: NodeJS.ReadStream & { fd: 0 };
23+
/**
24+
* Command path segments set by Stricli's `forCommand` callback.
25+
*
26+
* Contains the full prefix including the program name, e.g.,
27+
* `["sentry", "issue", "list"]`. Used by `buildCommand` to show
28+
* help when a user passes `help` as a positional argument.
29+
*/
30+
readonly commandPrefix?: readonly string[];
2331
}
2432

2533
/**
@@ -47,7 +55,7 @@ export function buildContext(process: NodeJS.Process, span?: Span) {
4755
...baseContext,
4856
forCommand: ({ prefix }: { prefix: readonly string[] }): SentryContext => {
4957
setCommandSpanName(span, prefix.join("."));
50-
return baseContext;
58+
return { ...baseContext, commandPrefix: prefix };
5159
},
5260
};
5361
}

src/lib/command.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
} from "@stricli/core";
3939
import type { Writer } from "../types/index.js";
4040
import { OutputError } from "./errors.js";
41+
import { warning } from "./formatters/colors.js";
4142
import { parseFieldsList } from "./formatters/json.js";
4243
import {
4344
ClearScreen,
@@ -393,6 +394,44 @@ export function buildCommand<
393394
}
394395
}
395396

397+
/**
398+
* Check if positional args contain a bare `help` or `-h` token and, if so,
399+
* display the command's help and exit.
400+
*
401+
* Users intuitively type `sentry issue list help` expecting help output.
402+
* Stricli only recognizes `--help`/`-h` as a flag during route scanning,
403+
* so bare `"help"` flows through as a positional and causes confusing
404+
* errors (e.g., "Project 'help' not found"). When detected, show the
405+
* command's help via the introspection system — same output as
406+
* `sentry help <command>`.
407+
*/
408+
async function maybeShowHelpAndExit(
409+
stdout: Writer,
410+
ctx: { commandPrefix?: readonly string[]; stderr: Writer },
411+
args: unknown[]
412+
): Promise<void> {
413+
if (args.length === 0 || !args.some((a) => a === "help" || a === "-h")) {
414+
return;
415+
}
416+
if (!ctx.commandPrefix) {
417+
return;
418+
}
419+
const pathSegments = ctx.commandPrefix.slice(1); // strip "sentry" prefix
420+
// Dynamic import to avoid circular: command.ts → help.ts → app.ts → commands → command.ts
421+
const { introspectCommand, formatHelpHuman } = await import("./help.js");
422+
const result = introspectCommand(pathSegments);
423+
if ("error" in result) {
424+
return;
425+
}
426+
ctx.stderr.write(
427+
warning(
428+
`Tip: use --help for help (e.g., sentry ${pathSegments.join(" ")} --help)\n\n`
429+
)
430+
);
431+
stdout.write(`${formatHelpHuman(result)}\n`);
432+
process.exit(0);
433+
}
434+
396435
// Wrap func to intercept logging flags, capture telemetry, then call original.
397436
// The wrapper is an async function that iterates the generator returned by func.
398437
const wrappedFunc = async function (
@@ -413,6 +452,15 @@ export function buildCommand<
413452

414453
const stdout = (this as unknown as { stdout: Writer }).stdout;
415454

455+
await maybeShowHelpAndExit(
456+
stdout,
457+
this as unknown as {
458+
commandPrefix?: readonly string[];
459+
stderr: Writer;
460+
},
461+
args
462+
);
463+
416464
// Reset per-invocation state
417465
pendingClear = false;
418466

test/lib/help-positional.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Tests for help-as-positional-arg interception in buildCommand.
3+
*
4+
* When users type `sentry issue list help` (bare `help` as a positional
5+
* argument), the buildCommand wrapper detects it and shows the command's
6+
* help via the introspection system instead of treating it as a target.
7+
*
8+
* These tests run commands through Stricli's `run()` with `help` as a
9+
* trailing positional and verify the output matches the introspection
10+
* system's help text.
11+
*/
12+
13+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
14+
import { run } from "@stricli/core";
15+
import { app } from "../../src/app.js";
16+
import type { SentryContext } from "../../src/context.js";
17+
import { useTestConfigDir } from "../helpers.js";
18+
19+
useTestConfigDir("help-positional-");
20+
21+
/** Captured output from a command run */
22+
type CapturedOutput = {
23+
stdout: string;
24+
stderr: string;
25+
};
26+
27+
/** Original process.exit before mocking */
28+
let originalExit: typeof process.exit;
29+
30+
/** Whether process.exit was called */
31+
let exitCalled: { code: number | undefined } | undefined;
32+
33+
beforeEach(() => {
34+
exitCalled = undefined;
35+
originalExit = process.exit;
36+
// Mock process.exit to prevent actually exiting during tests.
37+
// Throws to stop execution (like the real process.exit would).
38+
process.exit = mock((code?: number) => {
39+
exitCalled = { code: code ?? 0 };
40+
throw new Error(`process.exit(${code})`);
41+
}) as typeof process.exit;
42+
});
43+
44+
afterEach(() => {
45+
process.exit = originalExit;
46+
});
47+
48+
/**
49+
* Run a command through Stricli and capture stdout/stderr.
50+
*
51+
* Since the help interception calls `process.exit(0)`, this catches
52+
* the mock exit error and returns the captured output.
53+
*/
54+
async function runCommand(args: string[]): Promise<CapturedOutput> {
55+
let stdout = "";
56+
let stderr = "";
57+
58+
const stdoutWriter = {
59+
write(data: string | Uint8Array) {
60+
stdout +=
61+
typeof data === "string" ? data : new TextDecoder().decode(data);
62+
return true;
63+
},
64+
};
65+
const stderrWriter = {
66+
write(data: string | Uint8Array) {
67+
stderr +=
68+
typeof data === "string" ? data : new TextDecoder().decode(data);
69+
return true;
70+
},
71+
};
72+
73+
const baseContext: SentryContext = {
74+
process,
75+
env: process.env,
76+
cwd: process.cwd(),
77+
homeDir: "/tmp",
78+
configDir: "/tmp",
79+
stdout: stdoutWriter,
80+
stderr: stderrWriter,
81+
stdin: process.stdin,
82+
};
83+
84+
// Stricli calls forCommand({ prefix }) before running the command.
85+
// We must provide it so commandPrefix is set on the context.
86+
const mockContext = {
87+
...baseContext,
88+
forCommand: ({ prefix }: { prefix: readonly string[] }): SentryContext => ({
89+
...baseContext,
90+
commandPrefix: prefix,
91+
}),
92+
};
93+
94+
try {
95+
await run(app, args, mockContext);
96+
} catch {
97+
// process.exit mock throws — expected
98+
}
99+
100+
return { stdout, stderr };
101+
}
102+
103+
describe("help as positional argument", () => {
104+
test("sentry issue list help → shows help for issue list", async () => {
105+
const { stdout, stderr } = await runCommand(["issue", "list", "help"]);
106+
107+
// Should show command help output (from introspection)
108+
expect(stdout).toContain("sentry issue list");
109+
// Should show the tip about --help
110+
expect(stderr).toContain("--help");
111+
expect(stderr).toContain("Tip");
112+
// Should exit with code 0
113+
expect(exitCalled).toEqual({ code: 0 });
114+
});
115+
116+
test("sentry log list help → shows help for log list", async () => {
117+
const { stdout, stderr } = await runCommand(["log", "list", "help"]);
118+
119+
expect(stdout).toContain("sentry log list");
120+
expect(stderr).toContain("--help");
121+
expect(exitCalled).toEqual({ code: 0 });
122+
});
123+
124+
test("sentry trace view help → shows help for trace view", async () => {
125+
const { stdout, stderr } = await runCommand(["trace", "view", "help"]);
126+
127+
expect(stdout).toContain("sentry trace view");
128+
expect(stderr).toContain("--help");
129+
expect(exitCalled).toEqual({ code: 0 });
130+
});
131+
132+
test("sentry project list help → shows help for project list", async () => {
133+
const { stdout, stderr } = await runCommand(["project", "list", "help"]);
134+
135+
expect(stdout).toContain("sentry project list");
136+
expect(stderr).toContain("--help");
137+
expect(exitCalled).toEqual({ code: 0 });
138+
});
139+
140+
test("sentry issue view help → shows help for issue view", async () => {
141+
const { stdout, stderr } = await runCommand(["issue", "view", "help"]);
142+
143+
expect(stdout).toContain("sentry issue view");
144+
expect(stderr).toContain("--help");
145+
expect(exitCalled).toEqual({ code: 0 });
146+
});
147+
148+
test("stderr hint includes the correct command path", async () => {
149+
const { stderr } = await runCommand(["span", "list", "help"]);
150+
151+
expect(stderr).toContain("sentry span list --help");
152+
});
153+
});
154+
155+
describe("help command unchanged", () => {
156+
test("sentry help still shows branded help", async () => {
157+
const { stdout } = await runCommand(["help"]);
158+
159+
// Custom help command shows branded output
160+
expect(stdout).toContain("sentry");
161+
// Should NOT have exited via our interception
162+
expect(exitCalled).toBeUndefined();
163+
});
164+
165+
test("sentry help issue list still shows introspected help", async () => {
166+
const { stdout } = await runCommand(["help", "issue", "list"]);
167+
168+
expect(stdout).toContain("sentry issue list");
169+
// Should NOT have exited via our interception
170+
expect(exitCalled).toBeUndefined();
171+
});
172+
});

0 commit comments

Comments
 (0)