Skip to content

Commit 089a521

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. Two layers of fix: 1. `buildListCommand(routeName, config)` — drop-in replacement for `buildCommand` that wraps the func to intercept subcommand names. Each list command migrates with a one-line change: buildCommand({...}) → buildListCommand("project", {...}) When a match is found, the target is replaced with `undefined` (auto-detect) and a command-specific hint prints to stderr: Tip: "list" is a subcommand. Running: sentry project list 2. `exceptionWhileParsingArguments` handler in app.ts — catches the multi-arg case (e.g. `sentry projects view cli`) where Stricli rejects with "Too many arguments" before our func runs. Detects the plural alias and suggests the singular form: Did you mean: sentry project view cli Subcommand names are dynamically extracted per-route from the Stricli route map at runtime — no hardcoded lists. Fixes CLI-7G
1 parent 1e7b397 commit 089a521

File tree

9 files changed

+297
-17
lines changed

9 files changed

+297
-17
lines changed

src/app.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildApplication,
66
buildRouteMap,
77
text_en,
8+
UnexpectedPositionalError,
89
} from "@stricli/core";
910
import { apiCommand } from "./commands/api.js";
1011
import { authRoute } from "./commands/auth/index.js";
@@ -33,7 +34,21 @@ import {
3334
getExitCode,
3435
stringifyUnknown,
3536
} from "./lib/errors.js";
36-
import { error as errorColor } from "./lib/formatters/colors.js";
37+
import { error as errorColor, warning } from "./lib/formatters/colors.js";
38+
39+
/**
40+
* Plural alias → singular route name mapping.
41+
* Used to suggest the correct command when users type e.g. `sentry projects view cli`.
42+
*/
43+
const PLURAL_TO_SINGULAR: Record<string, string> = {
44+
issues: "issue",
45+
orgs: "org",
46+
projects: "project",
47+
repos: "repo",
48+
teams: "team",
49+
logs: "log",
50+
traces: "trace",
51+
};
3752

3853
/** Top-level route map containing all CLI commands */
3954
export const routes = buildRouteMap({
@@ -77,6 +92,27 @@ export const routes = buildRouteMap({
7792
*/
7893
const customText: ApplicationText = {
7994
...text_en,
95+
exceptionWhileParsingArguments: (
96+
exc: unknown,
97+
ansiColor: boolean
98+
): string => {
99+
// When a plural alias receives extra positional args (e.g. `sentry projects view cli`),
100+
// Stricli throws UnexpectedPositionalError because the list command only accepts 1 arg.
101+
// Detect this and suggest the singular form.
102+
if (exc instanceof UnexpectedPositionalError) {
103+
const args = process.argv.slice(2);
104+
const firstArg = args[0];
105+
if (firstArg && firstArg in PLURAL_TO_SINGULAR) {
106+
const singular = PLURAL_TO_SINGULAR[firstArg];
107+
const rest = args.slice(1).join(" ");
108+
const hint = ansiColor
109+
? warning(`\nDid you mean: sentry ${singular} ${rest}\n`)
110+
: `\nDid you mean: sentry ${singular} ${rest}\n`;
111+
return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${hint}`;
112+
}
113+
}
114+
return text_en.exceptionWhileParsingArguments(exc, ansiColor);
115+
},
80116
exceptionWhileRunningCommand: (exc: unknown, ansiColor: boolean): string => {
81117
// Re-throw AuthError("not_authenticated") for auto-login flow in bin.ts
82118
// Don't capture to Sentry - it's an expected state (user not logged in), not an error

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,

0 commit comments

Comments
 (0)