diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 6efb60c7c..be33afb80 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -212,6 +212,7 @@ async function resolveLogListTarget( const { traceId, org } = await resolveTraceOrg( parsed, context.cwd, + "log list", traceUsageHint(), ); return { diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 136a393ba..14de6173b 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -253,6 +253,7 @@ async function resolveSpanListTarget( const resolved = await resolveTraceOrgProject( parsed, context.cwd, + "span list", traceUsageHint(), ); return { mode: "trace", ...resolved }; diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index c26967fa4..712621950 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -170,6 +170,7 @@ async function resolveSpanViewInput( const { traceId, org, project } = await resolveTraceOrgProject( traceTarget, context.cwd, + "span view", usageHint(), ); // Span IDs are unique only within a trace, so recovery scopes its fuzzy diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index cc6f1edbb..e3506cabe 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -132,6 +132,7 @@ async function resolveTraceLogsTarget( const { traceId, org } = await resolveTraceOrg( parsed, context.cwd, + "trace logs", usageHint(), ); return { diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index bba353485..d886b21d2 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -149,6 +149,7 @@ async function resolveTraceViewInput( const target = await resolveTraceOrgOptionalProject( parsed, context.cwd, + "trace view", usageHint(), ); return { diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index 79e845d16..652f7964f 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -44,8 +44,6 @@ const log = logger.withTag("trace-target"); /** Match `[]` in usageHint — captures bracket content + trailing placeholder */ const USAGE_TARGET_RE = /\[.*\]<[^>]+>/; -const SENTRY_COMMAND_PREFIX_RE = /^sentry\s+/; -const USAGE_ARGUMENT_START_RE = /\s+(?:\[|<)/; // --------------------------------------------------------------------------- // Types @@ -354,14 +352,6 @@ export function warnIfNormalized(parsed: ParsedTraceTarget, tag: string): void { // Resolution — org + project // --------------------------------------------------------------------------- -function commandNameFromUsageHint(usageHint: string): string { - const withoutPrefix = usageHint.replace(SENTRY_COMMAND_PREFIX_RE, ""); - const targetIndex = withoutPrefix.search(USAGE_ARGUMENT_START_RE); - return targetIndex === -1 - ? withoutPrefix - : withoutPrefix.slice(0, targetIndex); -} - /** * Resolve a parsed trace target to org + optional project. * @@ -374,6 +364,7 @@ function commandNameFromUsageHint(usageHint: string): string { export async function resolveTraceOrgOptionalProject( parsed: ParsedTraceTarget, cwd: string, + commandName: string, usageHint: string, ): Promise { if (parsed.type === "org-scoped") { @@ -384,12 +375,12 @@ export async function resolveTraceOrgOptionalProject( const resolved = await resolveOrgOptionalTarget( parsed, cwd, - commandNameFromUsageHint(usageHint), + commandName, usageHint, ); return { traceId: parsed.traceId, ...resolved }; } - return await resolveTraceOrgProject(parsed, cwd, usageHint); + return await resolveTraceOrgProject(parsed, cwd, commandName, usageHint); } /** @@ -404,6 +395,7 @@ export async function resolveTraceOrgOptionalProject( export async function resolveTraceOrgProject( parsed: ParsedTraceTarget, cwd: string, + commandName: string, usageHint: string, ): Promise { switch (parsed.type) { @@ -417,7 +409,12 @@ export async function resolveTraceOrgProject( case "project-search": // resolveProjectBoundTarget (called inside) sets telemetry context. - return await resolveProjectSearchTarget(parsed, cwd, usageHint); + return await resolveProjectSearchTarget( + parsed, + cwd, + commandName, + usageHint, + ); case "org-scoped": throw new ContextError("Specific project", usageHint, [ @@ -450,14 +447,12 @@ export async function resolveTraceOrgProject( async function resolveProjectSearchTarget( parsed: Extract, cwd: string, + commandName: string, usageHint: string, ): Promise { - const target = await resolveProjectBoundTarget( - parsed, - cwd, - commandNameFromUsageHint(usageHint), - { usageHint }, - ); + const target = await resolveProjectBoundTarget(parsed, cwd, commandName, { + usageHint, + }); return { traceId: parsed.traceId, org: target.org, @@ -480,6 +475,7 @@ async function resolveProjectSearchTarget( export async function resolveTraceOrg( parsed: ParsedTraceTarget, cwd: string, + commandName: string, usageHint: string, ): Promise { switch (parsed.type) { @@ -495,7 +491,7 @@ export async function resolveTraceOrg( const org = await resolveOrgOnlyTarget( parsed, cwd, - commandNameFromUsageHint(usageHint), + commandName, usageHint, ); return { traceId: parsed.traceId, org }; diff --git a/packages/cli/test/commands/log/list.test.ts b/packages/cli/test/commands/log/list.test.ts index 5748b6cb2..693948881 100644 --- a/packages/cli/test/commands/log/list.test.ts +++ b/packages/cli/test/commands/log/list.test.ts @@ -734,6 +734,7 @@ describe("listCommand.func — trace mode", () => { traceId: TRACE_ID, }), "/tmp", + "log list", expect.any(String), ); }); diff --git a/packages/cli/test/lib/trace-target.test.ts b/packages/cli/test/lib/trace-target.test.ts index 2ac3d92da..37694d042 100644 --- a/packages/cli/test/lib/trace-target.test.ts +++ b/packages/cli/test/lib/trace-target.test.ts @@ -30,6 +30,7 @@ vi.mock("../../src/lib/region.js", async (importOriginal) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as apiClient from "../../src/lib/api-client.js"; +import { getAppIdentity, setAppIdentity } from "../../src/lib/app-identity.js"; import { ContextError, ResolutionError, @@ -49,6 +50,7 @@ import { } from "../../src/lib/trace-target.js"; const VALID_TRACE_ID = "aaaa1111bbbb2222cccc3333dddd4444"; +const defaultIdentity = getAppIdentity(); /** The target {@link parseTraceTargetInput} parses `args` into. */ function parsedTraceTarget( @@ -216,6 +218,7 @@ describe("trace target resolution", () => { const resolved = await resolveTraceOrg( parsed, "/tmp", + "trace logs", "sentry trace logs [/[/]]", ); @@ -231,7 +234,7 @@ describe("trace target resolution", () => { const usageHint = "sentry span view [//]"; try { - await resolveTraceOrgProject(parsed, "/tmp", usageHint); + await resolveTraceOrgProject(parsed, "/tmp", "span view", usageHint); expect.unreachable("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ResolutionError); @@ -248,13 +251,64 @@ describe("trace target resolution", () => { const usageHint = "sentry trace view [//]"; try { - await resolveTraceOrgOptionalProject(parsed, "/tmp", usageHint); + await resolveTraceOrgOptionalProject( + parsed, + "/tmp", + "trace view", + usageHint, + ); expect.unreachable("should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ResolutionError); expect((error as ResolutionError).hint).toBe(usageHint); } }); + + describe("under another program name", () => { + beforeEach(() => { + setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + findProjectsBySlugSpy.mockResolvedValue({ + projects: [ + { id: "1", slug: "frontend", name: "Frontend", orgSlug: "org-a" }, + { id: "2", slug: "frontend", name: "Frontend", orgSlug: "org-b" }, + ], + orgs: [ + { slug: "org-a", name: "Org A" }, + { slug: "org-b", name: "Org B" }, + ], + }); + }); + + afterEach(() => { + setAppIdentity(defaultIdentity); + }); + + test.each([ + ["log list", resolveTraceOrg], + ["span list", resolveTraceOrgProject], + ["trace view", resolveTraceOrgOptionalProject], + ])( + "an ambiguous project names the program once in %s suggestions", + async (commandName, resolve) => { + const parsed = parsedTraceTarget( + ["frontend", VALID_TRACE_ID], + "sentry trace view ", + ); + + const error = await resolve( + parsed, + "/tmp", + commandName, + `sentry-mcp ${commandName} [//]`, + ).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResolutionError); + const message = (error as ResolutionError).message; + expect(message).toContain(`sentry-mcp ${commandName} org-a/frontend`); + expect(message).not.toContain("sentry-mcp sentry-mcp"); + }, + ); + }); }); describe("parseTraceTargetInput", () => {