Skip to content

Commit e33a353

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. Now all list commands intercept known subcommand names before parsing the positional target. If a match is found, the target is treated as auto-detect (as if no target was given) and a gentle hint is printed to stderr: "Tip: use the singular form (e.g. `sentry project list`)." The subcommand names are dynamically extracted from the Stricli route map at runtime — no hardcoded lists. This keeps the set in sync as commands are added or renamed. Fixes CLI-7G
1 parent 25fe2f4 commit e33a353

File tree

7 files changed

+202
-8
lines changed

7 files changed

+202
-8
lines changed

src/commands/issue/list.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
type ResolvedTarget,
5959
resolveAllTargets,
6060
} from "../../lib/resolve-target.js";
61+
import { interceptSubcommand } from "../../lib/route-subcommands.js";
6162
import { getApiBaseUrl } from "../../lib/sentry-client.js";
6263
import type {
6364
ProjectAliasEntry,
@@ -766,7 +767,8 @@ export const listCommand = buildCommand({
766767
): Promise<void> {
767768
const { stdout, stderr, cwd, setContext } = this;
768769

769-
const parsed = parseOrgProjectArg(target);
770+
const resolved = interceptSubcommand(target, stderr);
771+
const parsed = parseOrgProjectArg(resolved);
770772

771773
// Validate --limit range. Auto-pagination handles the API's 100-per-page
772774
// cap transparently, but we cap the total at MAX_LIMIT for practical CLI

src/commands/log/list.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "../../lib/formatters/index.js";
2121
import { TARGET_PATTERN_NOTE } from "../../lib/list-command.js";
2222
import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js";
23+
import { interceptSubcommand } from "../../lib/route-subcommands.js";
2324
import { getUpdateNotification } from "../../lib/version-check.js";
2425
import type { SentryLog, Writer } from "../../types/index.js";
2526

@@ -297,8 +298,9 @@ export const listCommand = buildCommand({
297298
const { stdout, stderr, cwd, setContext } = this;
298299

299300
// Resolve org/project from positional arg, config, or DSN auto-detection
301+
const resolved = interceptSubcommand(target, stderr);
300302
const { org, project } = await resolveOrgProjectFromArg(
301-
target,
303+
resolved,
302304
cwd,
303305
COMMAND_NAME
304306
);

src/commands/project/list.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
type ListCommandMeta,
5252
} from "../../lib/org-list.js";
5353
import { resolveAllTargets } from "../../lib/resolve-target.js";
54+
import { interceptSubcommand } from "../../lib/route-subcommands.js";
5455
import { getApiBaseUrl } from "../../lib/sentry-client.js";
5556
import type { SentryProject, Writer } from "../../types/index.js";
5657

@@ -642,9 +643,10 @@ export const listCommand = buildCommand({
642643
flags: ListFlags,
643644
target?: string
644645
): Promise<void> {
645-
const { stdout, cwd } = this;
646+
const { stdout, stderr, cwd } = this;
646647

647-
const parsed = parseOrgProjectArg(target);
648+
const resolved = interceptSubcommand(target, stderr);
649+
const parsed = parseOrgProjectArg(resolved);
648650

649651
await dispatchOrgScopedList({
650652
config: projectListMeta,

src/commands/trace/list.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from "../../lib/formatters/index.js";
1717
import { TARGET_PATTERN_NOTE } from "../../lib/list-command.js";
1818
import { resolveOrgProjectFromArg } from "../../lib/resolve-target.js";
19+
import { interceptSubcommand } from "../../lib/route-subcommands.js";
1920

2021
type ListFlags = {
2122
readonly limit: number;
@@ -123,11 +124,12 @@ export const listCommand = buildCommand({
123124
flags: ListFlags,
124125
target?: string
125126
): Promise<void> {
126-
const { stdout, cwd, setContext } = this;
127+
const { stdout, stderr, cwd, setContext } = this;
127128

128129
// Resolve org/project from positional arg, config, or DSN auto-detection
130+
const resolved = interceptSubcommand(target, stderr);
129131
const { org, project } = await resolveOrgProjectFromArg(
130-
target,
132+
resolved,
131133
cwd,
132134
COMMAND_NAME
133135
);

src/lib/list-command.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { SentryContext } from "../context.js";
1818
import { parseOrgProjectArg } from "./arg-parsing.js";
1919
import { buildCommand, numberParser } from "./command.js";
2020
import { dispatchOrgScopedList, type OrgListConfig } from "./org-list.js";
21+
import { interceptSubcommand } from "./route-subcommands.js";
2122

2223
// ---------------------------------------------------------------------------
2324
// Level A: shared parameter / flag definitions
@@ -173,8 +174,9 @@ export function buildOrgListCommand<TEntity, TWithOrg>(
173174
},
174175
target?: string
175176
): Promise<void> {
176-
const { stdout, cwd } = this;
177-
const parsed = parseOrgProjectArg(target);
177+
const { stdout, stderr, cwd } = this;
178+
const resolved = interceptSubcommand(target, stderr);
179+
const parsed = parseOrgProjectArg(resolved);
178180
await dispatchOrgScopedList({ config, stdout, cwd, flags, parsed });
179181
},
180182
});

src/lib/route-subcommands.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Lazily extracts subcommand names from the Stricli route map.
3+
*
4+
* Used to detect when a user types `sentry projects list` where "list"
5+
* is passed as a positional arg to the projects (→ project list) command.
6+
* The set is derived dynamically from the actual route definitions so it
7+
* stays in sync as commands are added or renamed.
8+
*
9+
* Uses lazy initialization to avoid circular dependency issues: this module
10+
* lazily imports `routes` from `app.ts` on first call, after all modules
11+
* have loaded.
12+
*/
13+
14+
import { warning } from "./formatters/colors.js";
15+
16+
let _subcommands: Set<string> | undefined;
17+
18+
/**
19+
* Return the set of subcommand names registered under any Stricli route map.
20+
*
21+
* Walks all top-level entries in the route map. For each entry that is itself
22+
* a route map (has `getAllEntries`), collects its child command names.
23+
*
24+
* Cached after first call.
25+
*/
26+
export function getRouteSubcommands(): Set<string> {
27+
if (_subcommands) {
28+
return _subcommands;
29+
}
30+
31+
// Lazy require to break the circular dependency:
32+
// route-subcommands → app → commands → list-command → route-subcommands
33+
const { routes } = require("../app.js") as {
34+
routes: {
35+
getAllEntries: () => readonly {
36+
name: { original: string };
37+
target: unknown;
38+
}[];
39+
};
40+
};
41+
42+
_subcommands = new Set<string>();
43+
44+
for (const entry of routes.getAllEntries()) {
45+
const target = entry.target as unknown as Record<string, unknown>;
46+
if (typeof target?.getAllEntries === "function") {
47+
const children = (
48+
target.getAllEntries as () => readonly { name: { original: string } }[]
49+
)();
50+
for (const child of children) {
51+
_subcommands.add(child.name.original);
52+
}
53+
}
54+
}
55+
56+
return _subcommands;
57+
}
58+
59+
/**
60+
* Check if a positional target arg is actually a subcommand name (e.g.
61+
* "list" from `sentry projects list`).
62+
*
63+
* When a plural alias like `sentry projects` maps directly to the list
64+
* command, Stricli passes extra tokens as positional args. If the token
65+
* matches a known subcommand name, we treat it as if no target was given
66+
* (auto-detect) and print a gentle hint to stderr.
67+
*
68+
* @returns The original target, or `undefined` if it was a subcommand name.
69+
*/
70+
export function interceptSubcommand(
71+
target: string | undefined,
72+
stderr: { write(s: string): void }
73+
): string | undefined {
74+
if (!target) {
75+
return target;
76+
}
77+
const trimmed = target.trim();
78+
if (trimmed && getRouteSubcommands().has(trimmed)) {
79+
stderr.write(
80+
warning(
81+
`Tip: use the singular form (e.g. \`sentry project ${trimmed}\`) for subcommands.\n`
82+
)
83+
);
84+
return;
85+
}
86+
return target;
87+
}

test/lib/route-subcommands.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Tests for route-subcommands and interceptSubcommand.
3+
*/
4+
5+
import { describe, expect, test } from "bun:test";
6+
import { interceptSubcommand } from "../../src/lib/route-subcommands.js";
7+
import { getRouteSubcommands } from "../../src/lib/route-subcommands.js";
8+
9+
describe("getRouteSubcommands", () => {
10+
test("returns a non-empty set", () => {
11+
const subs = getRouteSubcommands();
12+
expect(subs.size).toBeGreaterThan(0);
13+
});
14+
15+
test("contains known subcommand names from routes", () => {
16+
const subs = getRouteSubcommands();
17+
// These exist because issue route has list/view/explain/plan
18+
expect(subs.has("list")).toBe(true);
19+
expect(subs.has("view")).toBe(true);
20+
expect(subs.has("explain")).toBe(true);
21+
expect(subs.has("plan")).toBe(true);
22+
});
23+
24+
test("does not contain top-level command names", () => {
25+
const subs = getRouteSubcommands();
26+
expect(subs.has("issue")).toBe(false);
27+
expect(subs.has("project")).toBe(false);
28+
expect(subs.has("api")).toBe(false);
29+
});
30+
31+
test("returns the same cached set on repeat calls", () => {
32+
const a = getRouteSubcommands();
33+
const b = getRouteSubcommands();
34+
expect(a).toBe(b);
35+
});
36+
});
37+
38+
describe("interceptSubcommand", () => {
39+
function makeStderr(): { write(s: string): void; output: string } {
40+
let output = "";
41+
return {
42+
write(s: string) {
43+
output += s;
44+
},
45+
get output() {
46+
return output;
47+
},
48+
};
49+
}
50+
51+
test("returns undefined and writes hint for known subcommand", () => {
52+
const stderr = makeStderr();
53+
const result = interceptSubcommand("list", stderr);
54+
expect(result).toBeUndefined();
55+
expect(stderr.output).toContain("Tip:");
56+
expect(stderr.output).toContain("singular form");
57+
});
58+
59+
test("returns target unchanged for normal project names", () => {
60+
const stderr = makeStderr();
61+
const result = interceptSubcommand("my-project", stderr);
62+
expect(result).toBe("my-project");
63+
expect(stderr.output).toBe("");
64+
});
65+
66+
test("returns target unchanged for org/project patterns", () => {
67+
const stderr = makeStderr();
68+
const result = interceptSubcommand("sentry/cli", stderr);
69+
expect(result).toBe("sentry/cli");
70+
expect(stderr.output).toBe("");
71+
});
72+
73+
test("returns undefined/empty unchanged (no hint)", () => {
74+
const stderr = makeStderr();
75+
expect(interceptSubcommand(undefined, stderr)).toBeUndefined();
76+
expect(stderr.output).toBe("");
77+
78+
expect(interceptSubcommand("", stderr)).toBe("");
79+
expect(stderr.output).toBe("");
80+
});
81+
82+
test("includes the subcommand name in the hint", () => {
83+
const stderr = makeStderr();
84+
interceptSubcommand("view", stderr);
85+
expect(stderr.output).toContain("view");
86+
});
87+
88+
test("handles 'explain' and 'plan' subcommands", () => {
89+
const stderr1 = makeStderr();
90+
expect(interceptSubcommand("explain", stderr1)).toBeUndefined();
91+
expect(stderr1.output).toContain("Tip:");
92+
93+
const stderr2 = makeStderr();
94+
expect(interceptSubcommand("plan", stderr2)).toBeUndefined();
95+
expect(stderr2.output).toContain("Tip:");
96+
});
97+
});

0 commit comments

Comments
 (0)