Skip to content

Commit 1cabf73

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 1cabf73

File tree

3 files changed

+243
-2
lines changed

3 files changed

+243
-2
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: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import {
3737
numberParser as stricliNumberParser,
3838
} from "@stricli/core";
3939
import type { Writer } from "../types/index.js";
40-
import { OutputError } from "./errors.js";
40+
import { CliError, 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,56 @@ export function buildCommand<
393394
}
394395
}
395396

397+
/**
398+
* When a command throws a {@link CliError} and a positional arg was
399+
* `"help"`, the user likely intended `--help`. Show the command's
400+
* help instead of the confusing error.
401+
*
402+
* Only fires as **error recovery** — if the command succeeds with a
403+
* legitimate value like a project named "help", this never runs.
404+
*
405+
* Catches all {@link CliError} subtypes (AuthError, ResolutionError,
406+
* ValidationError, ContextError, etc.) because any failure with "help"
407+
* as input strongly signals the user wanted `--help`. For example,
408+
* `sentry issue list help` may throw AuthError (not logged in) before
409+
* ever reaching project resolution.
410+
*
411+
* Note: `-h` is NOT checked here because Stricli intercepts `-h` as
412+
* a help flag during route scanning, before `func` is ever called.
413+
*
414+
* @returns `true` if help was shown and the error was recovered
415+
*/
416+
async function maybeRecoverWithHelp(
417+
err: unknown,
418+
stdout: Writer,
419+
ctx: { commandPrefix?: readonly string[]; stderr: Writer },
420+
args: unknown[]
421+
): Promise<boolean> {
422+
if (!(err instanceof CliError)) {
423+
return false;
424+
}
425+
if (args.length === 0 || !args.some((a) => a === "help")) {
426+
return false;
427+
}
428+
if (!ctx.commandPrefix) {
429+
return false;
430+
}
431+
const pathSegments = ctx.commandPrefix.slice(1); // strip "sentry" prefix
432+
// Dynamic import to avoid circular: command.ts → help.ts → app.ts → commands → command.ts
433+
const { introspectCommand, formatHelpHuman } = await import("./help.js");
434+
const result = introspectCommand(pathSegments);
435+
if ("error" in result) {
436+
return false;
437+
}
438+
ctx.stderr.write(
439+
warning(
440+
`Tip: use --help for help (e.g., sentry ${pathSegments.join(" ")} --help)\n\n`
441+
)
442+
);
443+
stdout.write(`${formatHelpHuman(result)}\n`);
444+
return true;
445+
}
446+
396447
// Wrap func to intercept logging flags, capture telemetry, then call original.
397448
// The wrapper is an async function that iterates the generator returned by func.
398449
const wrappedFunc = async function (
@@ -468,6 +519,23 @@ export function buildCommand<
468519
if (!cleanFlags.json) {
469520
writeFinalization(stdout, undefined, false, renderer);
470521
}
522+
523+
// If a positional arg was "help" and the command failed with a
524+
// resolution/validation error, the user likely meant --help.
525+
// Show help as recovery instead of the confusing error.
526+
const recovered = await maybeRecoverWithHelp(
527+
err,
528+
stdout,
529+
this as unknown as {
530+
commandPrefix?: readonly string[];
531+
stderr: Writer;
532+
},
533+
args
534+
);
535+
if (recovered) {
536+
return;
537+
}
538+
471539
handleOutputError(err);
472540
}
473541
};

test/lib/help-positional.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* Tests for help-as-positional-arg error recovery in buildCommand.
3+
*
4+
* When a command throws a CliError and a positional arg was `"help"`,
5+
* the buildCommand wrapper recovers by showing the command's help
6+
* instead of the confusing error.
7+
*
8+
* This only fires as error recovery — if a command successfully resolves
9+
* a legitimate value like a project named "help", the recovery never runs.
10+
*
11+
* Note: `-h` is NOT covered here because Stricli intercepts it as a help
12+
* flag during route scanning, before the command's func is ever called.
13+
*
14+
* Tests run commands through Stricli's `run()` with `help` as a positional
15+
* and verify help output is shown when resolution fails.
16+
*/
17+
18+
import { describe, expect, test } from "bun:test";
19+
import { run } from "@stricli/core";
20+
import { app } from "../../src/app.js";
21+
import type { SentryContext } from "../../src/context.js";
22+
import { useTestConfigDir } from "../helpers.js";
23+
24+
useTestConfigDir("help-positional-");
25+
26+
/** Captured output from a command run */
27+
type CapturedOutput = {
28+
stdout: string;
29+
stderr: string;
30+
};
31+
32+
/**
33+
* Build a mock context with forCommand support.
34+
*
35+
* Stricli calls `forCommand({ prefix })` before running the command.
36+
* We must provide it so `commandPrefix` is set on the context, enabling
37+
* the help recovery logic in `buildCommand`.
38+
*/
39+
function buildMockContext(captured: {
40+
stdout: string;
41+
stderr: string;
42+
}): SentryContext & {
43+
forCommand: (opts: { prefix: readonly string[] }) => SentryContext;
44+
} {
45+
const stdoutWriter = {
46+
write(data: string | Uint8Array) {
47+
captured.stdout +=
48+
typeof data === "string" ? data : new TextDecoder().decode(data);
49+
return true;
50+
},
51+
};
52+
const stderrWriter = {
53+
write(data: string | Uint8Array) {
54+
captured.stderr +=
55+
typeof data === "string" ? data : new TextDecoder().decode(data);
56+
return true;
57+
},
58+
};
59+
60+
const baseContext: SentryContext = {
61+
process,
62+
env: process.env,
63+
cwd: process.cwd(),
64+
homeDir: "/tmp",
65+
configDir: "/tmp",
66+
stdout: stdoutWriter,
67+
stderr: stderrWriter,
68+
stdin: process.stdin,
69+
};
70+
71+
return {
72+
...baseContext,
73+
forCommand: ({ prefix }: { prefix: readonly string[] }): SentryContext => ({
74+
...baseContext,
75+
commandPrefix: prefix,
76+
}),
77+
};
78+
}
79+
80+
/**
81+
* Run a command through Stricli and capture stdout/stderr.
82+
*
83+
* Commands that hit resolution errors with "help" as a positional arg
84+
* will be recovered by the buildCommand wrapper, which shows help output
85+
* instead of the error.
86+
*/
87+
async function runCommand(args: string[]): Promise<CapturedOutput> {
88+
const captured = { stdout: "", stderr: "" };
89+
const mockContext = buildMockContext(captured);
90+
91+
try {
92+
await run(app, args, mockContext);
93+
} catch {
94+
// Some commands may still throw (e.g., uncaught errors)
95+
}
96+
97+
return captured;
98+
}
99+
100+
describe("help recovery on ResolutionError", () => {
101+
test("sentry issue list help → shows help for issue list", async () => {
102+
// "help" is treated as a project slug, fails resolution → recovery shows help
103+
const { stdout, stderr } = await runCommand(["issue", "list", "help"]);
104+
105+
expect(stdout).toContain("sentry issue list");
106+
expect(stderr).toContain("--help");
107+
expect(stderr).toContain("Tip");
108+
});
109+
110+
test("sentry project list help → shows help for project list", async () => {
111+
const { stdout, stderr } = await runCommand(["project", "list", "help"]);
112+
113+
expect(stdout).toContain("sentry project list");
114+
expect(stderr).toContain("--help");
115+
});
116+
117+
test("sentry span list help → shows help for span list", async () => {
118+
const { stdout, stderr } = await runCommand(["span", "list", "help"]);
119+
120+
expect(stdout).toContain("sentry span list");
121+
expect(stderr).toContain("--help");
122+
});
123+
124+
test("stderr hint includes the correct command path", async () => {
125+
const { stderr } = await runCommand(["issue", "list", "help"]);
126+
127+
expect(stderr).toContain("sentry issue list --help");
128+
});
129+
});
130+
131+
describe("help recovery on ValidationError", () => {
132+
test("sentry trace view help → shows help for trace view", async () => {
133+
// "help" fails hex ID validation → ValidationError → recovery shows help
134+
const { stdout, stderr } = await runCommand(["trace", "view", "help"]);
135+
136+
expect(stdout).toContain("sentry trace view");
137+
expect(stderr).toContain("--help");
138+
});
139+
140+
test("sentry log view help → shows help for log view", async () => {
141+
const { stdout, stderr } = await runCommand(["log", "view", "help"]);
142+
143+
expect(stdout).toContain("sentry log view");
144+
expect(stderr).toContain("--help");
145+
});
146+
});
147+
148+
describe("help command unchanged", () => {
149+
test("sentry help still shows branded help", async () => {
150+
const { stdout, stderr } = await runCommand(["help"]);
151+
152+
// Custom help command shows branded output — no recovery needed
153+
expect(stdout).toContain("sentry");
154+
// Should NOT have the recovery tip
155+
expect(stderr).not.toContain("Tip");
156+
});
157+
158+
test("sentry help issue list still shows introspected help", async () => {
159+
const { stdout, stderr } = await runCommand(["help", "issue", "list"]);
160+
161+
expect(stdout).toContain("sentry issue list");
162+
// Should NOT have the recovery tip — this is the normal help path
163+
expect(stderr).not.toContain("Tip");
164+
});
165+
});

0 commit comments

Comments
 (0)