Skip to content

Commit 1a0f45f

Browse files
committed
fix: proxy subcommand names from plural aliases instead of misinterpreting as targets
When `sentry projects list` passes "list" as a positional arg to the project list command, it was interpreted as a project slug search, producing confusing "No project 'list' found" errors. Added `buildListCommand(routeName, config)` — a drop-in replacement for `buildCommand` that wraps the command func to intercept subcommand names passed through plural aliases. The interception is transparent: the original func receives `undefined` instead of the subcommand name, and a command-specific hint is printed to stderr: Tip: "list" is a subcommand. Running: sentry project list Each list command migrates with a one-line change: buildCommand({...}) → buildListCommand("project", {...}) `buildOrgListCommand` (team, repo) now delegates to `buildListCommand` internally, so the interception is automatic for factory-built commands. Subcommand names are dynamically extracted per-route from the Stricli route map at runtime — no hardcoded lists. Fixes CLI-7G
1 parent 1e7b397 commit 1a0f45f

File tree

8 files changed

+260
-16
lines changed

8 files changed

+260
-16
lines changed

src/commands/issue/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
listProjects,
1717
} from "../../lib/api-client.js";
1818
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
19-
import { buildCommand } from "../../lib/command.js";
2019
import {
2120
clearPaginationCursor,
2221
escapeContextKeyValue,
@@ -43,6 +42,7 @@ import {
4342
writeJson,
4443
} from "../../lib/formatters/index.js";
4544
import {
45+
buildListCommand,
4646
buildListLimitFlag,
4747
LIST_BASE_ALIASES,
4848
LIST_JSON_FLAG,
@@ -700,7 +700,7 @@ const issueListMeta: ListCommandMeta = {
700700
commandPrefix: "sentry issue list",
701701
};
702702

703-
export const listCommand = buildCommand({
703+
export const listCommand = buildListCommand("issue", {
704704
docs: {
705705
brief: "List issues in a project",
706706
fullDescription:

src/commands/log/list.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@ import * as Sentry from "@sentry/bun";
1010
import type { SentryContext } from "../../context.js";
1111
import { listLogs } from "../../lib/api-client.js";
1212
import { validateLimit } from "../../lib/arg-parsing.js";
13-
import { buildCommand } from "../../lib/command.js";
1413
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
1514
import {
1615
formatLogRow,
1716
formatLogsHeader,
1817
writeFooter,
1918
writeJson,
2019
} from "../../lib/formatters/index.js";
21-
import { TARGET_PATTERN_NOTE } from "../../lib/list-command.js";
20+
import {
21+
buildListCommand,
22+
TARGET_PATTERN_NOTE,
23+
} from "../../lib/list-command.js";
2224
import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js";
2325
import { getUpdateNotification } from "../../lib/version-check.js";
2426
import type { SentryLog, Writer } from "../../types/index.js";
@@ -228,7 +230,7 @@ async function executeFollowMode(options: FollowModeOptions): Promise<void> {
228230
}
229231
}
230232

231-
export const listCommand = buildCommand({
233+
export const listCommand = buildListCommand("log", {
232234
docs: {
233235
brief: "List logs from a project",
234236
fullDescription:

src/commands/project/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
type ParsedOrgProject,
2424
parseOrgProjectArg,
2525
} from "../../lib/arg-parsing.js";
26-
import { buildCommand } from "../../lib/command.js";
2726
import { getDefaultOrganization } from "../../lib/db/defaults.js";
2827
import {
2928
clearPaginationCursor,
@@ -39,6 +38,7 @@ import {
3938
writeJson,
4039
} from "../../lib/formatters/index.js";
4140
import {
41+
buildListCommand,
4242
buildListLimitFlag,
4343
LIST_BASE_ALIASES,
4444
LIST_CURSOR_FLAG,
@@ -603,7 +603,7 @@ const projectListMeta: ListCommandMeta = {
603603
commandPrefix: "sentry project list",
604604
};
605605

606-
export const listCommand = buildCommand({
606+
export const listCommand = buildListCommand("project", {
607607
docs: {
608608
brief: "List projects",
609609
fullDescription:

src/commands/repo/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,4 @@ const docs: OrgListCommandDocs = {
6868
" sentry repo list --json",
6969
};
7070

71-
export const listCommand = buildOrgListCommand(repoListConfig, docs);
71+
export const listCommand = buildOrgListCommand(repoListConfig, docs, "repo");

src/commands/team/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,4 @@ const docs: OrgListCommandDocs = {
7474
" sentry team list --json",
7575
};
7676

77-
export const listCommand = buildOrgListCommand(teamListConfig, docs);
77+
export const listCommand = buildOrgListCommand(teamListConfig, docs, "team");

src/commands/trace/list.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77
import type { SentryContext } from "../../context.js";
88
import { listTransactions } from "../../lib/api-client.js";
99
import { validateLimit } from "../../lib/arg-parsing.js";
10-
import { buildCommand } from "../../lib/command.js";
1110
import {
1211
formatTraceRow,
1312
formatTracesHeader,
1413
writeFooter,
1514
writeJson,
1615
} from "../../lib/formatters/index.js";
17-
import { TARGET_PATTERN_NOTE } from "../../lib/list-command.js";
16+
import {
17+
buildListCommand,
18+
TARGET_PATTERN_NOTE,
19+
} from "../../lib/list-command.js";
1820
import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js";
1921

2022
type ListFlags = {
@@ -63,7 +65,7 @@ export function parseSort(value: string): SortValue {
6365
return value as SortValue;
6466
}
6567

66-
export const listCommand = buildCommand({
68+
export const listCommand = buildListCommand("trace", {
6769
docs: {
6870
brief: "List recent traces in a project",
6971
fullDescription:

src/lib/list-command.ts

Lines changed: 161 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313
* buildOrgListCommand
1414
*/
1515

16-
import type { Aliases, Command } from "@stricli/core";
16+
import type {
17+
Aliases,
18+
Command,
19+
CommandContext,
20+
CommandFunction,
21+
} from "@stricli/core";
1722
import type { SentryContext } from "../context.js";
1823
import { parseOrgProjectArg } from "./arg-parsing.js";
1924
import { buildCommand, numberParser } from "./command.js";
25+
import { warning } from "./formatters/colors.js";
2026
import { dispatchOrgScopedList, type OrgListConfig } from "./org-list.js";
2127

2228
// ---------------------------------------------------------------------------
@@ -125,7 +131,157 @@ export function buildListLimitFlag(
125131
export const LIST_BASE_ALIASES: Aliases<string> = { n: "limit", c: "cursor" };
126132

127133
// ---------------------------------------------------------------------------
128-
// Level B: full command builder for dispatchOrgScopedList-based commands
134+
// Level B: subcommand interception for plural aliases
135+
// ---------------------------------------------------------------------------
136+
137+
let _subcommandsByRoute: Map<string, Set<string>> | undefined;
138+
139+
/**
140+
* Get the subcommand names for a given singular route (e.g. "project" → {"list", "view"}).
141+
*
142+
* Lazily walks the Stricli route map on first call. Uses `require()` to break
143+
* the circular dependency: list-command → app → commands → list-command.
144+
*/
145+
function getSubcommandsForRoute(routeName: string): Set<string> {
146+
if (!_subcommandsByRoute) {
147+
_subcommandsByRoute = new Map();
148+
149+
const { routes } = require("../app.js") as {
150+
routes: {
151+
getAllEntries: () => readonly {
152+
name: { original: string };
153+
target: unknown;
154+
}[];
155+
};
156+
};
157+
158+
for (const entry of routes.getAllEntries()) {
159+
const target = entry.target as unknown as Record<string, unknown>;
160+
if (typeof target?.getAllEntries === "function") {
161+
const children = (
162+
target.getAllEntries as () => readonly {
163+
name: { original: string };
164+
}[]
165+
)();
166+
const names = new Set<string>();
167+
for (const child of children) {
168+
names.add(child.name.original);
169+
}
170+
_subcommandsByRoute.set(entry.name.original, names);
171+
}
172+
}
173+
}
174+
175+
return _subcommandsByRoute.get(routeName) ?? new Set();
176+
}
177+
178+
/**
179+
* Check if a positional target is actually a subcommand name passed through
180+
* a plural alias (e.g. "list" from `sentry projects list`).
181+
*
182+
* When a plural alias like `sentry projects` maps directly to the list
183+
* command, Stricli passes extra tokens as positional args. If the token
184+
* matches a known subcommand of the singular route, we treat it as if no
185+
* target was given (auto-detect) and print a command-specific hint.
186+
*
187+
* @param target - The raw positional argument
188+
* @param stderr - Writable stream for the hint message
189+
* @param routeName - Singular route name (e.g. "project", "issue")
190+
* @returns The original target, or `undefined` if it was a subcommand name
191+
*/
192+
export function interceptSubcommand(
193+
target: string | undefined,
194+
stderr: { write(s: string): void },
195+
routeName: string
196+
): string | undefined {
197+
if (!target) {
198+
return target;
199+
}
200+
const trimmed = target.trim();
201+
if (trimmed && getSubcommandsForRoute(routeName).has(trimmed)) {
202+
stderr.write(
203+
warning(
204+
`Tip: "${trimmed}" is a subcommand. Running: sentry ${routeName} ${trimmed}\n`
205+
)
206+
);
207+
return;
208+
}
209+
return target;
210+
}
211+
212+
// ---------------------------------------------------------------------------
213+
// Level C: list command builder with automatic subcommand interception
214+
// ---------------------------------------------------------------------------
215+
216+
/** Base flags type (mirrors command.ts) */
217+
type BaseFlags = Readonly<Partial<Record<string, unknown>>>;
218+
219+
/**
220+
* Build a Stricli command for a list endpoint with automatic plural-alias
221+
* interception.
222+
*
223+
* This is a drop-in replacement for `buildCommand` that wraps the command
224+
* function to intercept subcommand names passed through plural aliases.
225+
* For example, when `sentry projects list` passes "list" as a positional
226+
* target to the project list command, it is intercepted and treated as
227+
* auto-detect mode with a command-specific hint on stderr.
228+
*
229+
* Usage:
230+
* ```ts
231+
* // Before:
232+
* import { buildCommand } from "../../lib/command.js";
233+
* export const listCommand = buildCommand({ ... });
234+
*
235+
* // After:
236+
* import { buildListCommand } from "../../lib/list-command.js";
237+
* export const listCommand = buildListCommand("project", { ... });
238+
* ```
239+
*
240+
* @param routeName - Singular route name (e.g. "project", "issue") for the
241+
* hint message and subcommand lookup
242+
* @param builderArgs - Same arguments as `buildCommand` from `lib/command.js`
243+
*/
244+
export function buildListCommand<
245+
const FLAGS extends BaseFlags = NonNullable<unknown>,
246+
const ARGS extends readonly unknown[] = [],
247+
const CONTEXT extends CommandContext = CommandContext,
248+
>(
249+
routeName: string,
250+
builderArgs: {
251+
readonly parameters?: Record<string, unknown>;
252+
readonly docs: {
253+
readonly brief: string;
254+
readonly fullDescription?: string;
255+
};
256+
readonly func: CommandFunction<FLAGS, ARGS, CONTEXT>;
257+
}
258+
): Command<CONTEXT> {
259+
const originalFunc = builderArgs.func;
260+
261+
// biome-ignore lint/suspicious/noExplicitAny: Stricli's CommandFunction type is complex
262+
const wrappedFunc = function (this: CONTEXT, flags: FLAGS, ...args: any[]) {
263+
// The first positional arg is always the target (org/project pattern).
264+
// Intercept it to handle plural alias confusion.
265+
if (
266+
args.length > 0 &&
267+
(typeof args[0] === "string" || args[0] === undefined)
268+
) {
269+
// All list commands use SentryContext which has stderr at top level
270+
const ctx = this as unknown as { stderr: { write(s: string): void } };
271+
args[0] = interceptSubcommand(
272+
args[0] as string | undefined,
273+
ctx.stderr,
274+
routeName
275+
);
276+
}
277+
return originalFunc.call(this, flags, ...(args as unknown as ARGS));
278+
} as typeof originalFunc;
279+
280+
return buildCommand({ ...builderArgs, func: wrappedFunc });
281+
}
282+
283+
// ---------------------------------------------------------------------------
284+
// Level D: full command builder for dispatchOrgScopedList-based commands
129285
// ---------------------------------------------------------------------------
130286

131287
/** Documentation strings for a list command built with `buildOrgListCommand`. */
@@ -151,9 +307,10 @@ export type OrgListCommandDocs = {
151307
*/
152308
export function buildOrgListCommand<TEntity, TWithOrg>(
153309
config: OrgListConfig<TEntity, TWithOrg>,
154-
docs: OrgListCommandDocs
310+
docs: OrgListCommandDocs,
311+
routeName: string
155312
): Command<SentryContext> {
156-
return buildCommand({
313+
return buildListCommand(routeName, {
157314
docs,
158315
parameters: {
159316
positional: LIST_TARGET_POSITIONAL,

test/lib/route-subcommands.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Tests for interceptSubcommand in list-command.ts.
3+
*/
4+
5+
import { describe, expect, test } from "bun:test";
6+
import { interceptSubcommand } from "../../src/lib/list-command.js";
7+
8+
function makeStderr(): { write(s: string): void; output: string } {
9+
let output = "";
10+
return {
11+
write(s: string) {
12+
output += s;
13+
},
14+
get output() {
15+
return output;
16+
},
17+
};
18+
}
19+
20+
describe("interceptSubcommand", () => {
21+
test("returns undefined and writes hint for known subcommand", () => {
22+
const stderr = makeStderr();
23+
const result = interceptSubcommand("list", stderr, "project");
24+
expect(result).toBeUndefined();
25+
expect(stderr.output).toContain("Tip:");
26+
expect(stderr.output).toContain("sentry project list");
27+
});
28+
29+
test("returns target unchanged for normal project names", () => {
30+
const stderr = makeStderr();
31+
const result = interceptSubcommand("my-project", stderr, "project");
32+
expect(result).toBe("my-project");
33+
expect(stderr.output).toBe("");
34+
});
35+
36+
test("returns target unchanged for org/project patterns", () => {
37+
const stderr = makeStderr();
38+
const result = interceptSubcommand("sentry/cli", stderr, "issue");
39+
expect(result).toBe("sentry/cli");
40+
expect(stderr.output).toBe("");
41+
});
42+
43+
test("returns undefined/empty unchanged (no hint)", () => {
44+
const stderr = makeStderr();
45+
expect(interceptSubcommand(undefined, stderr, "project")).toBeUndefined();
46+
expect(stderr.output).toBe("");
47+
48+
expect(interceptSubcommand("", stderr, "project")).toBe("");
49+
expect(stderr.output).toBe("");
50+
});
51+
52+
test("hint includes the route name and subcommand", () => {
53+
const stderr = makeStderr();
54+
interceptSubcommand("view", stderr, "issue");
55+
expect(stderr.output).toContain("sentry issue view");
56+
});
57+
58+
test("handles 'explain' and 'plan' subcommands for issue route", () => {
59+
const stderr1 = makeStderr();
60+
expect(interceptSubcommand("explain", stderr1, "issue")).toBeUndefined();
61+
expect(stderr1.output).toContain("sentry issue explain");
62+
63+
const stderr2 = makeStderr();
64+
expect(interceptSubcommand("plan", stderr2, "issue")).toBeUndefined();
65+
expect(stderr2.output).toContain("sentry issue plan");
66+
});
67+
68+
test("does not intercept subcommands from unrelated routes", () => {
69+
const stderr = makeStderr();
70+
// "explain" is a subcommand of "issue" but not "project"
71+
const result = interceptSubcommand("explain", stderr, "project");
72+
expect(result).toBe("explain");
73+
expect(stderr.output).toBe("");
74+
});
75+
76+
test("only intercepts subcommands of the specified route", () => {
77+
const stderr = makeStderr();
78+
// "login" is a subcommand of "auth", not "project"
79+
const result = interceptSubcommand("login", stderr, "project");
80+
expect(result).toBe("login");
81+
expect(stderr.output).toBe("");
82+
});
83+
});

0 commit comments

Comments
 (0)