From 42bd081df0535ea395e19d0f190d38eb2f5b3d95 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:54:47 +0000 Subject: [PATCH] refactor(cli): Build stored command hints lazily and drop asInvocationCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands kept in constants and tables are now LazyCommand thunks (`() => sentry\`issue view \``) that build the hint when it is shown, so nothing rewrites a leading `sentry ` at runtime. ContextError, ResolutionError, buildValidationMessage and org-list print what they get. The sentry tag inserts interpolated values as they are, so user text that starts with "sentry " is no longer rewritten. The lint rule now flags module-level `sentry …` literals too, and a test checks that loading the command tree builds no hints. Refs #2071 Refs #2050 Co-Authored-By: Burak Yigit Kaya <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 2 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 37 +------------ .../src/commands/agent-conversation/view.ts | 12 ++--- packages/cli/src/commands/alert/arguments.ts | 5 +- .../cli/src/commands/alert/issues/create.ts | 9 ++-- .../cli/src/commands/alert/issues/delete.ts | 15 +++--- .../cli/src/commands/alert/issues/edit.ts | 13 ++--- .../cli/src/commands/alert/issues/list.ts | 13 ++--- .../src/commands/alert/issues/rule-resolve.ts | 10 ++-- .../cli/src/commands/alert/issues/view.ts | 9 ++-- .../cli/src/commands/alert/metrics/delete.ts | 9 ++-- .../cli/src/commands/alert/metrics/edit.ts | 9 ++-- .../cli/src/commands/alert/metrics/list.ts | 11 ++-- .../commands/alert/metrics/rule-resolve.ts | 10 ++-- .../cli/src/commands/alert/metrics/view.ts | 7 +-- packages/cli/src/commands/build/download.ts | 7 +-- packages/cli/src/commands/build/upload.ts | 7 +-- .../cli/src/commands/code-mappings/upload.ts | 6 +-- .../src/commands/dart-symbol-map/upload.ts | 7 +-- .../cli/src/commands/dashboard/arguments.ts | 11 ++-- packages/cli/src/commands/dashboard/list.ts | 2 +- .../cli/src/commands/dashboard/restore.ts | 3 +- .../cli/src/commands/dashboard/revisions.ts | 2 +- packages/cli/src/commands/dashboard/view.ts | 3 +- .../cli/src/commands/dashboard/widget/add.ts | 8 +-- .../src/commands/dashboard/widget/delete.ts | 5 +- .../cli/src/commands/dashboard/widget/edit.ts | 4 +- .../commands/debug-files/bundle-sources.ts | 6 +-- .../cli/src/commands/debug-files/check.ts | 6 +-- .../src/commands/debug-files/print-sources.ts | 8 +-- .../cli/src/commands/debug-files/upload.ts | 16 +++--- packages/cli/src/commands/dsn/list.ts | 8 +-- packages/cli/src/commands/event/list.ts | 6 +-- packages/cli/src/commands/event/view.ts | 35 ++++++------ .../cli/src/commands/integration/delete.ts | 12 ++--- packages/cli/src/commands/integration/list.ts | 3 +- packages/cli/src/commands/issue/explain.ts | 4 +- packages/cli/src/commands/issue/list.ts | 10 ++-- packages/cli/src/commands/issue/merge.ts | 4 +- packages/cli/src/commands/issue/positional.ts | 24 +++++---- packages/cli/src/commands/issue/view.ts | 4 +- packages/cli/src/commands/log/list.ts | 6 +-- packages/cli/src/commands/log/view.ts | 12 ++--- packages/cli/src/commands/monitor/list.ts | 3 +- packages/cli/src/commands/monitor/run.ts | 10 ++-- packages/cli/src/commands/proguard/upload.ts | 9 ++-- packages/cli/src/commands/project/create.ts | 20 +++---- packages/cli/src/commands/project/list.ts | 2 +- packages/cli/src/commands/project/view.ts | 22 ++++---- .../cli/src/commands/react-native/gradle.ts | 9 ++-- .../cli/src/commands/react-native/xcode.ts | 7 +-- packages/cli/src/commands/release/archive.ts | 5 +- packages/cli/src/commands/release/create.ts | 4 +- packages/cli/src/commands/release/delete.ts | 5 +- packages/cli/src/commands/release/deploy.ts | 9 ++-- packages/cli/src/commands/release/deploys.ts | 5 +- packages/cli/src/commands/release/finalize.ts | 5 +- packages/cli/src/commands/release/list.ts | 2 +- packages/cli/src/commands/release/parse.ts | 10 ++-- packages/cli/src/commands/release/restore.ts | 5 +- .../cli/src/commands/release/set-commits.ts | 4 +- packages/cli/src/commands/release/view.ts | 5 +- packages/cli/src/commands/replay/download.ts | 6 +-- packages/cli/src/commands/replay/view.ts | 14 ++--- packages/cli/src/commands/repo/list.ts | 3 +- .../cli/src/commands/snapshots/download.ts | 11 ++-- packages/cli/src/commands/snapshots/upload.ts | 7 +-- packages/cli/src/commands/sourcemap/upload.ts | 7 +-- packages/cli/src/commands/span/list.ts | 6 +-- packages/cli/src/commands/span/view.ts | 33 +++++++----- packages/cli/src/commands/team/list.ts | 3 +- packages/cli/src/commands/token/create.ts | 7 +-- packages/cli/src/commands/token/delete.ts | 14 ++--- packages/cli/src/commands/trace/logs.ts | 6 +-- packages/cli/src/commands/trace/view.ts | 6 +-- packages/cli/src/commands/trial/start.ts | 10 ++-- packages/cli/src/lib/app-identity.ts | 32 +++++------ packages/cli/src/lib/command-suggestions.ts | 53 ++++++++++--------- packages/cli/src/lib/command.ts | 18 ++++++- packages/cli/src/lib/errors.ts | 28 ++++------ packages/cli/src/lib/hex-id-recovery.ts | 14 ++--- packages/cli/src/lib/id-arguments.ts | 34 +++++++----- packages/cli/src/lib/org-list.ts | 34 ++++++------ packages/cli/src/lib/resolve-team.ts | 14 +++-- packages/cli/src/lib/target-positional.ts | 6 +-- packages/cli/src/lib/trace-target.ts | 4 +- .../cli/test/commands/replay/view.test.ts | 2 +- .../cli/test/lib/app-identity-import.test.ts | 27 ++++++++++ packages/cli/test/lib/app-identity.test.ts | 45 ++++++---------- packages/cli/test/lib/id-arguments.test.ts | 2 +- packages/cli/test/lib/list-command.test.ts | 2 +- packages/cli/test/lib/org-list.test.ts | 4 +- .../test/lib/release-parse.property.test.ts | 2 +- scripts/cli-lint-rules.test.mjs | 21 ++++---- 94 files changed, 522 insertions(+), 494 deletions(-) create mode 100644 packages/cli/test/lib/app-identity-import.test.ts diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index b7ac98c41..bb93383a9 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -826,7 +826,7 @@ All command docs and skill files are generated via `pnpm run generate:docs`. Thi - **Command docs** (`apps/cli-docs/src/content/docs/commands/*.md`) and the configuration page (`apps/cli-docs/src/content/docs/configuration.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `apps/cli-docs/src/fragments/` (paths live in `script/paths.ts`). - **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. -- **Hints name the running program**: the same code runs as `sentry`, `npx sentry`, and `sentry-mcp`, so write a hint's command with the `` sentry`…` `` tag from `src/lib/app-identity.ts`, without the program name (`` sentry`issue list ${org}/` ``), inside the function that builds the hint. A command held in a variable goes through `asInvocationCommand(command)`. A module-level usage line (`USAGE_HINT`, `commandPrefix`) is fine when the code that prints it wraps it, as `ContextError`, `ResolutionError`, `buildValidationMessage`, and `org-list` do. Command help (`docs`, `fullDescription`, `brief`, `examples`) stays `sentry …`. Lint (`sentry-cli/no-hardcoded-sentry-command`) flags a bare `sentry ` (routes come from `src/command-manifest.ts`), and `pnpm run check:doc-examples` checks each tag's command and flags against the route tree. +- **Hints name the running program**: the same code runs as `sentry`, `npx sentry`, and `sentry-mcp`, so write a hint's command with the `` sentry`…` `` tag from `src/lib/app-identity.ts`, without the program name (`` sentry`issue list ${org}/` ``), inside the function that builds the hint. The tag inserts interpolated values as they are and nothing rewrites a hint later, so errors such as `ContextError` print usage lines as given. Keep a command in a constant or table as a `LazyCommand`, `` const usageHint = () => sentry`issue view ` ``, and call it where the hint is shown: the entry point registers the program only after the command modules load (`test/lib/app-identity-import.test.ts` checks that no hint is built at import). Command help (`docs`, `fullDescription`, `brief`, `examples`) stays `sentry …`. Lint (`sentry-cli/no-hardcoded-sentry-command`) flags a bare `sentry ` (routes come from `src/command-manifest.ts`), and `pnpm run check:doc-examples` checks each tag's command and flags against the route tree. - **Examples belong to the command**: add them to `docs.examples` (`{ description, command }[]`, see `src/commands/agent-conversation/list.ts`). They render in `--help`, the generated docs, and the skill references. Don't hand-write examples in `fullDescription` (an `Examples:` block, or indented `sentry …` lines under any label) or an Examples heading in a fragment; the `sentry-cli/no-examples-in-full-description` lint rule and `pnpm run check:fragments` reject both. - **Target syntax belongs to the positional**: describe accepted target forms in the positional `brief` (use `ORG_ALL_TARGET_BRIEF` or `PROJECT_TARGET_BRIEF` from `src/lib/list-command.ts` for `[/][]` targets), not in a `Target patterns:` block. The brief renders in `--help`, the generated docs, and the skill references. - Use fragments in `apps/cli-docs/src/fragments/commands/` only for prose the command metadata can't carry: guides, permissions, caveats. diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 37ab5c620..a79afdfdf 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -396,11 +396,6 @@ function namesSentryCommand(text) { return false; } -/** A whole `sentry `, such as a usage line or a command prefix. */ -function isWholeSentryCommand(text) { - return namesSentryCommand(text) && text.startsWith("sentry "); -} - /** The tag that builds a command hint: `` sentry`issue list` ``. */ function isSentryTag(node) { return ( @@ -410,15 +405,6 @@ function isSentryTag(node) { ); } -/** A call of the runtime form, for commands held in variables. */ -function isInvocationCall(node) { - return ( - node.type === "CallExpression" && - node.callee.type === "Identifier" && - node.callee.name === "asInvocationCommand" - ); -} - /** Text of a template literal, each interpolation as one word. */ function templateText(node) { return node.quasis @@ -446,7 +432,7 @@ function hasFunctionAncestor(node) { function isInvocationSafe(node) { for (let current = node.parent; current; current = current.parent) { if (FUNCTION_TYPES.has(current.type)) return false; - if (isSentryTag(current) || isInvocationCall(current)) return true; + if (isSentryTag(current)) return true; if ( current.type === "Property" && HELP_TEXT_KEYS.has(current.key?.name ?? current.key?.value) @@ -911,9 +897,6 @@ const rules = { if (!file.includes("/src/")) return {}; const check = (node, text) => { if (!namesSentryCommand(text) || isInvocationSafe(node)) return; - // A module-level constant that holds a whole command is data: the - // code that prints it wraps it, as ContextError and org-list do. - if (isWholeSentryCommand(text) && !hasFunctionAncestor(node)) return; report(node); }; // The running program is known only once an entry point has started, @@ -922,26 +905,10 @@ const rules = { if (!hasFunctionAncestor(node)) report( node, - "Build the hint inside the function that prints it, not at module load: the running program is not known yet.", + "Build the hint when it is shown, not at module load: the running program is not known yet. Keep a command in a constant or table as `() => sentry`…``.", ); }; return { - CallExpression(node) { - if (!isInvocationCall(node)) return; - checkModuleLevel(node); - const [arg] = node.arguments; - const text = - arg?.type === "Literal" - ? arg.value - : arg?.type === "TemplateLiteral" - ? templateText(arg) - : undefined; - if (typeof text === "string" && text.startsWith("sentry ")) - report( - node, - "Write a literal command with the sentry`…` tag; asInvocationCommand() is for commands held in variables.", - ); - }, TaggedTemplateExpression(node) { if (!isSentryTag(node)) return; checkModuleLevel(node); diff --git a/packages/cli/src/commands/agent-conversation/view.ts b/packages/cli/src/commands/agent-conversation/view.ts index 2f05dbf26..6709f04bb 100644 --- a/packages/cli/src/commands/agent-conversation/view.ts +++ b/packages/cli/src/commands/agent-conversation/view.ts @@ -6,7 +6,6 @@ import type { SentryContext } from "../../context.js"; import { getConversationSpans } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { ORG_TARGET } from "../../lib/completion-target.js"; import { @@ -33,6 +32,7 @@ import { parseOrgInput, resolveOrgInput, } from "../../lib/target-positional.js"; +import { sentry } from "../../lib/app-identity.js"; type ViewFlags = { readonly json: boolean; @@ -40,7 +40,7 @@ type ViewFlags = { }; const USAGE = "[/]"; -const USAGE_HINT = `sentry agent-conversation view ${USAGE}`; +const usageHint = () => sentry`agent-conversation view ${USAGE}`; const log = logger.withTag("agent-conversation.view"); @@ -82,7 +82,7 @@ export function parseConversationArguments( args: readonly string[], ): ConversationInput { const parsed = parseIdArguments(args, ID_KINDS.conversation, { - usageHint: USAGE_HINT, + usageHint: usageHint(), multiple: true, }); let { target, ids } = parsed; @@ -92,12 +92,12 @@ export function parseConversationArguments( } if (ids.length > 1) { throw new ValidationError( - `View one conversation at a time: ${asInvocationCommand(USAGE_HINT)}`, + `View one conversation at a time: ${usageHint()}`, "conversation-id", ); } const [conversationId = ""] = ids; - const org = parseOrgInput(target, USAGE_HINT); + const org = parseOrgInput(target, usageHint()); return { conversationId, org: parsed.baseUrl ? { ...org, baseUrl: parsed.baseUrl } : org, @@ -147,7 +147,7 @@ export const viewCommand = buildCommand({ const { org } = await resolveOrgInput( input.org, context.cwd, - USAGE_HINT, + usageHint(), ); return { org, conversationId: input.conversationId }; }, diff --git a/packages/cli/src/commands/alert/arguments.ts b/packages/cli/src/commands/alert/arguments.ts index 927d76ca6..21b74483f 100644 --- a/packages/cli/src/commands/alert/arguments.ts +++ b/packages/cli/src/commands/alert/arguments.ts @@ -24,6 +24,7 @@ import { boundPositional, } from "../../lib/positional-parse.js"; import { applySentryUrlContext } from "../../lib/sentry-url-parser.js"; +import type { LazyCommand } from "../../lib/app-identity.js"; /** A rule ID or name and the target to find it in. */ export type AlertRuleRef = { @@ -58,11 +59,11 @@ export function alertRuleParameter(options: { readonly brief: string; readonly kind: typeof ORG_TARGET | typeof ORG_PROJECT_TARGET; readonly split: RuleArgSplitter; - readonly usageHint: string; + readonly usageHint: LazyCommand; }) { const { placeholder, brief, kind, split, usageHint } = options; const parse = (raw: string | undefined) => { - const { ref, targetArg } = split(raw ?? "", usageHint); + const { ref, targetArg } = split(raw ?? "", usageHint()); return { ref, ...parseOrgProjectInput(targetArg) }; }; return { diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index d6f33c4a4..4055c272d 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -49,9 +49,10 @@ import type { IssueAlertCreateOutput, } from "../../../output-schemas/alert.js"; import { IssueAlertCreateOutputSchema } from "../../../generated/output-schemas/alert.js"; +import { sentry } from "../../../lib/app-identity.js"; -const USAGE_HINT = - "sentry alert issues create --name --condition --action "; +const usageHint = () => + sentry`alert issues create --name --condition --action `; type CreateFlags = { readonly name: string; @@ -86,11 +87,11 @@ async function resolveCreateTarget( ): Promise { const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); const [target, ...others] = targets; if (!target) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } if (others.length > 0) { throw new ValidationError( diff --git a/packages/cli/src/commands/alert/issues/delete.ts b/packages/cli/src/commands/alert/issues/delete.ts index 1e91e4906..da61254ce 100644 --- a/packages/cli/src/commands/alert/issues/delete.ts +++ b/packages/cli/src/commands/alert/issues/delete.ts @@ -20,9 +20,10 @@ import type { AlertRuleArgument } from "../arguments.js"; import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js"; import type { IssueAlertDeleteOutput } from "../../../output-schemas/alert.js"; import { IssueAlertDeleteOutputSchema } from "../../../generated/output-schemas/alert.js"; +import { sentry } from "../../../lib/app-identity.js"; -const USAGE_HINT = - "sentry alert issues delete //"; +const usageHint = () => + sentry`alert issues delete //`; type DeleteFlags = { readonly yes: boolean; @@ -69,7 +70,7 @@ export const deleteCommand = buildDeleteCommand({ positional: { kind: "tuple", parameters: [ - issueRuleParameter(USAGE_HINT, "Rule id or name (same as view)"), + issueRuleParameter(usageHint, "Rule id or name (same as view)"), ], }, }, @@ -80,7 +81,7 @@ export const deleteCommand = buildDeleteCommand({ ) { const { cwd } = this; const { ref, target: parsed } = await ruleArg.resolved; - requireExplicitTarget(parsed, "Issue alert target", USAGE_HINT); + requireExplicitTarget(parsed, "Issue alert target", usageHint()); if (parsed.type !== "explicit") { throw new ValidationError( "Issue alert delete requires an explicit // target.", @@ -90,16 +91,16 @@ export const deleteCommand = buildDeleteCommand({ const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (targets.length === 0) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { target, rule } = await resolveIssueAlertRule( targets, ref, - USAGE_HINT, + usageHint(), ); const key = `${target.org}/${target.project}/${rule.id}`; diff --git a/packages/cli/src/commands/alert/issues/edit.ts b/packages/cli/src/commands/alert/issues/edit.ts index 5766ea3df..e8e0b0305 100644 --- a/packages/cli/src/commands/alert/issues/edit.ts +++ b/packages/cli/src/commands/alert/issues/edit.ts @@ -42,9 +42,10 @@ import type { AlertRuleArgument } from "../arguments.js"; import { issueRuleParameter, resolveIssueAlertRule } from "./rule-resolve.js"; import type { IssueAlertEditOutput } from "../../../output-schemas/alert.js"; import { IssueAlertEditOutputSchema } from "../../../generated/output-schemas/alert.js"; +import { sentry } from "../../../lib/app-identity.js"; -const USAGE_HINT = - "sentry alert issues edit // --name | --status active|disabled"; +const usageHint = () => + sentry`alert issues edit // --name | --status active|disabled`; type EditFlags = { readonly name?: string; @@ -230,7 +231,7 @@ export const editCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - issueRuleParameter(USAGE_HINT, "Rule id or name (same as view)"), + issueRuleParameter(usageHint, "Rule id or name (same as view)"), ], }, flags: { @@ -312,16 +313,16 @@ export const editCommand = buildCommand({ const { ref, target: parsed } = await ruleArg.resolved; const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (targets.length === 0) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { target, rule } = await resolveIssueAlertRule( targets, ref, - USAGE_HINT, + usageHint(), ); const body = { diff --git a/packages/cli/src/commands/alert/issues/list.ts b/packages/cli/src/commands/alert/issues/list.ts index dab6c4add..24490b8c4 100644 --- a/packages/cli/src/commands/alert/issues/list.ts +++ b/packages/cli/src/commands/alert/issues/list.ts @@ -78,11 +78,12 @@ import { fetchAlertRulesWithBudget, throwAlertListFetchFailure, } from "../list-utils.js"; +import { sentry } from "../../../lib/app-identity.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "alert-issues-list"; -const USAGE_HINT = "sentry alert issues list /"; +const usageHint = () => sentry`alert issues list /`; type ListFlags = { readonly web: boolean; @@ -122,7 +123,7 @@ type IssueAlertListResult = ListResult & { const issueAlertListMeta: ListCommandMeta = { paginationKey: PAGINATION_KEY, entityPlural: "issue alert rules", - commandPrefix: "sentry alert issues list", + commandPrefix: () => sentry`alert issues list`, }; // Fetch helpers @@ -186,11 +187,11 @@ async function resolveWebUrl( const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), projectSearchResolution, }); if (targets.length === 0) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const orgs = [...new Set(targets.map((t) => t.org))]; @@ -226,11 +227,11 @@ async function handleResolvedTargets( const { targets, footer, detectedDsns } = await resolveProjectBoundTargets( parsed, - { cwd, usageHint: USAGE_HINT, projectSearchResolution }, + { cwd, usageHint: usageHint(), projectSearchResolution }, ); if (targets.length === 0) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const contextKey = buildMultiTargetContextKey(targets, { diff --git a/packages/cli/src/commands/alert/issues/rule-resolve.ts b/packages/cli/src/commands/alert/issues/rule-resolve.ts index eabfcdb20..7192121ca 100644 --- a/packages/cli/src/commands/alert/issues/rule-resolve.ts +++ b/packages/cli/src/commands/alert/issues/rule-resolve.ts @@ -10,7 +10,7 @@ import { isNotFoundApiError, listIssueAlertsPaginated, } from "../../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../../lib/app-identity.js"; +import { type LazyCommand, sentry } from "../../../lib/app-identity.js"; import { ORG_PROJECT_TARGET } from "../../../lib/completion-target.js"; import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; @@ -40,7 +40,7 @@ export function parseIssueRuleArg( const trimmed = arg.trim(); if (!trimmed) { throw new ValidationError( - `Rule id or name is required.\nUse: ${asInvocationCommand(usageHint)}`, + `Rule id or name is required.\nUse: ${usageHint}`, "rule", ); } @@ -52,7 +52,7 @@ export function parseIssueRuleArg( if (slashCount === 1) { throw new ValidationError( `Missing rule id or name after the project (got '${trimmed}').\n` + - `Use: ${asInvocationCommand(usageHint)}\n` + + `Use: ${usageHint}\n` + `Example: ${trimmed}/`, "rule", ); @@ -63,7 +63,7 @@ export function parseIssueRuleArg( const ref = trimmed.slice(lastSlash + 1).trim(); if (!ref) { throw new ValidationError( - `Invalid rule reference '${arg}'.\nUse: ${asInvocationCommand(usageHint)}`, + `Invalid rule reference '${arg}'.\nUse: ${usageHint}`, "rule", ); } @@ -74,7 +74,7 @@ export function parseIssueRuleArg( * The `//` parameter of an issue alert * command, parsed with {@link parseIssueRuleArg} before the command runs. */ -export function issueRuleParameter(usageHint: string, brief: string) { +export function issueRuleParameter(usageHint: LazyCommand, brief: string) { return alertRuleParameter({ placeholder: "org/project/rule-id-or-name", brief, diff --git a/packages/cli/src/commands/alert/issues/view.ts b/packages/cli/src/commands/alert/issues/view.ts index aafed67e6..26a2ad5f0 100644 --- a/packages/cli/src/commands/alert/issues/view.ts +++ b/packages/cli/src/commands/alert/issues/view.ts @@ -14,7 +14,8 @@ import { resolveIssueAlertRule, } from "./rule-resolve.js"; -const USAGE_HINT = "sentry alert issues view //"; +const usageHint = () => + sentry`alert issues view //`; type ViewFlags = { readonly web: boolean; @@ -77,7 +78,7 @@ export const viewCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - issueRuleParameter(USAGE_HINT, "Issue alert rule ID or name"), + issueRuleParameter(usageHint, "Issue alert rule ID or name"), ], }, flags: { @@ -110,7 +111,7 @@ export const viewCommand = buildCommand({ usageHint: sentry`alert issues view //`, }); if (targets.length === 0) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } if (flags.web) { @@ -123,7 +124,7 @@ export const viewCommand = buildCommand({ return; } - const result = await resolveIssueAlertRule(targets, ref, USAGE_HINT); + const result = await resolveIssueAlertRule(targets, ref, usageHint()); yield new CommandOutput(result); }, }); diff --git a/packages/cli/src/commands/alert/metrics/delete.ts b/packages/cli/src/commands/alert/metrics/delete.ts index 6b39ca3f7..7ed86a0f7 100644 --- a/packages/cli/src/commands/alert/metrics/delete.ts +++ b/packages/cli/src/commands/alert/metrics/delete.ts @@ -19,8 +19,9 @@ import type { AlertRuleArgument } from "../arguments.js"; import { metricRuleParameter, resolveMetricAlertRule } from "./rule-resolve.js"; import type { MetricAlertDeleteOutput } from "../../../output-schemas/alert.js"; import { MetricAlertDeleteOutputSchema } from "../../../generated/output-schemas/alert.js"; +import { sentry } from "../../../lib/app-identity.js"; -const USAGE_HINT = "sentry alert metrics delete /"; +const usageHint = () => sentry`alert metrics delete /`; type DeleteFlags = { readonly yes: boolean; @@ -62,7 +63,7 @@ export const deleteCommand = buildDeleteCommand({ positional: { kind: "tuple", parameters: [ - metricRuleParameter(USAGE_HINT, "Rule id or name (same as view)"), + metricRuleParameter(usageHint, "Rule id or name (same as view)"), ], }, }, @@ -73,14 +74,14 @@ export const deleteCommand = buildDeleteCommand({ ) { const { cwd } = this; const { ref, target } = await ruleArg.resolved; - requireExplicitTarget(target, "Metric alert target", USAGE_HINT); + requireExplicitTarget(target, "Metric alert target", usageHint()); const org = await resolveOrgOnlyTarget(target, cwd, "alert metrics delete"); const orgSlugs = [org]; const { orgSlug, rule } = await resolveMetricAlertRule( orgSlugs, ref, - USAGE_HINT, + usageHint(), ); const key = `${orgSlug}/${rule.id}`; const name = rule.name; diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 0c43ba548..7b24c8a21 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -46,9 +46,10 @@ import type { AlertRuleArgument } from "../arguments.js"; import { metricRuleParameter, resolveMetricAlertRule } from "./rule-resolve.js"; import type { MetricAlertEditOutput } from "../../../output-schemas/alert.js"; import { MetricAlertEditOutputSchema } from "../../../generated/output-schemas/alert.js"; +import { sentry } from "../../../lib/app-identity.js"; -const USAGE_HINT = - "sentry alert metrics edit / --name | --status active|disabled"; +const usageHint = () => + sentry`alert metrics edit / --name | --status active|disabled`; type EditFlags = { readonly name?: string; @@ -217,7 +218,7 @@ export const editCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - metricRuleParameter(USAGE_HINT, "Rule id or name (same as view)"), + metricRuleParameter(usageHint, "Rule id or name (same as view)"), ], }, flags: { @@ -310,7 +311,7 @@ export const editCommand = buildCommand({ const { orgSlug, rule } = await resolveMetricAlertRule( orgSlugs, ref, - USAGE_HINT, + usageHint(), ); const body = { ...(await getMetricAlertRuleDocument(orgSlug, rule.id)), diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index a71f8c430..af1c9d697 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -78,11 +78,12 @@ import { throwAlertListFetchFailure, } from "../list-utils.js"; import { metricAlertStatusLabel } from "./status.js"; +import { sentry } from "../../../lib/app-identity.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "alert-metrics-list"; -const USAGE_HINT = "sentry alert metrics list /"; +const usageHint = () => sentry`alert metrics list /`; const METRIC_ALERT_TARGET_POSITIONAL = { kind: "tuple" as const, @@ -134,7 +135,7 @@ type MetricAlertListResult = ListResult & { const metricAlertListMeta: ListCommandMeta = { paginationKey: PAGINATION_KEY, entityPlural: "metric alert rules", - commandPrefix: "sentry alert metrics list", + commandPrefix: () => sentry`alert metrics list`, }; // Fetch helpers @@ -193,7 +194,7 @@ async function resolveOrgs( } const { targets, footer } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), projectSearchResolution, }); return { @@ -225,7 +226,7 @@ async function resolveWebUrl( const { orgs } = await resolveOrgs(parsed, cwd, projectSearchResolution); const uniqueOrgs = [...new Set(orgs)]; if (uniqueOrgs.length === 0) { - throw new ContextError("Organization", USAGE_HINT); + throw new ContextError("Organization", usageHint()); } if (uniqueOrgs.length !== 1) { throw new ValidationError( @@ -254,7 +255,7 @@ async function handleResolvedOrgs( ); if (resolved.length === 0) { - throw new ContextError("Organization", USAGE_HINT); + throw new ContextError("Organization", usageHint()); } const uniqueOrgs = [...new Set(resolved)]; diff --git a/packages/cli/src/commands/alert/metrics/rule-resolve.ts b/packages/cli/src/commands/alert/metrics/rule-resolve.ts index 01026eceb..dd0fb473b 100644 --- a/packages/cli/src/commands/alert/metrics/rule-resolve.ts +++ b/packages/cli/src/commands/alert/metrics/rule-resolve.ts @@ -10,7 +10,7 @@ import { listMetricAlertsPaginated, type MetricAlertRule, } from "../../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../../lib/app-identity.js"; +import { type LazyCommand, sentry } from "../../../lib/app-identity.js"; import { ORG_TARGET } from "../../../lib/completion-target.js"; import { ResolutionError, ValidationError } from "../../../lib/errors.js"; import { fuzzyMatch } from "../../../lib/fuzzy.js"; @@ -39,7 +39,7 @@ export function parseMetricRuleArg( const trimmed = arg.trim(); if (!trimmed) { throw new ValidationError( - `Rule id or name is required.\nUse: ${asInvocationCommand(usageHint)}`, + `Rule id or name is required.\nUse: ${usageHint}`, "rule", ); } @@ -51,7 +51,7 @@ export function parseMetricRuleArg( const slashCount = countChar(trimmed, "/"); if (slashCount > 1) { throw new ValidationError( - `Metric alerts are org-scoped — use '/', not '//'.\nUse: ${asInvocationCommand(usageHint)}`, + `Metric alerts are org-scoped — use '/', not '//'.\nUse: ${usageHint}`, "rule", ); } @@ -61,7 +61,7 @@ export function parseMetricRuleArg( const ref = trimmed.slice(lastSlash + 1).trim(); if (!ref) { throw new ValidationError( - `Invalid rule reference '${arg}' (missing id or name after org).\nUse: ${asInvocationCommand(usageHint)}`, + `Invalid rule reference '${arg}' (missing id or name after org).\nUse: ${usageHint}`, "rule", ); } @@ -72,7 +72,7 @@ export function parseMetricRuleArg( * The `/` parameter of a metric alert command, * parsed with {@link parseMetricRuleArg} before the command runs. */ -export function metricRuleParameter(usageHint: string, brief: string) { +export function metricRuleParameter(usageHint: LazyCommand, brief: string) { return alertRuleParameter({ placeholder: "org/rule-id-or-name", brief, diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index 78915aeb2..ebb04601f 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -17,8 +17,9 @@ import { buildMetricAlertsUrl } from "../../../lib/sentry-web-urls.js"; import { isAllDigits } from "../../../lib/utils.js"; import type { AlertRuleArgument } from "../arguments.js"; import { metricRuleParameter, resolveMetricAlertRule } from "./rule-resolve.js"; +import { sentry } from "../../../lib/app-identity.js"; -const USAGE_HINT = "sentry alert metrics view /"; +const usageHint = () => sentry`alert metrics view /`; type ViewFlags = { readonly web: boolean; @@ -71,7 +72,7 @@ export const viewCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - metricRuleParameter(USAGE_HINT, "Metric alert rule ID or name"), + metricRuleParameter(usageHint, "Metric alert rule ID or name"), ], }, flags: { @@ -106,7 +107,7 @@ export const viewCommand = buildCommand({ return; } - const result = await resolveMetricAlertRule([org], ref, USAGE_HINT); + const result = await resolveMetricAlertRule([org], ref, usageHint()); // Name resolution uses the compact list projection; inspect the detail // document so it has the same conditions and links as an ID lookup. const rule = isAllDigits(ref) diff --git a/packages/cli/src/commands/build/download.ts b/packages/cli/src/commands/build/download.ts index 423594ff3..43268c88f 100644 --- a/packages/cli/src/commands/build/download.ts +++ b/packages/cli/src/commands/build/download.ts @@ -23,8 +23,9 @@ import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { resolveOrgRegion } from "../../lib/region.js"; import { resolveOrg } from "../../lib/resolve-target.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry build download "; +const usageHint = () => sentry`build download `; /** Structured result for `build download`. */ type BuildDownloadResult = { @@ -106,7 +107,7 @@ export const downloadCommand = buildCommand({ ) { const resolved = await resolveOrg({ cwd: this.cwd }); if (!resolved) { - throw new ContextError("Organization", USAGE_HINT); + throw new ContextError("Organization", usageHint()); } const { org } = resolved; @@ -115,7 +116,7 @@ export const downloadCommand = buildCommand({ throw new ResolutionError( `Build ${buildId}`, "is not installable", - USAGE_HINT, + usageHint(), [ "The build may still be processing, or it has no downloadable artifact.", ], diff --git a/packages/cli/src/commands/build/upload.ts b/packages/cli/src/commands/build/upload.ts index 809bde156..99289acd7 100644 --- a/packages/cli/src/commands/build/upload.ts +++ b/packages/cli/src/commands/build/upload.ts @@ -51,10 +51,11 @@ import { import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("build.upload"); -const USAGE_HINT = "sentry build upload "; +const usageHint = () => sentry`build upload `; /** Parse `--pr-number` as a non-negative integer. */ function parsePrNumber(value: string): number { @@ -347,10 +348,10 @@ export const uploadCommand = buildCommand({ const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index 9600394c3..e7461e997 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -59,7 +59,7 @@ type CodeMappingsUploadResult = { // Formatter -const USAGE_HINT = "sentry code-mappings upload "; +const usageHint = () => sentry`code-mappings upload `; /** Format human-readable output for upload results. */ function formatUploadResult(data: CodeMappingsUploadResult): string { @@ -246,10 +246,10 @@ export const uploadCommand = buildCommand({ // 2. Resolve org/project const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/dart-symbol-map/upload.ts b/packages/cli/src/commands/dart-symbol-map/upload.ts index 382c23665..6e19e9933 100644 --- a/packages/cli/src/commands/dart-symbol-map/upload.ts +++ b/packages/cli/src/commands/dart-symbol-map/upload.ts @@ -21,6 +21,7 @@ import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("dart-symbol-map.upload"); @@ -42,7 +43,7 @@ type DartSymbolMapUploadResult = { // Formatter -const USAGE_HINT = "sentry dart-symbol-map upload --debug-id "; +const usageHint = () => sentry`dart-symbol-map upload --debug-id `; /** Format human-readable output for upload results. */ function formatUploadResult(data: DartSymbolMapUploadResult): string { @@ -254,10 +255,10 @@ export const uploadCommand = buildCommand({ // 4. Resolve org/project const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/dashboard/arguments.ts b/packages/cli/src/commands/dashboard/arguments.ts index 8fa6a302e..71c387ed5 100644 --- a/packages/cli/src/commands/dashboard/arguments.ts +++ b/packages/cli/src/commands/dashboard/arguments.ts @@ -30,6 +30,7 @@ import { resolveDashboardId, resolveOrgFromTarget, } from "./resolve.js"; +import type { LazyCommand } from "../../lib/app-identity.js"; const log = logger.withTag("dashboard"); @@ -152,16 +153,16 @@ async function resolveWholeTitle( */ export function dashboardArguments(options: { readonly placeholder: string; - readonly usageHint: string; + readonly usageHint: LazyCommand; }) { const { placeholder, usageHint } = options; return boundArguments({ placeholder, brief: "[] ", kind: DASHBOARD_TARGET, - parse: (args) => parseDashboardInput(args, usageHint), + parse: (args) => parseDashboardInput(args, usageHint()), resolve: (input: DashboardInput, context: SentryContext) => - resolveDashboardInput(input, context.cwd, usageHint), + resolveDashboardInput(input, context.cwd, usageHint()), }); } @@ -199,7 +200,7 @@ function parseDashboardListInput(args: readonly string[]): DashboardListInput { * * @param usageHint - Usage shown when the org cannot be found */ -export function dashboardListArguments(usageHint: string) { +export function dashboardListArguments(usageHint: LazyCommand) { return boundArguments({ placeholder: "org/title-filter", brief: "[] []", @@ -207,7 +208,7 @@ export function dashboardListArguments(usageHint: string) { parse: parseDashboardListInput, resolve: (input: DashboardListInput, context: SentryContext) => { applyInputContext(input); - return resolveOrgFromTarget(input.target, context.cwd, usageHint); + return resolveOrgFromTarget(input.target, context.cwd, usageHint()); }, }); } diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index bc34d4d74..72dcb7d73 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -421,7 +421,7 @@ export const listCommand = buildListCommand("dashboard", { schema: DashboardListItemSchema, }, parameters: { - positional: dashboardListArguments("sentry dashboard list /"), + positional: dashboardListArguments(() => sentry`dashboard list /`), flags: { web: { kind: "boolean", diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 34f68d73a..4e13412b8 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -19,6 +19,7 @@ import { DashboardRestoreOutputSchema } from "../../generated/output-schemas/das import type { DashboardRestoreOutput } from "../../output-schemas/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "./arguments.js"; import { withDashboardErrors } from "./resolve.js"; +import { sentry } from "../../lib/app-identity.js"; type RestoreFlags = { readonly revision: string; @@ -78,7 +79,7 @@ export const restoreCommand = buildCommand({ parameters: { positional: dashboardArguments({ placeholder: "org/dashboard", - usageHint: "sentry dashboard restore / --revision ", + usageHint: () => sentry`dashboard restore / --revision `, }), flags: { revision: { diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 006a567a3..7ba86359f 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -146,7 +146,7 @@ export const revisionsCommand = buildCommand({ parameters: { positional: dashboardArguments({ placeholder: "org/dashboard", - usageHint: "sentry dashboard revisions / ", + usageHint: () => sentry`dashboard revisions / `, }), flags: { limit: buildListLimitFlag("revisions"), diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 7acc0b65a..48b76e68f 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -38,6 +38,7 @@ import type { import { type DashboardArguments, dashboardArguments } from "./arguments.js"; import { withDashboardErrors } from "./resolve.js"; import { DashboardViewOutputSchema } from "../../generated/output-schemas/dashboard.js"; +import { sentry } from "../../lib/app-identity.js"; /** Default auto-refresh interval in seconds */ const DEFAULT_REFRESH_INTERVAL = 60; @@ -217,7 +218,7 @@ export const viewCommand = buildCommand({ parameters: { positional: dashboardArguments({ placeholder: "org/project/dashboard", - usageHint: "sentry dashboard view / ", + usageHint: () => sentry`dashboard view / `, }), flags: { web: { diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index 5dae0becf..7d5a3aa80 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -69,8 +69,8 @@ type AddFlags = WidgetQueryFlags & readonly fields?: string[]; }; -const USAGE_HINT = - "sentry dashboard widget add / --display <type>"; +const usageHint = () => + sentry`dashboard widget add <org>/ <dashboard> <title> --display <type>`; /** `widget add` arguments: the dashboard, then the widget title. */ type WidgetAddInput = DashboardInput & { readonly title: string }; @@ -176,10 +176,10 @@ export const addCommand = buildCommand({ kind: DASHBOARD_TARGET, parse: (args): WidgetAddInput => { const { dashboardArgs, title } = parseAddPositionalArgs(args); - return { ...parseDashboardInput(dashboardArgs, USAGE_HINT), title }; + return { ...parseDashboardInput(dashboardArgs, usageHint()), title }; }, resolve: (input: WidgetAddInput, context: SentryContext) => - resolveDashboardInput(input, context.cwd, USAGE_HINT), + resolveDashboardInput(input, context.cwd, usageHint()), }), flags: { display: { diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index 41c61dff4..a5fec9aa2 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -26,6 +26,7 @@ import { import { type DashboardArguments, dashboardArguments } from "../arguments.js"; import { withDashboardErrors, resolveWidgetIndex } from "../resolve.js"; import { DashboardWidgetDeleteOutputSchema } from "../../../generated/output-schemas/dashboard.js"; +import { sentry } from "../../../lib/app-identity.js"; type DeleteFlags = { readonly index?: number; @@ -68,8 +69,8 @@ export const deleteCommand = buildDeleteCommand( parameters: { positional: dashboardArguments({ placeholder: "org/project/dashboard", - usageHint: - "sentry dashboard widget delete <org>/ <id> (--index <n> | --title <name>)", + usageHint: () => + sentry`dashboard widget delete <org>/ <id> (--index <n> | --title <name>)`, }), flags: { index: { diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index b4ce0535c..ba3f966eb 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -245,8 +245,8 @@ export const editCommand = buildCommand({ parameters: { positional: dashboardArguments({ placeholder: "org/project/dashboard", - usageHint: - "sentry dashboard widget edit <org>/ <dashboard> --title <name> --display <type>", + usageHint: () => + sentry`dashboard widget edit <org>/ <dashboard> --title <name> --display <type>`, }), flags: { index: { diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 7f136e680..7b3463421 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -18,7 +18,7 @@ import { readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { createSourceBundle } from "../../lib/dif/index.js"; import { parseFilePath } from "../../lib/completion-target.js"; @@ -34,7 +34,7 @@ import { readDebugFile } from "./read-file.js"; const log = logger.withTag("debug-files.bundle-sources"); -const USAGE_HINT = "sentry debug-files bundle-sources <path>"; +const usageHint = () => sentry`debug-files bundle-sources <path>`; /** Structured result for the bundle-sources command. */ type BundleSourcesResult = { @@ -161,7 +161,7 @@ export const bundleSourcesCommand = buildCommand({ fileCount: 0, }); return { - hint: `No source files found on disk for '${path}'. This is normally run on the build machine. Try: ${asInvocationCommand(USAGE_HINT)}`, + hint: `No source files found on disk for '${path}'. This is normally run on the build machine. Try: ${usageHint()}`, }; } diff --git a/packages/cli/src/commands/debug-files/check.ts b/packages/cli/src/commands/debug-files/check.ts index cd363dd4f..a9b1f75e6 100644 --- a/packages/cli/src/commands/debug-files/check.ts +++ b/packages/cli/src/commands/debug-files/check.ts @@ -13,7 +13,6 @@ */ import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type DifArchiveInfo, parseDebugFile } from "../../lib/dif/index.js"; import { parseFilePath } from "../../lib/completion-target.js"; @@ -25,8 +24,9 @@ import { } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { readDebugFile } from "./read-file.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry debug-files check <path>"; +const usageHint = () => sentry`debug-files check <path>`; /** Structured result for the check command. */ type DebugFilesCheckResult = DifArchiveInfo & { @@ -187,7 +187,7 @@ export const checkCommand = buildCommand({ // using it here keeps the exit-code observable in tests. this.process.exitCode = 1; return { - hint: `No usable debug information found. Try: ${asInvocationCommand(USAGE_HINT)}`, + hint: `No usable debug information found. Try: ${usageHint()}`, }; } }, diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index 1cde436a4..51ed1e631 100644 --- a/packages/cli/src/commands/debug-files/print-sources.ts +++ b/packages/cli/src/commands/debug-files/print-sources.ts @@ -14,7 +14,7 @@ import { inlineCode } from "@sentry/toolkit-core/markdown"; import { existsSync } from "node:fs"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type DifSourcesInfo, @@ -28,7 +28,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { readDebugFile } from "./read-file.js"; -const USAGE_HINT = "sentry debug-files print-sources <path>"; +const usageHint = () => sentry`debug-files print-sources <path>`; const log = logger.withTag("debug-files.print-sources"); @@ -194,7 +194,7 @@ export const printSourcesCommand = buildCommand({ if (objects.length === 0) { return { - hint: `No objects found in '${path}'. Try: ${asInvocationCommand(USAGE_HINT)}`, + hint: `No objects found in '${path}'. Try: ${usageHint()}`, }; } const total = objects.reduce((sum, object) => sum + object.files.length, 0); @@ -208,7 +208,7 @@ export const printSourcesCommand = buildCommand({ }; } return { - hint: `No referenced sources found in '${path}'. Try: ${asInvocationCommand(USAGE_HINT)}`, + hint: `No referenced sources found in '${path}'. Try: ${usageHint()}`, }; } return { diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index 99fc2e6d2..74f255f55 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -36,7 +36,6 @@ import { type DebugFileUploadResult, uploadDebugFiles, } from "../../lib/api/debug-files.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { createIl2cppLineMapping, @@ -64,10 +63,11 @@ import { import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("debug-files.upload"); -const USAGE_HINT = "sentry debug-files upload <path>..."; +const usageHint = () => sentry`debug-files upload <path>...`; /** Relative path to Xcode's DerivedData folder under the user's home dir. */ const DERIVED_DATA_SUBPATH = "Library/Developer/Xcode/DerivedData"; @@ -393,7 +393,7 @@ function* doDryRun( hint: oversizedCount > 0 ? `${oversizedCount} file(s) would be skipped for exceeding the maximum file size.` - : `No debug information files found. Try: ${asInvocationCommand(USAGE_HINT)}`, + : `No debug information files found. Try: ${usageHint()}`, }; } @@ -433,7 +433,7 @@ function* doNothingToUpload( } log.warn("No debug information files found."); return { - hint: `No debug information files found. Try: ${asInvocationCommand(USAGE_HINT)}`, + hint: `No debug information files found. Try: ${usageHint()}`, }; } @@ -712,7 +712,7 @@ export const uploadCommand = buildCommand({ async *func(this: SentryContext, flags: UploadFlags, ...paths: string[]) { const scanTargets = collectScanPaths(paths, Boolean(flags["derived-data"])); if (scanTargets.length === 0) { - throw new ContextError("Debug file path(s)", USAGE_HINT, []); + throw new ContextError("Debug file path(s)", usageHint(), []); } const { wait, maxWaitMs } = resolveWaitMode(flags); const requireAll = Boolean(flags["require-all"]); @@ -738,10 +738,10 @@ export const uploadCommand = buildCommand({ if (!flags["no-upload"]) { resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } serverOptions = await getChunkUploadOptions(resolved.org); if (serverOptions.maxFileSize && serverOptions.maxFileSize > 0) { @@ -785,7 +785,7 @@ export const uploadCommand = buildCommand({ // `resolved` is guaranteed set here: the non-dry-run branch above resolved // it or threw. if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } return yield* doUpload(setExitCode, { diff --git a/packages/cli/src/commands/dsn/list.ts b/packages/cli/src/commands/dsn/list.ts index 091e1304d..6a3c4e6cd 100644 --- a/packages/cli/src/commands/dsn/list.ts +++ b/packages/cli/src/commands/dsn/list.ts @@ -57,12 +57,12 @@ import { import type { ListCommandTarget } from "../../lib/target-positional.js"; const PAGINATION_KEY = "dsn-list"; -const USAGE_HINT = "sentry dsn list <org>/<project>"; +const usageHint = () => sentry`dsn list <org>/<project>`; const listConfig: ListCommandMeta = { paginationKey: PAGINATION_KEY, entityPlural: "DSNs", - commandPrefix: "sentry dsn list", + commandPrefix: () => sentry`dsn list`, }; /** List an organization's keys directly, preserving its cursor history. */ @@ -185,13 +185,13 @@ async function listForResolvedProjects< >(ctx: HandlerContext<T>): Promise<ListResult<DsnListItem>> { const resolution = await resolveProjectBoundTargets(ctx.parsed, { cwd: ctx.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), projectSearchResolution: ctx.projectSearchResolution, }); if (resolution.targets.length === 0) { throw new ContextError( "Organization and project", - USAGE_HINT, + usageHint(), undefined, resolution.skippedSelfHosted ? `Found ${resolution.skippedSelfHosted} DSN(s) that could not be resolved — you may not have access to these projects` diff --git a/packages/cli/src/commands/event/list.ts b/packages/cli/src/commands/event/list.ts index 371103f2d..b61c56e76 100644 --- a/packages/cli/src/commands/event/list.ts +++ b/packages/cli/src/commands/event/list.ts @@ -45,7 +45,7 @@ import { IssueEventSchema } from "../../generated/output-schemas/event.js"; const COMMAND_NAME = "list"; /** Base command prefix for error hints */ -const COMMAND_BASE = "sentry event"; +const commandBase = () => sentry`event`; /** Command key for pagination cursor storage */ const PAGINATION_KEY = "event-list"; @@ -132,7 +132,7 @@ export const listCommand = buildListCommand("event", { parameters: { positional: issuePositional({ command: COMMAND_NAME, - commandBase: COMMAND_BASE, + commandBase: commandBase, }), flags: EVENTS_FLAGS, aliases: EVENTS_ALIASES, @@ -155,7 +155,7 @@ export const listCommand = buildListCommand("event", { if (!org) { throw new ContextError( "Organization", - buildCommandHint(COMMAND_NAME, issueArg, COMMAND_BASE), + buildCommandHint(COMMAND_NAME, issueArg, commandBase()), ); } diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index 485d7690f..e319c074f 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -17,7 +17,7 @@ import { type ResolvedEvent, resolveEventInOrg, } from "../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { type IssueSelector, issueSelectorHelp, @@ -212,7 +212,7 @@ function viewOutputHint( } /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry event view <org>/<project> <event-id>"; +const usageHint = () => sentry`event view <org>/<project> <event-id>`; /** * Expand positional args by splitting each on newlines. @@ -238,14 +238,14 @@ const LATEST_EVENT_SENTINEL = "@latest"; const DEFAULT_SELECTOR: IssueSelector = "@latest"; /** Usage hint for magic selectors */ -const SELECTOR_HINT = "sentry event view [<org>/[<project>/]]@latest"; +const selectorHint = () => sentry`event view [<org>/[<project>/]]@latest`; /** * Shared usage hint shown when a bare "latest" is passed without an issue. * There is no "latest event of a project" API — the latest-event lookup is * keyed by issue, so the user must supply an issue ID (or short ID). */ -const BARE_LATEST_HINT = "sentry event view <issue-id>"; +const bareLatestHint = () => sentry`event view <issue-id>`; /** * The arguments of `event view`, parsed. Parsing has no side effects: the @@ -329,7 +329,7 @@ export function parseEventViewInput(args: readonly string[]): EventViewInput { let parsed: ReturnType<typeof parseIdArguments>; try { parsed = parseIdArguments(tokens, ID_KINDS.event, { - usageHint: USAGE_HINT, + usageHint: usageHint(), multiple: true, needsProject: true, validate: false, @@ -364,9 +364,9 @@ export function parseEventViewInput(args: readonly string[]): EventViewInput { if (input.eventId.toLowerCase() === "latest") { // There is no "latest event of a project" endpoint — the latest-event // lookup is keyed by issue, so a bare "latest" has nothing to resolve - throw new ContextError("Issue ID", BARE_LATEST_HINT, [ + throw new ContextError("Issue ID", bareLatestHint(), [ `'latest' resolves the newest event of an issue — pass an issue ID, e.g. '${sentry`event view 17370`}'`, - `Or use the @latest selector for the latest event of the most recent unresolved issue: ${SELECTOR_HINT}`, + `Or use the @latest selector for the latest event of the most recent unresolved issue: ${selectorHint()}`, ]); } const target = parseOrgProjectInput(input.targetArg); @@ -419,7 +419,7 @@ function selectorInput(tokens: readonly string[]): IssueInput | null { otherIsId ) { throw new ValidationError( - `'${token}' selects one event; pass it alone or after a target, not with IDs: ${SELECTOR_HINT}`, + `'${token}' selects one event; pass it alone or after a target, not with IDs: ${selectorHint()}`, "positional", ); } @@ -451,7 +451,7 @@ function targetOnlyInput( return null; } const parsed = parseIdArguments(tokens, ID_KINDS.event, { - usageHint: USAGE_HINT, + usageHint: usageHint(), multiple: true, optional: true, needsProject: true, @@ -484,7 +484,7 @@ function issueFromUrl(url: ParsedSentryUrl): IssueInput | null { issueId: url.issueId, }; } - throw new ContextError("Event ID", USAGE_HINT, [ + throw new ContextError("Event ID", usageHint(), [ "Pass an event URL: https://sentry.io/organizations/{org}/issues/{id}/events/{eventId}/", "Or an issue URL to view the latest event: https://sentry.io/organizations/{org}/issues/{id}/", ]); @@ -691,7 +691,7 @@ export async function resolveEventTarget( case ProjectSpecificationType.ProjectSearch: { const resolved = await resolveProjectBoundSlug( parsed.projectSlug, - USAGE_HINT, + usageHint(), sentry`event view <org>/${parsed.projectSlug} ${eventId}`, parsed.originalSlug, ); @@ -769,7 +769,10 @@ export async function resolveAutoDetectTarget( eventId: string, cwd: string, ): Promise<ResolvedEventTarget | null> { - const autoTarget = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + const autoTarget = await resolveOrgAndProject({ + cwd, + usageHint: usageHint(), + }); if (autoTarget) { return autoTarget; } @@ -1039,14 +1042,14 @@ async function resolveSelectorScope( case ProjectSpecificationType.ProjectSearch: return await resolveProjectBoundSlug( parsed.projectSlug, - SELECTOR_HINT, + selectorHint(), sentry`event view <org>/${parsed.projectSlug}/${selector}`, parsed.originalSlug, ); case ProjectSpecificationType.AutoDetect: { const resolved = await resolveOrg({ cwd }); if (!resolved) { - throw new ContextError("Organization", SELECTOR_HINT); + throw new ContextError("Organization", selectorHint()); } return { org: resolved.org }; } @@ -1084,7 +1087,7 @@ async function resolveSelectorEvent( return { org, data, - hint: `Showing the latest event of ${issue.shortId} (${selector}). Pass an event ID to view a specific one: ${asInvocationCommand(USAGE_HINT)}`, + hint: `Showing the latest event of ${issue.shortId} (${selector}). Pass an event ID to view a specific one: ${usageHint()}`, }; } @@ -1485,7 +1488,7 @@ export const viewCommand = buildCommand({ }); if (!target) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } if (flags.web) { diff --git a/packages/cli/src/commands/integration/delete.ts b/packages/cli/src/commands/integration/delete.ts index dfe6f42db..eb2357399 100644 --- a/packages/cli/src/commands/integration/delete.ts +++ b/packages/cli/src/commands/integration/delete.ts @@ -10,7 +10,6 @@ import { inlineCode } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { deleteIntegration, getIntegration } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; import { ORG_TARGET } from "../../lib/completion-target.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; import { renderMarkdown } from "../../lib/formatters/markdown.js"; @@ -33,9 +32,10 @@ import { parseOrgInput, resolveOrgInput, } from "../../lib/target-positional.js"; +import { sentry } from "../../lib/app-identity.js"; const USAGE = "[<org>/]<integration-id>"; -const USAGE_HINT = `sentry integration delete ${USAGE}`; +const usageHint = () => sentry`integration delete ${USAGE}`; const log = logger.withTag("integration.delete"); @@ -110,17 +110,17 @@ export function parseIntegrationArguments( const { target, ids, baseUrl, warnings } = parseIdArguments( args, ID_KINDS.integration, - { usageHint: USAGE_HINT, multiple: true }, + { usageHint: usageHint(), multiple: true }, ); // Not an ignored extra: deleting one of several IDs would surprise if (ids.length > 1) { throw new ValidationError( - `Remove one integration at a time, not ${ids.length}: ${asInvocationCommand(USAGE_HINT)}`, + `Remove one integration at a time, not ${ids.length}: ${usageHint()}`, "integration-id", ); } const [id = ""] = ids; - const org = parseOrgInput(target, USAGE_HINT); + const org = parseOrgInput(target, usageHint()); return { id, org: baseUrl ? { ...org, baseUrl } : org, warnings }; } @@ -195,7 +195,7 @@ export const deleteCommand = buildDeleteCommand({ const { org } = await resolveOrgInput( input.org, context.cwd, - USAGE_HINT, + usageHint(), ); return { org, id: input.id }; }, diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index b26a7c0be..bc9e21558 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -18,6 +18,7 @@ import { import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; import type { SentryIntegration } from "../../types/index.js"; import { IntegrationListItemSchema } from "../../generated/output-schemas/integration.js"; +import { sentry } from "../../lib/app-identity.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "integration-list"; @@ -43,7 +44,7 @@ const integrationListConfig: OrgListConfig< > = { paginationKey: PAGINATION_KEY, entityPlural: "integrations", - commandPrefix: "sentry integration list", + commandPrefix: () => sentry`integration list`, listForOrg: (org) => listIntegrations(org), listPaginated: (org, opts) => listIntegrationsPaginated(org, opts), withOrg: (integration, orgSlug) => ({ ...integration, orgSlug }), diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index 8cd367883..7ccf86de9 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -32,7 +32,7 @@ import { } from "./utils.js"; const log = logger.withTag("issue.explain"); -const USAGE_HINT = "sentry issue explain <issue> [<issue>...]"; +const usageHint = () => sentry`issue explain <issue> [<issue>...]`; type ExplainFlags = { readonly json: boolean; @@ -142,7 +142,7 @@ export const explainCommand = buildCommand({ parameters: { positional: issuesPositional({ brief: "One or more issue IDs", - usageHint: USAGE_HINT, + usageHint: usageHint, distinct: true, }), flags: { diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index f674bac31..784bc0f85 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -185,7 +185,7 @@ function defaultIssueSort(): SortValue { } /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry issue list <org>/<project>"; +const usageHint = () => sentry`issue list <org>/<project>`; /** Options returned by {@link buildListApiOptions}. */ type ListApiOptions = { @@ -1219,7 +1219,7 @@ async function handleResolvedTargets( const { targets, footer, skippedSelfHosted, detectedDsns, soleOrg } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), formatRecoveryCommand: (target) => contextRecoveryCommand(target, flags), projectSearchResolution, enrichProjectIds: true, @@ -1237,12 +1237,12 @@ async function handleResolvedTargets( if (skippedSelfHosted) { throw new ContextError( "Organization and project", - USAGE_HINT, + usageHint(), undefined, `Found ${skippedSelfHosted} DSN(s) that could not be resolved — you may not have access to these projects`, ); } - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } // Build a compound cursor context key that encodes the full target set + @@ -1517,7 +1517,7 @@ async function handleResolvedTargets( const issueListMeta: ListCommandMeta = { paginationKey: PAGINATION_KEY, entityPlural: "issues", - commandPrefix: "sentry issue list", + commandPrefix: () => sentry`issue list`, }; /** diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 1cadc34a8..76d09526e 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -46,7 +46,7 @@ const log = logger.withTag("issue.merge"); const COMMAND = "merge"; /** Full command path for error messages (this one isn't passed to resolveIssue). */ const COMMAND_PATH = "issue merge"; -const USAGE_HINT = "sentry issue merge <issue> <issue> [<issue>...]"; +const usageHint = () => sentry`issue merge <issue> <issue> [<issue>...]`; type MergeFlags = { readonly json: boolean; @@ -302,7 +302,7 @@ export const mergeCommand = buildCommand({ // counts `--into` too positional: issuesPositional({ brief: "Issue IDs to merge (2 or more required)", - usageHint: USAGE_HINT, + usageHint: usageHint, optional: true, }), flags: { diff --git a/packages/cli/src/commands/issue/positional.ts b/packages/cli/src/commands/issue/positional.ts index e776dc866..76cbaaf8d 100644 --- a/packages/cli/src/commands/issue/positional.ts +++ b/packages/cli/src/commands/issue/positional.ts @@ -8,7 +8,7 @@ * pasted issue URL is applied. See `positional-parse.ts`. */ -import { sentry } from "../../lib/app-identity.js"; +import { type LazyCommand, sentry } from "../../lib/app-identity.js"; import { ISSUE_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -38,10 +38,13 @@ export type IssueTarget = BoundPositional< type IssueCommand = { /** The subcommand, e.g. `"view"` */ readonly command: string; - /** The command before it (default: `"sentry issue"`) */ - readonly commandBase?: string; + /** The command before it (default: `sentry issue`) */ + readonly commandBase?: LazyCommand; }; +/** The default {@link IssueCommand.commandBase}. */ +const issueBase = () => sentry`issue`; + /** What the issue resolver needs from the command context. */ type IssueTargetContext = { readonly cwd: string }; @@ -67,8 +70,8 @@ export function requireIssueArgument( if (issue) { return issue; } - const { command, commandBase = sentry`issue` } = issueCommand; - throw new ContextError("Issue ID", `${commandBase} ${command} <issue>`); + const { command, commandBase = issueBase } = issueCommand; + throw new ContextError("Issue ID", `${commandBase()} ${command} <issue>`); } /** @@ -79,7 +82,7 @@ export function requireIssueArgument( function issueTarget( issueCommand: IssueCommand, ): (this: IssueTargetContext, input?: string) => IssueTarget { - const { command, commandBase = sentry`issue` } = issueCommand; + const { command, commandBase = issueBase } = issueCommand; return boundPositional( ISSUE_TARGET, parseIssueTargetInput, @@ -88,7 +91,7 @@ function issueTarget( issueArg: requireIssueArgument(issue, issueCommand), cwd: context.cwd, command, - commandBase, + commandBase: commandBase(), }), ); } @@ -135,7 +138,7 @@ export type IssuesArguments = BoundArguments< */ export function issuesPositional(options: { readonly brief: string; - readonly usageHint: string; + readonly usageHint: LazyCommand; readonly distinct?: boolean; readonly optional?: boolean; }) { @@ -145,7 +148,10 @@ export function issuesPositional(options: { brief, kind: ISSUE_TARGET, parse: (raw): readonly IssueArgument[] => { - const issues = parseIssueArguments(raw, { usageHint, optional }); + const issues = parseIssueArguments(raw, { + usageHint: usageHint(), + optional, + }); if (!distinct) { return issues; } diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index b0923cb87..57b15c434 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -39,7 +39,7 @@ import { IssueViewOutputSchema } from "../../generated/output-schemas/issue.js"; const log = logger.withTag("issue.view"); /** Usage hint for a missing issue */ -const USAGE_HINT = "sentry issue view <issue> [<issue>...]"; +const usageHint = () => sentry`issue view <issue> [<issue>...]`; /** Maximum browser tabs opened without an explicit safety override. */ export const MAX_WEB_ISSUES = 5; @@ -401,7 +401,7 @@ export const viewCommand = buildCommand({ parameters: { positional: issuesPositional({ brief: "One or more issue IDs", - usageHint: USAGE_HINT, + usageHint: usageHint, distinct: true, }), flags: { diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index c9973fb8d..6efb60c7c 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -119,7 +119,7 @@ const DEFAULT_POLL_INTERVAL = 2; const COMMAND_NAME = "log list"; /** Usage hint for trace mode error messages */ -const TRACE_USAGE_HINT = "sentry log list [<org>/[<project>/]]<trace-id>"; +const traceUsageHint = () => sentry`log list [<org>/[<project>/]]<trace-id>`; /** * Parse --limit flag, delegating range validation to shared utility. @@ -194,7 +194,7 @@ type LogListArguments = BoundArguments<LogListInput, LogListTarget>; * (trace mode when the arguments end in a trace ID). */ function parseLogListInput(args: readonly string[]): LogListInput { - const input = parseDualModeInput(args, TRACE_USAGE_HINT); + const input = parseDualModeInput(args, traceUsageHint()); if (input.mode === "trace") { return input; } @@ -212,7 +212,7 @@ async function resolveLogListTarget( const { traceId, org } = await resolveTraceOrg( parsed, context.cwd, - TRACE_USAGE_HINT, + traceUsageHint(), ); return { mode: "trace", diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 15f4a2753..966dd4f04 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -70,7 +70,7 @@ type ViewFlags = { }; /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry log view <org>/<project> <log-id> [<log-id>...]"; +const usageHint = () => sentry`log view <org>/<project> <log-id> [<log-id>...]`; /** * Parse positional arguments for log view. @@ -99,7 +99,7 @@ export function parsePositionalArgs(args: readonly string[]): { suggestion?: string; } { const { target, ids, warnings } = parseIdArguments(args, ID_KINDS.log, { - usageHint: USAGE_HINT, + usageHint: usageHint(), multiple: true, needsProject: true, validate: false, @@ -178,7 +178,7 @@ async function resolveTarget( case "project-search": { const result = await resolveProjectBoundSlug( parsed.projectSlug, - USAGE_HINT, + usageHint(), sentry`log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, parsed.originalSlug, ); @@ -195,10 +195,10 @@ async function resolveTarget( } case "org-all": - throw new ContextError("Specific project", USAGE_HINT, []); + throw new ContextError("Specific project", usageHint(), []); case "auto-detect": - return await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + return await resolveOrgAndProject({ cwd, usageHint: usageHint() }); default: { const _exhaustiveCheck: never = parsed; @@ -250,7 +250,7 @@ async function resolveLogViewInput( context.cwd, ); if (!target) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } // `log.warn` preserves emission order regardless of await order, and a // throwing recovery (multi-match ResolutionError) exits via diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index 6dfcf73b2..cb47051ec 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -22,6 +22,7 @@ import { import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; import type { SentryMonitor } from "../../types/index.js"; import { MonitorListItemSchema } from "../../generated/output-schemas/monitor.js"; +import { sentry } from "../../lib/app-identity.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "monitor-list"; @@ -67,7 +68,7 @@ const monitorListConfig: OrgListConfig< > = { paginationKey: PAGINATION_KEY, entityPlural: "monitors", - commandPrefix: "sentry monitor list", + commandPrefix: () => sentry`monitor list`, listForOrg: (org) => listMonitors(org), listPaginated: (org, opts) => listMonitorsPaginated(org, opts), withOrg: (monitor, orgSlug) => ({ ...monitor, orgSlug }), diff --git a/packages/cli/src/commands/monitor/run.ts b/packages/cli/src/commands/monitor/run.ts index 1a902e9a1..dbd624929 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -24,7 +24,6 @@ import { uuid4, } from "@sentry/core"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { buildCheckIn, @@ -45,11 +44,12 @@ import { } from "../../lib/completion-target.js"; import { CliError, ConfigError, ValidationError } from "../../lib/errors.js"; import { logger } from "../../lib/logger.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("monitor.run"); /** Usage hint shown when no command is provided. */ -const USAGE_HINT = "sentry monitor run <monitor-slug> -- <command>"; +const usageHint = () => sentry`monitor run <monitor-slug> -- <command>`; type RunFlags = CheckInConfigFlags & { dsn?: string; @@ -85,7 +85,7 @@ async function resolveCheckInDsn( throw new ConfigError( "No DSN found. Provide one via --dsn <dsn>, set the SENTRY_DSN environment variable, or run from a project where a DSN can be detected.", - USAGE_HINT, + usageHint(), ); } @@ -284,13 +284,13 @@ The wrapped command receives the \`SENTRY_MONITOR_SLUG\` environment variable.`, if (!monitorSlug) { throw new ValidationError( - `No monitor slug provided. Usage: ${asInvocationCommand(USAGE_HINT)}`, + `No monitor slug provided. Usage: ${usageHint()}`, "monitor-slug", ); } if (command.length === 0) { throw new ValidationError( - `No command provided. Usage: ${asInvocationCommand(USAGE_HINT)}`, + `No command provided. Usage: ${usageHint()}`, "command", ); } diff --git a/packages/cli/src/commands/proguard/upload.ts b/packages/cli/src/commands/proguard/upload.ts index cdf546248..3ed0da10f 100644 --- a/packages/cli/src/commands/proguard/upload.ts +++ b/packages/cli/src/commands/proguard/upload.ts @@ -20,6 +20,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { computeProguardUuid } from "../../lib/proguard.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("proguard.upload"); @@ -45,7 +46,7 @@ type ProguardUploadResult = { // ── Formatter ─────────────────────────────────────────────────────── -const USAGE_HINT = "sentry proguard upload <path>..."; +const usageHint = () => sentry`proguard upload <path>...`; /** Format human-readable output for upload results. */ function formatUploadResult(data: ProguardUploadResult): string { @@ -235,7 +236,7 @@ export const uploadCommand = buildCommand({ "path", ); } - throw new ContextError("Mapping file path(s)", USAGE_HINT, []); + throw new ContextError("Mapping file path(s)", usageHint(), []); } // 2. Validate --uuid with multiple files is ambiguous @@ -279,10 +280,10 @@ export const uploadCommand = buildCommand({ // 7. Resolve org/project const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index c46c2052e..bc54b1555 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -26,7 +26,7 @@ import { listTeams, MEMBER_PROJECT_CREATION_DISABLED_DETAIL, } from "../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { parseOrgProjectInput } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { parseTeam, PROJECT_SPEC_TARGET } from "../../lib/completion-target.js"; @@ -69,7 +69,7 @@ const log = logger.withTag("project.create"); const WHITESPACE_RE = /\s/; /** Full usage hint shown in errors and help text. */ -const USAGE_HINT = "sentry project create [<org>/]<name>:<platform>..."; +const usageHint = () => sentry`project create [<org>/]<name>:<platform>...`; type CreateFlags = { readonly team?: string; @@ -194,7 +194,7 @@ async function handleCreateProject404(opts: { // listTeams returned 404 → org doesn't exist // Delegates to shared helper that handles DSN org ID resolution and org listing if (listTeamsError instanceof ApiError && listTeamsError.status === 404) { - return await buildOrgNotFoundError(orgSlug, USAGE_HINT, detectedFrom); + return await buildOrgNotFoundError(orgSlug, usageHint(), detectedFrom); } // listTeams failed for other reasons (403, 5xx, network) — can't disambiguate @@ -228,7 +228,7 @@ async function resolveDryRunTeam( return await resolveOrCreateTeam(orgSlug, { team: opts.team, detectedFrom: opts.detectedFrom, - usageHint: USAGE_HINT, + usageHint: usageHint(), autoCreateSlug: opts.autoCreateSlug, dryRun: true, }); @@ -438,13 +438,13 @@ function parseProjectName( platform, }; case "org-all": - throw new ContextError("Project name", USAGE_HINT, [ + throw new ContextError("Project name", usageHint(), [ `'${rawName}' looks like an org, not a project name.`, ]); case "auto-detect": throw new ValidationError("Project name cannot be empty.", "name"); default: - throw new ContextError("Project name", USAGE_HINT, []); + throw new ContextError("Project name", usageHint(), []); } } @@ -504,7 +504,7 @@ function parsePairedProjectSpec( */ function parseProjectSpecs(rawSpecs: readonly string[]): ProjectSpecsInput { if (rawSpecs.length === 0) { - throw new ContextError("Project specification", USAGE_HINT, []); + throw new ContextError("Project specification", usageHint(), []); } if (rawSpecs.length === 1 && !rawSpecs[0]?.includes(":")) { @@ -552,8 +552,8 @@ async function resolveProjectSpecsOrg( } const resolved = await resolveOrg({ org: input.explicitOrg, cwd }); if (!resolved) { - throw new ContextError("Organization", USAGE_HINT, [ - `Include org in name: ${asInvocationCommand(USAGE_HINT)}`, + throw new ContextError("Organization", usageHint(), [ + `Include org in name: ${usageHint()}`, ]); } return resolved; @@ -620,7 +620,7 @@ async function createOneProject(opts: { const team: ResolvedTeam = await resolveOrCreateTeam(orgSlug, { team: flags.team, detectedFrom, - usageHint: USAGE_HINT, + usageHint: usageHint(), autoCreateSlug, }); teamSlug = team.slug; diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index d4f2d3662..e9e8bc44d 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -846,7 +846,7 @@ function handleProjectSearchNotFound( const projectListMeta: ListCommandMeta = { paginationKey: PAGINATION_KEY, entityPlural: "projects", - commandPrefix: "sentry project list", + commandPrefix: () => sentry`project list`, }; export const listCommand = buildListCommand("project", { diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index ac3728605..c26b756c6 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -11,7 +11,7 @@ import { resolveOrgDisplayName, tryGetPrimaryDsn, } from "../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -52,7 +52,7 @@ type ViewFlags = { }; /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry project view <org>/<project>"; +const usageHint = () => sentry`project view <org>/<project>`; /** * Build an error message for missing context, with optional DSN resolution hint. @@ -61,13 +61,13 @@ function buildContextError(skippedSelfHosted?: number): ContextError { if (skippedSelfHosted) { return new ContextError( "Organization and project", - USAGE_HINT, + usageHint(), undefined, `Found ${skippedSelfHosted} DSN(s) that could not be resolved — you may not have access to these projects`, ); } - return new ContextError("Organization and project", USAGE_HINT); + return new ContextError("Organization and project", usageHint()); } /** @@ -76,13 +76,9 @@ function buildContextError(skippedSelfHosted?: number): ContextError { */ async function handleWebView(resolvedTargets: ResolvedTarget[]): Promise<void> { if (resolvedTargets.length > 1) { - throw new ContextError( - "Single project", - `${asInvocationCommand(USAGE_HINT)} -w`, - [ - `Found ${resolvedTargets.length} projects — specify which project to open in browser`, - ], - ); + throw new ContextError("Single project", `${usageHint()} -w`, [ + `Found ${resolvedTargets.length} projects — specify which project to open in browser`, + ]); } const target = resolvedTargets[0]; @@ -296,7 +292,7 @@ async function resolveViewTargets( // Search for project across all orgs - single target const resolved = await resolveProjectBoundSlug( parsed.projectSlug, - USAGE_HINT, + usageHint(), sentry`project view <org>/${parsed.projectSlug}`, parsed.originalSlug, ); @@ -332,7 +328,7 @@ async function resolveViewTargets( } default: - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } } diff --git a/packages/cli/src/commands/react-native/gradle.ts b/packages/cli/src/commands/react-native/gradle.ts index 51d09e86e..bf9017d6c 100644 --- a/packages/cli/src/commands/react-native/gradle.ts +++ b/packages/cli/src/commands/react-native/gradle.ts @@ -29,11 +29,12 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import { injectDebugId } from "../../lib/sourcemap/debug-id.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("react-native.gradle"); -const USAGE_HINT = - "sentry react-native gradle --sourcemap <path> --bundle <path>"; +const usageHint = () => + sentry`react-native gradle --sourcemap <path> --bundle <path>`; /** Little-endian magic (0xFB0BD1E5) that starts an indexed RAM bundle. */ const RAM_BUNDLE_MAGIC = [0xe5, 0xd1, 0x0b, 0xfb]; @@ -199,10 +200,10 @@ export const gradleCommand = buildCommand({ const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/react-native/xcode.ts b/packages/cli/src/commands/react-native/xcode.ts index fc980215a..95c26a1cd 100644 --- a/packages/cli/src/commands/react-native/xcode.ts +++ b/packages/cli/src/commands/react-native/xcode.ts @@ -46,10 +46,11 @@ import { resolveReleaseAndDist, } from "../../lib/react-native/xcode-env.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("react-native.xcode"); -const USAGE_HINT = "sentry react-native xcode"; +const usageHint = () => sentry`react-native xcode`; const DEFAULT_PACKAGER_URL = "http://127.0.0.1:8081/"; const DEFAULT_BUILD_SCRIPT = "../node_modules/react-native/scripts/react-native-xcode.sh"; @@ -305,10 +306,10 @@ async function uploadPair( }> { const resolved = await resolveOrgAndProject({ cwd: ctx.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/release/archive.ts b/packages/cli/src/commands/release/archive.ts index d9ac6da89..80f3c74d3 100644 --- a/packages/cli/src/commands/release/archive.ts +++ b/packages/cli/src/commands/release/archive.ts @@ -20,8 +20,9 @@ import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; import type { ReleaseUpdateOutput } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { ReleaseUpdateOutputSchema } from "../../generated/output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry release archive [<org>/]<version>"; +const usageHint = () => sentry`release archive [<org>/]<version>`; /** * Render the human-readable result of archiving a release. @@ -76,7 +77,7 @@ export const archiveCommand = buildCommand({ kind: "tuple", parameters: [ releaseParameter( - USAGE_HINT, + usageHint, "[<org>/]<version> - Release version to archive", ), ], diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index d49b03ece..fae46f919 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -26,7 +26,7 @@ import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { ReleaseCreateOutputSchema } from "../../generated/output-schemas/release.js"; import type { ReleaseCreateOutputFields } from "../../output-schemas/release.js"; -const USAGE_HINT = "sentry release create [<org>/]<version>"; +const usageHint = () => sentry`release create [<org>/]<version>`; /** The release `--dry-run` reports: the request body, shaped like a release. */ type ReleaseCreateDryRun = WithOutputFields< @@ -126,7 +126,7 @@ export const createCommand = buildCommand({ kind: "tuple", parameters: [ releaseParameter( - USAGE_HINT, + usageHint, "[<org>/]<version> - Release version to create", ), ], diff --git a/packages/cli/src/commands/release/delete.ts b/packages/cli/src/commands/release/delete.ts index d318769a1..a10f92bac 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -22,8 +22,9 @@ import { buildReleaseUrl } from "../../lib/sentry-web-urls.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { ReleaseDeleteOutputSchema } from "../../generated/output-schemas/release.js"; import type { ReleaseDeleteOutput } from "../../output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry release delete [<org>/]<version>"; +const usageHint = () => sentry`release delete [<org>/]<version>`; type DeleteResult = ReleaseDeleteOutput; @@ -104,7 +105,7 @@ export const deleteCommand = buildDeleteCommand({ kind: "tuple", parameters: [ releaseParameter( - USAGE_HINT, + usageHint, "[<org>/]<version> - Release version to delete", ), ], diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index e1d509594..1ce169957 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -23,9 +23,10 @@ import type { SentryDeploy, WithOutputFields } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { ReleaseDeployOutputSchema } from "../../generated/output-schemas/release.js"; import type { ReleaseDeployOutputFields } from "../../output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = - "sentry release deploy [<org>/]<version> <environment> [name]"; +const usageHint = () => + sentry`release deploy [<org>/]<version> <environment> [name]`; /** JSON output: the created deploy, or the `--dry-run` preview. */ type DeployOutput = WithOutputFields< @@ -75,7 +76,7 @@ function parseDeployArgs( name?: string, ): { environment: string; name?: string } { if (!environment) { - throw new ContextError("Environment", USAGE_HINT, []); + throw new ContextError("Environment", usageHint(), []); } return { environment, name: name || undefined }; } @@ -117,7 +118,7 @@ export const deployCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - releaseParameter(USAGE_HINT, "[<org>/]<version> - Release version"), + releaseParameter(usageHint, "[<org>/]<version> - Release version"), { placeholder: "environment", brief: "Deploy environment (e.g. production)", diff --git a/packages/cli/src/commands/release/deploys.ts b/packages/cli/src/commands/release/deploys.ts index 35827b5ab..2384dba4d 100644 --- a/packages/cli/src/commands/release/deploys.ts +++ b/packages/cli/src/commands/release/deploys.ts @@ -13,8 +13,9 @@ import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import type { SentryDeploy } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { SentryDeploySchema } from "../../generated/output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry release deploys [<org>/]<version>"; +const usageHint = () => sentry`release deploys [<org>/]<version>`; const DEPLOY_COLUMNS: Column<SentryDeploy>[] = [ { header: "ENVIRONMENT", value: (d) => d.environment }, @@ -59,7 +60,7 @@ export const deploysCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - releaseParameter(USAGE_HINT, "[<org>/]<version> - Release version"), + releaseParameter(usageHint, "[<org>/]<version> - Release version"), ], }, }, diff --git a/packages/cli/src/commands/release/finalize.ts b/packages/cli/src/commands/release/finalize.ts index ca01ac49e..ff17361c1 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -20,8 +20,9 @@ import type { SentryRelease, WithOutputFields } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { ReleaseFinalizeOutputSchema } from "../../generated/output-schemas/release.js"; import type { ReleaseFinalizeOutputFields } from "../../output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry release finalize [<org>/]<version>"; +const usageHint = () => sentry`release finalize [<org>/]<version>`; /** JSON output: the finalized release, or the `--dry-run` preview. */ type FinalizeOutput = WithOutputFields< @@ -79,7 +80,7 @@ export const finalizeCommand = buildCommand({ kind: "tuple", parameters: [ releaseParameter( - USAGE_HINT, + usageHint, "[<org>/]<version> - Release version to finalize", ), ], diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index 7212a200b..7f30d7016 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -261,7 +261,7 @@ function buildReleaseListConfig( return { paginationKey: PAGINATION_KEY, entityPlural: "releases", - commandPrefix: sentry`release list`, + commandPrefix: () => sentry`release list`, listForOrg: async (org) => { const { data } = await listReleasesPaginated(org, { perPage: 100, diff --git a/packages/cli/src/commands/release/parse.ts b/packages/cli/src/commands/release/parse.ts index 58a9f8506..830af906f 100644 --- a/packages/cli/src/commands/release/parse.ts +++ b/packages/cli/src/commands/release/parse.ts @@ -6,7 +6,6 @@ * slug separators. This module provides version-aware parsing. */ -import { asInvocationCommand } from "../../lib/app-identity.js"; import { ORG_TARGET } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { @@ -14,6 +13,7 @@ import { boundPositional, } from "../../lib/positional-parse.js"; import { resolveOrg } from "../../lib/resolve-target.js"; +import type { LazyCommand } from "../../lib/app-identity.js"; /** Slug pattern: lowercase alphanumeric + hyphens, no leading/trailing hyphen */ const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; @@ -56,7 +56,7 @@ export function parseReleaseArg( if (!arg) { throw new ValidationError( - `Release version is required.\n\n Usage: ${asInvocationCommand(usageHint)}`, + `Release version is required.\n\n Usage: ${usageHint}`, "version", ); } @@ -109,20 +109,20 @@ function parseReleaseInput( * @param usageHint - Single-line usage example for error messages * @param brief - Help text of the parameter */ -export function releaseParameter(usageHint: string, brief: string) { +export function releaseParameter(usageHint: LazyCommand, brief: string) { return { placeholder: "org/version", brief, parse: boundPositional( ORG_TARGET, - (raw) => parseReleaseInput(raw, usageHint), + (raw) => parseReleaseInput(raw, usageHint()), async ( { version, orgSlug }, context: ReleaseTargetContext, ): Promise<ResolvedReleaseTarget> => { const resolved = await resolveOrg({ org: orgSlug, cwd: context.cwd }); if (!resolved) { - throw new ContextError("Organization", usageHint); + throw new ContextError("Organization", usageHint()); } return { version, diff --git a/packages/cli/src/commands/release/restore.ts b/packages/cli/src/commands/release/restore.ts index 96ff504c5..b11e45d8f 100644 --- a/packages/cli/src/commands/release/restore.ts +++ b/packages/cli/src/commands/release/restore.ts @@ -19,8 +19,9 @@ import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; import type { ReleaseUpdateOutput } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { ReleaseUpdateOutputSchema } from "../../generated/output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry release restore [<org>/]<version>"; +const usageHint = () => sentry`release restore [<org>/]<version>`; /** * Render the human-readable result of restoring a release. @@ -73,7 +74,7 @@ export const restoreCommand = buildCommand({ kind: "tuple", parameters: [ releaseParameter( - USAGE_HINT, + usageHint, "[<org>/]<version> - Release version to restore", ), ], diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index eebe71908..06c084c16 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -43,7 +43,7 @@ import { SentryReleaseSchema } from "../../generated/output-schemas/release.js"; const log = logger.withTag("release.set-commits"); -const USAGE_HINT = "sentry release set-commits [<org>/]<version>"; +const usageHint = () => sentry`release set-commits [<org>/]<version>`; /** Diagnostic note shared by --from validation errors. */ const FROM_RANGE_NOTE = @@ -424,7 +424,7 @@ export const setCommitsCommand = buildCommand({ positional: { kind: "tuple", parameters: [ - releaseParameter(USAGE_HINT, "[<org>/]<version> - Release version"), + releaseParameter(usageHint, "[<org>/]<version> - Release version"), ], }, flags: { diff --git a/packages/cli/src/commands/release/view.ts b/packages/cli/src/commands/release/view.ts index aa2b15acf..3b46c31e0 100644 --- a/packages/cli/src/commands/release/view.ts +++ b/packages/cli/src/commands/release/view.ts @@ -26,8 +26,9 @@ import { import type { SentryRelease } from "../../types/index.js"; import { type ReleaseTarget, releaseParameter } from "./parse.js"; import { SentryReleaseSchema } from "../../generated/output-schemas/release.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry release view [<org>/]<version>"; +const usageHint = () => sentry`release view [<org>/]<version>`; /** Wrap a plain "—" in muted color for consistent table styling. */ function mutedIfDash(value: string): string { @@ -187,7 +188,7 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ releaseParameter( - USAGE_HINT, + usageHint, "[<org>/]<version> - Release version to view", ), ], diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index 6f1e61289..1567a3736 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -44,8 +44,8 @@ type DownloadFlags = { readonly fresh: boolean; }; -const USAGE_HINT = - "sentry replay download [<org>/<project>/]<replay-id> | <replay-url>"; +const usageHint = () => + sentry`replay download [<org>/<project>/]<replay-id> | <replay-url>`; const log = logger.withTag("replay.download"); @@ -85,7 +85,7 @@ export const downloadCommand = buildCommand({ human: formatReplayDownloadResult, }, parameters: { - positional: replayArguments("download", USAGE_HINT), + positional: replayArguments("download", usageHint), flags: { output: { kind: "parsed", diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index c7d063ee5..4bc39a806 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -12,7 +12,7 @@ import { listIssuesPaginated, resolveReplay, } from "../../lib/api-client.js"; -import { sentry } from "../../lib/app-identity.js"; +import { type LazyCommand, sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -61,8 +61,8 @@ type ViewFlags = { readonly fields?: string[]; }; -const USAGE_HINT = - "sentry replay view [<org>/<project>/]<replay-id> | <replay-url>"; +const viewUsageHint = () => + sentry`replay view [<org>/<project>/]<replay-id> | <replay-url>`; const MAX_ACTIVITY_EVENTS = 6; const MAX_RELATED_ERRORS = 3; const MAX_RELATED_TRACES = 2; @@ -79,7 +79,7 @@ const log = logger.withTag("replay.view"); */ export function parseReplayArguments( args: readonly string[], - usageHint = USAGE_HINT, + usageHint = viewUsageHint(), ): ReplayArgumentsInput { const { target, id, baseUrl, warnings } = parseIdArgument( args, @@ -122,14 +122,14 @@ export type ReplayArguments = BoundArguments< */ export function replayArguments( command: "view" | "download", - usageHint: string, + usageHint: LazyCommand, ) { const commandLog = logger.withTag(`replay.${command}`); return boundArguments({ placeholder: "replay-id-or-url", brief: "[<org>/<project>] <replay-id or trace-id> or <replay-url>", kind: ORG_PROJECT_TARGET, - parse: (raw) => parseReplayArguments(raw, usageHint), + parse: (raw) => parseReplayArguments(raw, usageHint()), resolve: async ( input, context: SentryContext, @@ -364,7 +364,7 @@ export const viewCommand = buildCommand({ schema: ReplayViewOutputSchema, }, parameters: { - positional: replayArguments("view", USAGE_HINT), + positional: replayArguments("view", viewUsageHint), flags: { web: { kind: "boolean", diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index cec1a3f89..6b1f9d6aa 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -23,6 +23,7 @@ import { import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; import type { SentryRepository } from "../../types/index.js"; import { RepositoryListItemSchema } from "../../generated/output-schemas/repo.js"; +import { sentry } from "../../lib/app-identity.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "repo-list"; @@ -47,7 +48,7 @@ const repoListConfig: OrgListConfig< > = { paginationKey: PAGINATION_KEY, entityPlural: "repositories", - commandPrefix: "sentry repo list", + commandPrefix: () => sentry`repo list`, listForOrg: (org) => listRepositories(org), listPaginated: (org, opts) => listRepositoriesPaginated(org, opts), withOrg: (repo, orgSlug) => ({ ...repo, orgSlug }), diff --git a/packages/cli/src/commands/snapshots/download.ts b/packages/cli/src/commands/snapshots/download.ts index 88dd04191..9be157ae5 100644 --- a/packages/cli/src/commands/snapshots/download.ts +++ b/packages/cli/src/commands/snapshots/download.ts @@ -35,11 +35,12 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { extractZipStream } from "../../lib/snapshots/archive.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("snapshots.download"); -const USAGE_HINT = - "sentry snapshots download --app-id <app> | --snapshot-id <id>"; +const usageHint = () => + sentry`snapshots download --app-id <app> | --snapshot-id <id>`; const DEFAULT_OUTPUT = "./snapshots-base/"; /** Flags accepted by `snapshots download`. */ @@ -97,7 +98,7 @@ function validateSnapshotFlags(flags: DownloadFlags): void { ); } if (!(appId || snapshotId)) { - throw new ContextError("Snapshot", USAGE_HINT, []); + throw new ContextError("Snapshot", usageHint(), []); } } @@ -140,7 +141,7 @@ async function resolveSnapshotId( throw new ResolutionError( `Baseline snapshot for app '${appId}'${branchMsg}`, "not found", - USAGE_HINT, + usageHint(), ["No baseline snapshot exists for this app yet."], ); } @@ -218,7 +219,7 @@ export const downloadCommand = buildCommand({ const resolved = await resolveOrg({ cwd: this.cwd }); if (!resolved) { - throw new ContextError("Organization", USAGE_HINT); + throw new ContextError("Organization", usageHint()); } const { org } = resolved; const project = resolveOptionalProject(this.env); diff --git a/packages/cli/src/commands/snapshots/upload.ts b/packages/cli/src/commands/snapshots/upload.ts index ddead9f20..9591078e1 100644 --- a/packages/cli/src/commands/snapshots/upload.ts +++ b/packages/cli/src/commands/snapshots/upload.ts @@ -54,10 +54,11 @@ import { splitAndTrim, validateImageSizes, } from "../../lib/snapshots/images.js"; +import { sentry } from "../../lib/app-identity.js"; const log = logger.withTag("snapshots.upload"); -const USAGE_HINT = "sentry snapshots upload <path> --app-id <id>"; +const usageHint = () => sentry`snapshots upload <path> --app-id <id>`; /** Flags accepted by `snapshots upload`. */ type UploadFlags = { @@ -477,10 +478,10 @@ export const uploadCommand = buildCommand({ const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 9a8849a61..e65228fc0 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -39,6 +39,7 @@ import { type InjectResult, resolveUploadTarget, } from "../../lib/sourcemap/inject.js"; +import { sentry } from "../../lib/app-identity.js"; /** Result type for the upload command. */ type UploadCommandResult = { @@ -74,7 +75,7 @@ function formatUploadResult(data: UploadCommandResult): string { return renderMarkdown(mdKvTable(rows)); } -const USAGE_HINT = "sentry sourcemap upload <directory>"; +const usageHint = () => sentry`sourcemap upload <directory>`; /** * Compute the longest common directory prefix across a list of paths. @@ -415,10 +416,10 @@ export const uploadCommand = buildCommand({ const resolved = await resolveOrgAndProject({ cwd: this.cwd, - usageHint: USAGE_HINT, + usageHint: usageHint(), }); if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); + throw new ContextError("Organization and project", usageHint()); } const { org, project } = resolved; diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 9c1a396a7..136a393ba 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -183,7 +183,7 @@ export const PROJECT_PAGINATION_KEY = "span-search"; const COMMAND_NAME = "span list"; /** Usage hint for trace-mode ContextError messages */ -const TRACE_USAGE_HINT = "sentry span list [<org>/<project>/]<trace-id>"; +const traceUsageHint = () => sentry`span list [<org>/<project>/]<trace-id>`; /** * Parse --limit flag, delegating range validation to shared utility. @@ -232,7 +232,7 @@ type SpanListArguments = BoundArguments<SpanListInput, SpanListTarget>; * @internal Exported for testing */ export function parseSpanListInput(args: readonly string[]): SpanListInput { - const input = parseDualModeInput(args, TRACE_USAGE_HINT, { + const input = parseDualModeInput(args, traceUsageHint(), { needsProject: true, }); if (input.mode === "trace") { @@ -253,7 +253,7 @@ async function resolveSpanListTarget( const resolved = await resolveTraceOrgProject( parsed, context.cwd, - TRACE_USAGE_HINT, + traceUsageHint(), ); return { mode: "trace", ...resolved }; } diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index a0940b864..c26967fa4 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -64,8 +64,8 @@ type ViewFlags = { }; /** Usage hint for ContextError messages */ -const USAGE_HINT = - "sentry span view [<org>/<project>/]<trace-id> <span-id> [<span-id>...]"; +const usageHint = () => + sentry`span view [<org>/<project>/]<trace-id> <span-id> [<span-id>...]`; /** The arguments of `span view`, parsed but with the IDs as typed. */ export type SpanViewInput = { @@ -100,7 +100,7 @@ type SpanViewArguments = BoundArguments<SpanViewInput, ResolvedSpans>; export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { // A lone argument that does not end in a span ID is the trace const { target, ids } = parseIdArguments(rawArgs, ID_KINDS.span, { - usageHint: USAGE_HINT, + usageHint: usageHint(), multiple: true, optional: true, needsProject: true, @@ -113,7 +113,7 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { // (CLI-SC) throw new ContextError( "Trace ID and span ID", - USAGE_HINT, + usageHint(), first && ID_KINDS.span.isId(first) ? [ `'${first}' looks like a span ID (16 characters), not a trace ID`, @@ -125,7 +125,7 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { } if (first === undefined) { - throw new ContextError("Span ID", USAGE_HINT, [ + throw new ContextError("Span ID", usageHint(), [ `Use '${sentry`span list ${target}`}' to find span IDs within this trace`, ]); } @@ -142,14 +142,14 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { */ function parseTrace(traceArg: string, spanId: string): TraceArgumentsInput { try { - return parseTraceArgumentsInput([traceArg], USAGE_HINT, { + return parseTraceArgumentsInput([traceArg], usageHint(), { needsProject: true, }); } catch (error) { if (!(error instanceof ContextError)) { throw error; } - throw new ContextError("Trace ID", USAGE_HINT, [ + throw new ContextError("Trace ID", usageHint(), [ `'${traceArg}' is missing a trace ID before the span ID '${spanId}'`, `Provide the trace ID: ${sentry`span view ${traceArg}/<trace-id> ${spanId}`}`, ]); @@ -170,7 +170,7 @@ async function resolveSpanViewInput( const { traceId, org, project } = await resolveTraceOrgProject( traceTarget, context.cwd, - USAGE_HINT, + usageHint(), ); // Span IDs are unique only within a trace, so recovery scopes its fuzzy // lookup via `ctx.traceId`. @@ -403,10 +403,15 @@ export const viewCommand = buildCommand({ const spans = await getDetailedTrace(org, traceId); if (spans.length === 0) { - throw new ResolutionError(`Trace "${traceId}"`, "not found", USAGE_HINT, [ - "The ID format is valid but no matching trace exists in this project", - "Check that you are querying the right org/project, or the trace may be past your plan's retention window", - ]); + throw new ResolutionError( + `Trace "${traceId}"`, + "not found", + usageHint(), + [ + "The ID format is valid but no matching trace exists in this project", + "Check that you are querying the right org/project, or the trace may be past your plan's retention window", + ], + ); } // Find each requested span @@ -431,14 +436,14 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Span "${spanIds[0]}" in trace ${traceId}`, "not found", - USAGE_HINT, + usageHint(), ); } const idList = formatIdList(spanIds); throw new ResolutionError( `Spans in trace ${traceId}`, "none of the requested IDs were found", - USAGE_HINT, + usageHint(), [`Requested IDs:\n${idList}`], ); } diff --git a/packages/cli/src/commands/team/list.ts b/packages/cli/src/commands/team/list.ts index 9dda8cdf3..7fed0a829 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -24,6 +24,7 @@ import { import type { OrgListConfig, WithOrg } from "../../lib/org-list.js"; import type { SentryTeam } from "../../types/index.js"; import { TeamListItemSchema } from "../../generated/output-schemas/team.js"; +import { sentry } from "../../lib/app-identity.js"; /** Command key for pagination cursor storage */ export const PAGINATION_KEY = "team-list"; @@ -51,7 +52,7 @@ const teamListConfig: OrgListConfig< > = { paginationKey: PAGINATION_KEY, entityPlural: "teams", - commandPrefix: "sentry team list", + commandPrefix: () => sentry`team list`, listForOrg: (org) => listTeams(org), listPaginated: (org, opts) => listTeamsPaginated(org, opts), withOrg: (team, orgSlug) => ({ ...team, orgSlug }), diff --git a/packages/cli/src/commands/token/create.ts b/packages/cli/src/commands/token/create.ts index f7b69b305..b2312fabd 100644 --- a/packages/cli/src/commands/token/create.ts +++ b/packages/cli/src/commands/token/create.ts @@ -26,8 +26,9 @@ import { explainTokenForbidden, parseOrgScopedArg, } from "./shared.js"; +import { sentry } from "../../lib/app-identity.js"; -const USAGE_HINT = "sentry token create [<org>/]<name>"; +const usageHint = () => sentry`token create [<org>/]<name>`; type CreateFlags = { readonly "dry-run": boolean; @@ -104,12 +105,12 @@ export const createCommand = buildCommand({ brief: "[<org>/]<name> - Name of the new token", parse: boundPositional( ORG_TARGET, - (raw) => parseOrgScopedArg(raw, "Token name", USAGE_HINT), + (raw) => parseOrgScopedArg(raw, "Token name", usageHint()), async ({ value: name, orgSlug }, context: SentryContext) => { const { org } = await resolveOrgInput( { org: orgSlug }, context.cwd, - USAGE_HINT, + usageHint(), ); return { name, org }; }, diff --git a/packages/cli/src/commands/token/delete.ts b/packages/cli/src/commands/token/delete.ts index 9b83867e7..161fd2f14 100644 --- a/packages/cli/src/commands/token/delete.ts +++ b/packages/cli/src/commands/token/delete.ts @@ -10,7 +10,7 @@ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { deleteOrgAuthToken, listOrgAuthTokens } from "../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { ORG_TARGET } from "../../lib/completion-target.js"; import { selectAccountForOrg } from "../../lib/db/auth.js"; import { ResolutionError, ValidationError } from "../../lib/errors.js"; @@ -41,7 +41,7 @@ import { } from "./shared.js"; const USAGE = "[<org>/]<token-id-or-name>"; -const USAGE_HINT = `sentry token delete ${USAGE}`; +const usageHint = () => sentry`token delete ${USAGE}`; const log = logger.withTag("token.delete"); /** Sentry org (`sntrys_`) and user (`sntryu_`) auth token values. */ @@ -116,12 +116,12 @@ export function parseTokenArguments(args: readonly string[]): TokenInput { const { target, ids, name, baseUrl, warnings } = parseIdArguments( args, ID_KINDS.token, - { usageHint: USAGE_HINT, multiple: true }, + { usageHint: usageHint(), multiple: true }, ); // Never choose one of several tokens for a destructive action. if (ids.length > 1) { throw new ValidationError( - `Revoke one token at a time, not ${ids.length}: ${asInvocationCommand(USAGE_HINT)}`, + `Revoke one token at a time, not ${ids.length}: ${usageHint()}`, "token-id", ); } @@ -138,14 +138,14 @@ export function parseTokenArguments(args: readonly string[]): TokenInput { const scoped = parseOrgScopedArg( unscopedName, "Token ID or name", - USAGE_HINT, + usageHint(), ); orgArg = scoped.orgSlug; ref = { name: scoped.value }; } else if (name !== undefined) { ref = { name }; } - const org = parseOrgInput(orgArg, USAGE_HINT); + const org = parseOrgInput(orgArg, usageHint()); return { ...ref, org: baseUrl ? { ...org, baseUrl } : org, warnings }; } @@ -234,7 +234,7 @@ export const deleteCommand = buildDeleteCommand({ const { org } = await resolveOrgInput( input.org, context.cwd, - USAGE_HINT, + usageHint(), ); const ref: TokenRef = input.id === undefined ? { name: input.name } : { id: input.id }; diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index 6b3fdb6f3..cc6f1edbb 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -96,7 +96,7 @@ function formatTraceLogsHuman(data: TraceLogsData): string { const DEFAULT_PERIOD = "14d"; /** Usage hint shown in error messages */ -const USAGE_HINT = "sentry trace logs [<org>/[<project>/]]<trace-id>"; +const usageHint = () => sentry`trace logs [<org>/[<project>/]]<trace-id>`; /** * Parse --limit flag, delegating range validation to shared utility. @@ -116,7 +116,7 @@ type TraceLogsArguments = BoundArguments<TraceTargetInput, TraceLogsTarget>; /** Parse `trace logs` arguments, without side effects. */ function parseTraceLogsInput(args: readonly string[]): TraceTargetInput { - return parseTraceTargetInput(args, USAGE_HINT); + return parseTraceTargetInput(args, usageHint()); } /** @@ -132,7 +132,7 @@ async function resolveTraceLogsTarget( const { traceId, org } = await resolveTraceOrg( parsed, context.cwd, - USAGE_HINT, + usageHint(), ); return { traceId, diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 5040a911a..bba353485 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -68,7 +68,7 @@ type ViewFlags = { }; /** Usage hint for ContextError messages */ -const USAGE_HINT = "sentry trace view [<org>/<project>/]<trace-id>"; +const usageHint = () => sentry`trace view [<org>/<project>/]<trace-id>`; /** * Build a contextual hint with real values for easy copy-paste. @@ -120,7 +120,7 @@ export function parseTraceViewInput(args: readonly string[]): TraceViewInput { return { issueShortId: first }; } return { - trace: parseTraceArgumentsInput(args, USAGE_HINT), + trace: parseTraceArgumentsInput(args, usageHint()), ...(shortId && { suggestion: `Did you mean: ${sentry`issue view ${first}`}`, }), @@ -149,7 +149,7 @@ async function resolveTraceViewInput( const target = await resolveTraceOrgOptionalProject( parsed, context.cwd, - USAGE_HINT, + usageHint(), ); return { ...target, diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 77ca93c21..fb1fdcbd0 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -17,7 +17,7 @@ import { getProductTrials, startProductTrial, } from "../../lib/api-client.js"; -import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { detectSwappedTrialArgs } from "../../lib/arg-parsing.js"; import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -63,7 +63,7 @@ function isValidTrialArg(name: string): boolean { } const USAGE = "<name> [<org>]"; -const USAGE_HINT = `sentry trial start ${USAGE}`; +const usageHint = () => sentry`trial start ${USAGE}`; /** Parsed `trial start` arguments. */ export type TrialStartInput = { @@ -118,13 +118,13 @@ export function parseTrialStartArguments( ): TrialStartInput { const [first, second, ...extra] = args; if (first === undefined) { - throw new ContextError("Trial name", USAGE_HINT, [ + throw new ContextError("Trial name", usageHint(), [ `Valid trial names: ${NAMES_LIST}`, ]); } if (extra.length > 0) { throw new ValidationError( - `Too many arguments: ${extra.join(" ")}. Expected: ${asInvocationCommand(USAGE_HINT)}`, + `Too many arguments: ${extra.join(" ")}. Expected: ${usageHint()}`, "positional", ); } @@ -147,7 +147,7 @@ export function parseTrialStartArguments( return { name, - org: parseOrgInput(org, USAGE_HINT), + org: parseOrgInput(org, usageHint()), ...(swapped && { warning: swapped.warning }), }; } diff --git a/packages/cli/src/lib/app-identity.ts b/packages/cli/src/lib/app-identity.ts index e22240591..ae85d2025 100644 --- a/packages/cli/src/lib/app-identity.ts +++ b/packages/cli/src/lib/app-identity.ts @@ -48,38 +48,32 @@ export function getInvocationCommand(): string { : identity.name; } -/** - * `command` as a hint that runs this program again: a leading `sentry ` - * becomes {@link getInvocationCommand}. For commands held in runtime data, - * such as a usage line passed to an error; write literal hints with the - * {@link sentry} tag. - */ -export function asInvocationCommand(command: string): string { - return command.startsWith("sentry ") - ? `${getInvocationCommand()}${command.slice("sentry".length)}` - : command; -} - /** * Tag for a hint that runs this program again, written without the program * name: `` sentry`issue list ${org}/` `` gives `sentry issue list acme/`, * `npx -y sentry issue list acme/`, or `sentry-mcp issue list acme/`. * - * An interpolated value that is itself a `sentry …` command gets the same - * treatment, and a value this tag already built keeps its prefix, so a - * command is never named twice. + * Interpolated values are inserted as they are. A command built with this + * tag already names the program, so nesting one never names it twice, and + * text that only looks like a command is never rewritten. + * + * The program is known only once an entry point has started, so build the + * hint where it is shown. A command kept in a constant or a table is a + * {@link LazyCommand}: `` const usageHint = () => sentry`issue view <id>` ``. */ export function sentry( strings: TemplateStringsArray, ...values: unknown[] ): string { - let text = strings[0] ?? ""; - values.forEach((value, i) => { - text += asInvocationCommand(String(value)) + (strings[i + 1] ?? ""); - }); + const text = strings.reduce( + (built, part, i) => `${built}${String(values[i - 1])}${part}`, + ); return `${getInvocationCommand()} ${text}`; } +/** A command hint built when it is shown, as `() => sentry`issue view <id>``. */ +export type LazyCommand = () => string; + /** Command that signs in to the active Sentry host. */ export function getLoginCommand(): string { return `${getInvocationCommand()} auth login`; diff --git a/packages/cli/src/lib/command-suggestions.ts b/packages/cli/src/lib/command-suggestions.ts index da1bff038..ae67a0cc2 100644 --- a/packages/cli/src/lib/command-suggestions.ts +++ b/packages/cli/src/lib/command-suggestions.ts @@ -9,7 +9,7 @@ * Populated from CLI-Q3 telemetry analysis (~100 events, 85 users). */ -import { asInvocationCommand } from "./app-identity.js"; +import { type LazyCommand, sentry } from "./app-identity.js"; /** A suggestion for an unknown command token. */ export type CommandSuggestion = { @@ -27,72 +27,75 @@ export type CommandSuggestion = { * Keep registered commands and aliases out of this map: runtime error * handlers also consult it. Prefer native suggestions only for exact equivalents. */ -const SUGGESTIONS: ReadonlyMap<string, CommandSuggestion> = new Map([ +const SUGGESTIONS: ReadonlyMap< + string, + Omit<CommandSuggestion, "command"> & { command: LazyCommand } +> = new Map([ // --- view synonyms (~15 events) --- - ["issue/get", { command: "sentry issue view <issue-id>" }], - ["issue/details", { command: "sentry issue view <issue-id>" }], - ["issue/detail", { command: "sentry issue view <issue-id>" }], - ["issue/info", { command: "sentry issue view <issue-id>" }], + ["issue/get", { command: () => sentry`issue view <issue-id>` }], + ["issue/details", { command: () => sentry`issue view <issue-id>` }], + ["issue/detail", { command: () => sentry`issue view <issue-id>` }], + ["issue/info", { command: () => sentry`issue view <issue-id>` }], // --- mutation commands (~8 events) --- - ["issue/close", { command: "sentry issue resolve <issue-id>" }], + ["issue/close", { command: () => sentry`issue resolve <issue-id>` }], [ "issue/update", { - command: - "sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT", + command: () => + sentry`api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT`, explanation: "Issue mutations are available via the API", }, ], [ "issue/set-status", { - command: - 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"status":"..."}\'', + command: () => + sentry`api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data '{"status":"..."}'`, explanation: "Issue status changes are available via the API", }, ], [ "issue/assign", { - command: - 'sentry api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data \'{"assignedTo":"..."}\'', + command: () => + sentry`api /api/0/organizations/{org}/issues/{issue_id}/ --method PUT --data '{"assignedTo":"..."}'`, explanation: "Issue assignment is available via the API", }, ], [ "issue/comment", { - command: - 'sentry api /api/0/organizations/{org}/issues/{issue_id}/comments/ --method POST --data \'{"text":"..."}\'', + command: () => + sentry`api /api/0/organizations/{org}/issues/{issue_id}/comments/ --method POST --data '{"text":"..."}'`, explanation: "Issue commenting is available via the API", }, ], // --- old sentry-cli commands (~5 events) --- - ["cli/info", { command: "sentry auth status" }], - ["cli/send-event", { command: "sentry event send" }], - ["cli/issues", { command: "sentry issue list" }], - ["cli/logs", { command: "sentry log list" }], + ["cli/info", { command: () => sentry`auth status` }], + ["cli/send-event", { command: () => sentry`event send` }], + ["cli/issues", { command: () => sentry`issue list` }], + ["cli/logs", { command: () => sentry`log list` }], // --- dashboard synonyms --- - ["dashboard/default-overview", { command: "sentry dashboard list" }], + ["dashboard/default-overview", { command: () => sentry`dashboard list` }], // --- issue trends (from telemetry) --- [ "issue/trends", { - command: "sentry issue list --sort freq", + command: () => sentry`issue list --sort freq`, explanation: "Use issue list with sort options for trend analysis", }, ], // --- issue latest-event (from telemetry) --- - ["issue/latest-event", { command: "sentry issue view <issue-id>" }], + ["issue/latest-event", { command: () => sentry`issue view <issue-id>` }], // --- issue search/find --- - ["issue/search", { command: "sentry issue list --query <search>" }], - ["issue/find", { command: "sentry issue list --query <search>" }], + ["issue/search", { command: () => sentry`issue list --query <search>` }], + ["issue/find", { command: () => sentry`issue list --query <search>` }], ]); /** @@ -113,7 +116,7 @@ export function getCommandSuggestion( return ( suggestion && { ...suggestion, - command: asInvocationCommand(suggestion.command), + command: suggestion.command(), } ); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index d059233c6..5b76ddb73 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -39,7 +39,7 @@ import { } from "@stricli/core"; import type { GenericSchema } from "valibot"; import type { Writer } from "../types/index.js"; -import { asInvocationCommand, sentry } from "./app-identity.js"; +import { getInvocationCommand, sentry } from "./app-identity.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { @@ -463,6 +463,20 @@ function enrichDocsWithSchema( }; } +/** + * An example step as the running program would run it. Examples are help + * metadata written for the `sentry` binary, shared with the generated docs + * and skill references, so only `--help` swaps the leading program name. + */ +function exampleForRunningProgram(step: string): string { + return step.startsWith(EXAMPLE_PROGRAM) + ? `${getInvocationCommand()} ${step.slice(EXAMPLE_PROGRAM.length)}` + : step; +} + +/** The program name that starts a `docs.examples` step. */ +const EXAMPLE_PROGRAM = "sentry "; + /** Render command-owned examples in native help without exposing custom fields to Stricli. */ function prepareNativeDocs( docs: CommandDocumentation, @@ -473,7 +487,7 @@ function prepareNativeDocs( } const rendered = examples .map((example) => { - const steps = exampleSteps(example).map(asInvocationCommand); + const steps = exampleSteps(example).map(exampleForRunningProgram); return steps.length === 1 ? ` ${steps[0]} # ${example.description}` : [` # ${example.description}`, ...steps.map((s) => ` ${s}`)].join( diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 076506192..b2c81820b 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -30,12 +30,7 @@ import { buildOrgSettingsUrl, buildSeerSettingsUrl, } from "./sentry-urls.js"; -import { - asInvocationCommand, - getLoginCommand, - getUrlLoginCommand, - sentry, -} from "./app-identity.js"; +import { getLoginCommand, getUrlLoginCommand, sentry } from "./app-identity.js"; // --------------------------------------------------------------------------- // Exit code constants @@ -389,7 +384,7 @@ function defaultContextAlternatives(): string[] { * Build the formatted context error message with usage hints. * * @param resource - What is required (e.g., "Organization", "Trace ID and span ID") - * @param command - Single-line CLI usage example (e.g., "sentry org view <org-slug>") + * @param command - Single-line CLI usage example (e.g., sentry`org view <org-slug>`) * @param alternatives - Alternative ways to provide the context * @param note - Optional informational context (e.g., "Found 2 DSN(s) that could not be resolved"). * Rendered as a separate "Note:" section after alternatives. Use this for diagnostic @@ -474,7 +469,7 @@ function buildResolutionMessage( * * @param resource - What is required (e.g., "Organization", "Organization and project"). * Use " and " to join compound resources — triggers plural grammar ("are required"). - * @param command - **Single-line** CLI usage example (e.g., "sentry org view <org-slug>"). + * @param command - **Single-line** CLI usage example (e.g., sentry`org view <org-slug>`). * Must not contain newlines — multi-line messages indicate a resolution failure * that should use {@link ResolutionError}. * @param alternatives - Alternative ways to resolve (defaults to DSN/project detection hints). @@ -498,12 +493,10 @@ export class ContextError extends CliError { // When alternatives is omitted, auto-detection was tried and failed const isAutoDetect = alternatives === undefined; const resolvedAlternatives = alternatives ?? defaultContextAlternatives(); - // Commands hold usage lines written as `sentry …` - const usage = asInvocationCommand(command); // Include full formatted message so it's shown even when caught by external handlers super( - buildContextMessage(resource, usage, resolvedAlternatives, { + buildContextMessage(resource, command, resolvedAlternatives, { note, isAutoDetect, }), @@ -511,7 +504,7 @@ export class ContextError extends CliError { ); this.name = "ContextError"; this.resource = resource; - this.command = usage; + this.command = command; this.alternatives = resolvedAlternatives; this.note = note; @@ -566,18 +559,15 @@ export class ResolutionError extends CliError { hint: string, suggestions: string[] = [], ) { - // The hint and suggestions are often usage lines written as `sentry …` - const usage = asInvocationCommand(hint); - const commands = suggestions.map(asInvocationCommand); super( - buildResolutionMessage(resource, headline, usage, commands), + buildResolutionMessage(resource, headline, hint, suggestions), EXIT.RESOLUTION, ); this.name = "ResolutionError"; this.resource = resource; this.headline = headline; - this.hint = usage; - this.suggestions = commands; + this.hint = hint; + this.suggestions = suggestions; } override format(): string { @@ -606,7 +596,7 @@ export function buildValidationMessage( if (examples.length > 0) { lines.push("", "Try:"); for (const example of examples) { - lines.push(` ${asInvocationCommand(example)}`); + lines.push(` ${example}`); } } if (note) { diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index 964bbbbd6..cb9ee5149 100644 --- a/packages/cli/src/lib/hex-id-recovery.ts +++ b/packages/cli/src/lib/hex-id-recovery.ts @@ -28,7 +28,7 @@ import { addBreadcrumb } from "@sentry/node"; import type { SpanListItem } from "../types/index.js"; import { listLogs, listSpans, queryEvents } from "./api-client.js"; -import { asInvocationCommand, sentry } from "./app-identity.js"; +import { type LazyCommand, sentry } from "./app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -185,12 +185,12 @@ const URL_FRAGMENT_PREFIXES = ["span-", "txn-", "event-", "trace-"] as const; const OVER_NESTED_MIN_SEGMENTS = 4; /** Per-command slug redirect hints (used by the `looks-like-slug` branch). */ -const SLUG_REDIRECT_COMMAND: Record<HexEntityType, string> = { - event: "sentry issue list", - trace: "sentry trace list", - log: "sentry log list", +const SLUG_REDIRECT_COMMAND: Record<HexEntityType, LazyCommand | undefined> = { + event: () => sentry`issue list`, + trace: () => sentry`trace list`, + log: () => sentry`log list`, // Spans are trace-scoped — a slug-only input can't map to a list command. - span: "", + span: undefined, }; // --------------------------------------------------------------------------- @@ -590,7 +590,7 @@ function buildSlugHint( const orgPart = org ? `${org}/` : "<org>/"; const cmd = SLUG_REDIRECT_COMMAND[entityType]; if (cmd) { - return `'${input}' looks like a project slug, not a ${entityType} ID. Try: ${asInvocationCommand(cmd)} ${orgPart}${input}`; + return `'${input}' looks like a project slug, not a ${entityType} ID. Try: ${cmd()} ${orgPart}${input}`; } return `'${input}' looks like a project slug, not a ${entityType} ID. Spans are identified by 16 hex chars within a trace.`; } diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index 9188d67fa..c43f496ec 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -33,7 +33,7 @@ */ import { parse } from "../generated/id-arguments-grammar.js"; -import { asInvocationCommand, sentry } from "./app-identity.js"; +import { type LazyCommand, sentry } from "./app-identity.js"; import { looksLikeIssueShortId } from "./arg-parsing.js"; import { ContextError, ValidationError } from "./errors.js"; import { @@ -91,8 +91,8 @@ export type IdKind = { * instead of how the ID is malformed. */ readonly search?: { - /** The list command, e.g. `"sentry trace list"`, which takes `--query` */ - readonly command: string; + /** The list command, e.g. `sentry trace list`, which takes `--query` */ + readonly command: LazyCommand; /** Whether `value`, not an ID, reads as text rather than a broken ID */ readonly isText: (value: string) => boolean; }; @@ -138,7 +138,7 @@ const OPAQUE_ID_RE = /^[^/?#%\s\p{Cc}]+$/u; function hexKind( label: string, - listCommand: string | null, + listCommand: LazyCommand | null, fromUrl: IdKind["fromUrl"], urlExample?: string, ): IdKind { @@ -159,7 +159,7 @@ function hexKind( * to search for it rather than how the ID is malformed: `validate()` throws * that, so a command that recovers malformed IDs first still gets it last. */ -function searchable(kind: IdKind, listCommand: string): IdKind { +function searchable(kind: IdKind, listCommand: LazyCommand): IdKind { const search = { command: listCommand, isText: isHexSearchText }; return { ...kind, @@ -168,7 +168,7 @@ function searchable(kind: IdKind, listCommand: string): IdKind { if (!(kind.isId(value) || kind.mayBeId(value)) && search.isText(value)) { const label = kind.label.charAt(0).toLowerCase() + kind.label.slice(1); throw new ValidationError( - `Not a ${label}: "${value}". To search, try \`${asInvocationCommand(search.command)} --query ${shellQuote(value)}\`.\n\n` + + `Not a ${label}: "${value}". To search, try \`${search.command()} --query ${shellQuote(value)}\`.\n\n` + `If it names a project, pass the ${label} after it: <org>/<project> <${label}>`, "positional", ); @@ -213,17 +213,25 @@ const ISSUE_KIND: IdKind & { /** The kinds of ID commands take. Commands use these instead of their own. */ export const ID_KINDS = { - trace: hexKind("Trace ID", "sentry trace list", (url) => url.traceId), + trace: hexKind( + "Trace ID", + () => sentry`trace list`, + (url) => url.traceId, + ), // `event list` searches an issue's events, so there is no search to hint event: hexKind("Event ID", null, (url) => url.eventId), // Replays are looked up in an org, so a replay URL needs one replay: hexKind( "Replay ID", - "sentry replay list", + () => sentry`replay list`, (url) => (url.org ? url.replayId : undefined), "https://sentry.io/organizations/{org}/explore/replays/{replayId}/", ), - log: hexKind("Log ID", "sentry log list", () => undefined), + log: hexKind( + "Log ID", + () => sentry`log list`, + () => undefined, + ), span: searchable( { label: "Span ID", @@ -232,7 +240,7 @@ export const ID_KINDS = { validate: validateSpanId, fromUrl: () => undefined, }, - "sentry span list", + () => sentry`span list`, ), // A numeric ID, or a title: `Latency/p95`, `Top 10`, or unquoted words // after the org @@ -396,7 +404,7 @@ export function parseIdArguments( } else if (own !== target && token.target !== target) { throw new ValidationError( `${kind.label}s reference different projects ('${target}' and '${own}'). ` + - `Pass IDs from one project per call: ${asInvocationCommand(usageHint)}`, + `Pass IDs from one project per call: ${usageHint}`, "positional", ); } @@ -430,7 +438,7 @@ export function parseIdArguments( chosen = first ? [first] : []; const extra = typed.filter((token) => token !== first); warnings.push( - `Extra arguments ignored: ${extra.map((token) => token.text).join(" ")}. Expected: ${asInvocationCommand(usageHint)}`, + `Extra arguments ignored: ${extra.map((token) => token.text).join(" ")}. Expected: ${usageHint}`, ); } @@ -543,7 +551,7 @@ function pathTargetArg( } if (slash === 0 || slash === prefix.length - 1) { throw new ValidationError( - `Invalid format: "${text}" needs an organization and a project before the ID.\n\nUsage: ${asInvocationCommand(usageHint)}`, + `Invalid format: "${text}" needs an organization and a project before the ID.\n\nUsage: ${usageHint}`, "positional", ); } diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index d5a431623..4be9bc968 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -43,7 +43,7 @@ import { listOrganizations, type PaginatedResponse, } from "./api-client.js"; -import { asInvocationCommand, sentry } from "./app-identity.js"; +import { type LazyCommand, sentry } from "./app-identity.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, @@ -414,8 +414,8 @@ export type ListCommandMeta = { paginationKey: string; /** Plural entity name for messages (e.g., "teams") */ entityPlural: string; - /** CLI command prefix for hints (e.g., "sentry team list") */ - commandPrefix: string; + /** The command, for hints (e.g., `() => sentry`team list``) */ + commandPrefix: LazyCommand; }; /** Minimal flags required by the shared infrastructure. */ @@ -710,7 +710,7 @@ export async function handleOrgAll<TEntity, TWithOrg>( let hint: string | undefined; let header: string | undefined; - const base = `${asInvocationCommand(config.commandPrefix)} ${org}/`; + const base = `${config.commandPrefix()} ${org}/`; const navHint = paginationHint({ hasPrev, hasMore, @@ -807,13 +807,13 @@ export async function handleAutoDetect<TEntity, TWithOrg>( if (skippedSelfHosted) { hintParts.push( `Note: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - `Specify the organization explicitly: ${asInvocationCommand(config.commandPrefix)} <org>/`, + `Specify the organization explicitly: ${config.commandPrefix()} <org>/`, ); } if (limited.length > 0) { hintParts.push( - `Tip: Use '${asInvocationCommand(config.commandPrefix)} ${orgFilterTarget("<org>", filter)}' to filter by organization`, + `Tip: Use '${config.commandPrefix()} ${orgFilterTarget("<org>", filter)}' to filter by organization`, ); } @@ -858,7 +858,7 @@ function buildFetchedItemsResult<TEntity, TWithOrg>( const hintParts: string[] = []; if (items.length > limited.length) { const paginateTip = orgSlugForHint - ? ` Use '${asInvocationCommand(config.commandPrefix)} ${orgSlugForHint}/' for paginated results.` + ? ` Use '${config.commandPrefix()} ${orgSlugForHint}/' for paginated results.` : ""; hintParts.push( `Showing ${limited.length} of ${items.length} ${config.entityPlural}.${paginateTip}`, @@ -922,7 +922,7 @@ export async function handleExplicitOrg<TEntity, TWithOrg>( } if (items.length > 0 && !filtered) { - const tip = `Tip: Use '${asInvocationCommand(config.commandPrefix)} ${org}/' for paginated results`; + const tip = `Tip: Use '${config.commandPrefix()} ${org}/' for paginated results`; result.hint = result.hint ? `${result.hint}\n${tip}` : tip; } @@ -974,7 +974,7 @@ export async function handleExplicitProject<TEntity, TWithOrg>( }); if (items.length > 0 && !filtered) { - const tip = `Tip: Use '${asInvocationCommand(config.commandPrefix)} ${org}/' to see all ${config.entityPlural} in the org`; + const tip = `Tip: Use '${config.commandPrefix()} ${org}/' to see all ${config.entityPlural} in the org`; result.hint = result.hint ? `${result.hint}\n${tip}` : tip; } @@ -1054,10 +1054,10 @@ export async function handleProjectSearch<TEntity, TWithOrg>( throw new ResolutionError( `'${projectSlug}'`, "is an organization, not a project", - `${asInvocationCommand(config.commandPrefix)} ${projectSlug}/`, + `${config.commandPrefix()} ${projectSlug}/`, [ `List projects: ${sentry`project list ${projectSlug}/`}`, - `Specify a project: ${asInvocationCommand(config.commandPrefix)} ${projectSlug}/<project>`, + `Specify a project: ${config.commandPrefix()} ${projectSlug}/<project>`, ], ); } @@ -1074,7 +1074,7 @@ export async function handleProjectSearch<TEntity, TWithOrg>( throw new ResolutionError( `Project '${resolution.displaySlug}'`, "not found", - `${asInvocationCommand(config.commandPrefix)} <org>/${projectSlug}`, + `${config.commandPrefix()} <org>/${projectSlug}`, projectSearchNotFoundSuggestions(resolution), ); } @@ -1233,7 +1233,7 @@ async function handleExplicitByName<TEntity, TWithOrg>( items: [], hint: `No project '${name}' in '${org}', and no ${config.entityPlural} named like it.\n` + - `Tip: Use '${asInvocationCommand(config.commandPrefix)} ${org}/' to see all ${config.entityPlural}`, + `Tip: Use '${config.commandPrefix()} ${org}/' to see all ${config.entityPlural}`, }; } const note = `No project '${name}' in '${org}'. Matching ${config.entityPlural} by name instead.`; @@ -1281,7 +1281,7 @@ function withNameFilter<TEntity, TWithOrg>( const { namesOf, listForProject } = config; if (!namesOf) { throw new Error( - `'${asInvocationCommand(config.commandPrefix)}' got a name filter but its config has no namesOf.`, + `'${config.commandPrefix()}' got a name filter but its config has no namesOf.`, ); } const keep = (entities: TEntity[]) => filterByName(entities, filter, namesOf); @@ -1341,7 +1341,7 @@ function buildDefaultHandlers<TEntity, TWithOrg>( return () => Promise.reject( new Error( - `No handler for '${mode}' mode in '${asInvocationCommand(config.commandPrefix)}'. ` + + `No handler for '${mode}' mode in '${config.commandPrefix()}'. ` + "Provide a full OrgListConfig or an override for this mode.", ), ); @@ -1560,12 +1560,12 @@ export async function dispatchOrgScopedList<TEntity, TWithOrg>( if (flags.cursor && !cursorAllowedModes.includes(effectiveParsed.type)) { const hint = effectiveParsed.type === "project-search" - ? `\n\nDid you mean '${asInvocationCommand(config.commandPrefix)} ${effectiveParsed.projectSlug}/'? ` + + ? `\n\nDid you mean '${config.commandPrefix()} ${effectiveParsed.projectSlug}/'? ` + `A bare name searches for a project — add a trailing slash to list an org's ${config.entityPlural}.` : ""; throw new ValidationError( "The --cursor flag requires the <org>/ pattern " + - `(e.g., ${asInvocationCommand(config.commandPrefix)} my-org/).` + + `(e.g., ${config.commandPrefix()} my-org/).` + hint, "cursor", ); diff --git a/packages/cli/src/lib/resolve-team.ts b/packages/cli/src/lib/resolve-team.ts index e28a8a626..c67b010b7 100644 --- a/packages/cli/src/lib/resolve-team.ts +++ b/packages/cli/src/lib/resolve-team.ts @@ -22,7 +22,6 @@ import type { SentryTeam } from "../types/index.js"; import { createTeam, listOrganizations, listTeams } from "./api-client.js"; -import { asInvocationCommand } from "./app-identity.js"; import { ApiError, AuthError, @@ -123,7 +122,7 @@ async function handleListTeamsError( throw new ResolutionError( `Organization '${orgSlug}'`, `could not be accessed (${error.status})`, - `${asInvocationCommand(options.usageHint)} --team <team-slug>`, + `${options.usageHint} --team <team-slug>`, ["The organization may not exist, or you may lack access"], ); } @@ -209,11 +208,10 @@ function resolveEmptyTeams( ): Promise<ResolvedTeam> | ResolvedTeam { if (!options.autoCreateSlug) { const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; - throw new ContextError( - "Team", - `${asInvocationCommand(options.usageHint)} --team <team-slug>`, - [`No teams found in org '${orgSlug}'`, `Create a team at ${teamsUrl}`], - ); + throw new ContextError("Team", `${options.usageHint} --team <team-slug>`, [ + `No teams found in org '${orgSlug}'`, + `Create a team at ${teamsUrl}`, + ]); } if (options.dryRun) { return { slug: options.autoCreateSlug, source: "auto-created" }; @@ -280,7 +278,7 @@ export async function buildOrgNotFoundError( } const orgHint = await fetchOrgListHint( - `Specify org explicitly: ${asInvocationCommand(usageHint)}`, + `Specify org explicitly: ${usageHint}`, ); const suggestions: string[] = []; diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index cbb0d23bc..2157a1160 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -23,7 +23,7 @@ * `func` runs. */ -import { asInvocationCommand, sentry } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { firstNonBlankLine, type ListTarget, @@ -171,9 +171,7 @@ export function parseOrgInput( } const org = text.endsWith("/") ? text.slice(0, -1) : text; if (!org || org.includes("/")) { - const usage = usageHint - ? `\n\nUsage: ${asInvocationCommand(usageHint)}` - : ""; + const usage = usageHint ? `\n\nUsage: ${usageHint}` : ""; throw new ValidationError( `Expected an organization, not '${text}': this takes no project.${usage}`, "organization", diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index 3747f6514..79e845d16 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -10,7 +10,7 @@ * Used by: span list, span view, trace view, trace logs, log list. */ -import { asInvocationCommand, sentry } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { type ParsedOrgProject, parseOrgProjectArg, @@ -421,7 +421,7 @@ export async function resolveTraceOrgProject( case "org-scoped": throw new ContextError("Specific project", usageHint, [ - `Use: ${asInvocationCommand(usageHint.replace(USAGE_TARGET_RE, `${parsed.org}/<project>/${parsed.traceId}`))}`, + `Use: ${usageHint.replace(USAGE_TARGET_RE, `${parsed.org}/<project>/${parsed.traceId}`)}`, `List projects: ${sentry`project list ${parsed.org}/`}`, ]); diff --git a/packages/cli/test/commands/replay/view.test.ts b/packages/cli/test/commands/replay/view.test.ts index c64aa55d3..787648f11 100644 --- a/packages/cli/test/commands/replay/view.test.ts +++ b/packages/cli/test/commands/replay/view.test.ts @@ -468,7 +468,7 @@ describe("viewCommand.func", () => { }); describe("replayArguments", () => { - const positional = replayArguments("view", "sentry replay view <id>"); + const positional = replayArguments("view", () => "sentry replay view <id>"); const context = { cwd: "/tmp" }; const parse = (raw: string[]) => applyBoundPositionals(raw, positional, context)[0] as ReplayArguments; diff --git a/packages/cli/test/lib/app-identity-import.test.ts b/packages/cli/test/lib/app-identity-import.test.ts new file mode 100644 index 000000000..8af8ca15b --- /dev/null +++ b/packages/cli/test/lib/app-identity-import.test.ts @@ -0,0 +1,27 @@ +/** + * Hints name the running program, which an entry point registers only after + * the command modules load, so no hint may be built at import. + */ + +import { expect, test, vi } from "vitest"; + +const builtAtImport: string[] = []; + +vi.mock("../../src/lib/app-identity.js", async (importOriginal) => { + const actual = + await importOriginal<typeof import("../../src/lib/app-identity.js")>(); + return { + ...actual, + sentry: (strings: TemplateStringsArray, ...values: unknown[]) => { + const hint = actual.sentry(strings, ...values); + builtAtImport.push(hint); + return hint; + }, + }; +}); + +test("loading the commands builds no hints", async () => { + await import("../../src/app.js"); + await import("../../src/mcp-server-app.js"); + expect(builtAtImport).toEqual([]); +}); diff --git a/packages/cli/test/lib/app-identity.test.ts b/packages/cli/test/lib/app-identity.test.ts index 6bccbbce5..ec42c6c5b 100644 --- a/packages/cli/test/lib/app-identity.test.ts +++ b/packages/cli/test/lib/app-identity.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "vitest"; import { - asInvocationCommand, getLoginCommand, getUrlLoginCommand, sentry, @@ -48,15 +47,6 @@ describe("app identity", () => { expect(getLoginCommand()).toBe("npx -y @sentry/mcp-server auth login"); }); - test("rewrites only a leading `sentry ` in runtime commands", () => { - setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); - expect(asInvocationCommand("sentry auth login --url x")).toBe( - "sentry-mcp auth login --url x", - ); - expect(asInvocationCommand("npx sentry auth")).toBe("npx sentry auth"); - expect(asInvocationCommand("sentry-cli login")).toBe("sentry-cli login"); - }); - test("the sentry tag names the running program", () => { const org = "acme"; expect(sentry`issue list ${org}/`).toBe("sentry issue list acme/"); @@ -70,45 +60,40 @@ describe("app identity", () => { ); }); - test("the sentry tag rewrites interpolated commands once", () => { + test("the sentry tag inserts interpolated values as they are", () => { setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); - const usage = "sentry issue view <id>"; - expect(sentry`help ${usage}`).toBe( - "sentry-mcp help sentry-mcp issue view <id>", - ); const built = sentry`issue view 1`; expect(sentry`help ${built}`).toBe( "sentry-mcp help sentry-mcp issue view 1", ); + // Text that only looks like a command, such as a search, stays as typed + const query = "sentry issue view"; + expect(sentry`issue list --query ${query}`).toBe( + "sentry-mcp issue list --query sentry issue view", + ); expect(sentry`issue list ${"sentry"}/`).toBe( "sentry-mcp issue list sentry/", ); }); - test("command hints run this program again", () => { - const command = "sentry cli fix"; - expect(asInvocationCommand(command)).toBe(command); - + test("a command kept in a constant is built when it is shown", () => { + const usageHint = () => sentry`org list`; setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); - expect(asInvocationCommand(command)).toBe("sentry-mcp cli fix"); + expect(usageHint()).toBe("sentry-mcp org list"); process.env.npm_command = "exec"; - expect(asInvocationCommand(command)).toBe( - "npx -y @sentry/mcp-server cli fix", - ); + const context = new ContextError("Organization", usageHint()); + expect(context.command).toBe("npx -y @sentry/mcp-server org list"); + expect(context.message).toContain("npx -y @sentry/mcp-server org list"); }); - test("error usage lines name the running program under npx", () => { + test("error usage lines are shown as given", () => { process.env.npm_command = "exec"; - const context = new ContextError("Organization", "sentry org list"); - expect(context.command).toBe("npx -y sentry org list"); - expect(context.message).toContain("npx -y sentry org list"); - const resolution = new ResolutionError( "Project 'web'", "not found", - "sentry project view <org>/web", - ["sentry project list <org>/"], + sentry`project view <org>/web`, + [sentry`project list <org>/`], ); expect(resolution.hint).toBe("npx -y sentry project view <org>/web"); expect(resolution.suggestions).toEqual([ diff --git a/packages/cli/test/lib/id-arguments.test.ts b/packages/cli/test/lib/id-arguments.test.ts index e42f967df..75470514b 100644 --- a/packages/cli/test/lib/id-arguments.test.ts +++ b/packages/cli/test/lib/id-arguments.test.ts @@ -172,7 +172,7 @@ describe.each(KINDS)("%s IDs", (_name, kind, id, other) => { const label = kind.label.charAt(0).toLowerCase() + kind.label.slice(1); const text = args.at(-1)?.split("/").at(-1) ?? ""; expect(() => parse(kind, args)).toThrow( - `Not a ${label}: "${text}". To search, try \`${kind.search?.command} --query '${text}'\`.`, + `Not a ${label}: "${text}". To search, try \`${kind.search?.command()} --query '${text}'\`.`, ); // Unvalidated, it stays raw for the command to recover, then validate expect(parse(kind, args, { validate: false }).ids).toEqual([text]); diff --git a/packages/cli/test/lib/list-command.test.ts b/packages/cli/test/lib/list-command.test.ts index 550edf182..fd043667c 100644 --- a/packages/cli/test/lib/list-command.test.ts +++ b/packages/cli/test/lib/list-command.test.ts @@ -86,7 +86,7 @@ function makeFakeConfig( return { paginationKey: "fake-list", entityPlural: "widgets", - commandPrefix: "sentry widget list", + commandPrefix: () => "sentry widget list", listForOrg: vi.fn(() => Promise.resolve([])), listPaginated: vi.fn(() => Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }), diff --git a/packages/cli/test/lib/org-list.test.ts b/packages/cli/test/lib/org-list.test.ts index 99181b65f..2e7be4889 100644 --- a/packages/cli/test/lib/org-list.test.ts +++ b/packages/cli/test/lib/org-list.test.ts @@ -141,7 +141,7 @@ function makeConfig( return { paginationKey: "test-list", entityPlural: "widgets", - commandPrefix: "sentry widget list", + commandPrefix: () => "sentry widget list", listForOrg: vi.fn(() => Promise.resolve([])), listPaginated: vi.fn(() => Promise.resolve({ data: [] as FakeEntity[], nextCursor: undefined }), @@ -155,7 +155,7 @@ function makeConfig( const META_ONLY: ListCommandMeta = { paginationKey: "meta-list", entityPlural: "things", - commandPrefix: "sentry thing list", + commandPrefix: () => "sentry thing list", }; function createStdout() { diff --git a/packages/cli/test/lib/release-parse.property.test.ts b/packages/cli/test/lib/release-parse.property.test.ts index 02b4928bd..52c4cdcc1 100644 --- a/packages/cli/test/lib/release-parse.property.test.ts +++ b/packages/cli/test/lib/release-parse.property.test.ts @@ -146,7 +146,7 @@ describe("unit: releaseParameter", () => { }); const USAGE = "sentry release archive [<org>/]<version>"; - const { parse } = releaseParameter(USAGE, "Release version"); + const { parse } = releaseParameter(() => USAGE, "Release version"); const bind = (raw?: string): ReleaseTarget => parse.call({ cwd: "/tmp" }, raw); diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 01d23c951..75c9ed213 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -559,10 +559,14 @@ tester.run( filename: source, code: "function hint(org) { return sentry`project list ${org}/`; }", }, - // A command held in a variable goes through the runtime form. + // A command kept in a constant or table is built when it is shown. + { + filename: commands, + code: "const usageHint = () => sentry`issue list <org>/<project>`;", + }, { filename: source, - code: "function hint(usageHint) { return `Use: ${asInvocationCommand(usageHint)}`; }", + code: "const SUGGESTIONS = new Map([['issue/get', { command: () => sentry`issue view <id>` }]]);", }, { filename: commands, @@ -577,11 +581,6 @@ tester.run( filename: commands, code: "function label(file, version) { return [`sentry entries in ${file}`, 'sentry binary', `Installed sentry v${version}`]; }", }, - // A module-level usage line is data: ContextError and friends wrap it. - { - filename: commands, - code: "const USAGE_HINT = 'sentry issue list <org>/<project>';", - }, { filename: commands, code: "const docs = { examples: [{ command: 'sentry issue list' }] };", @@ -626,10 +625,10 @@ tester.run( code: "const MESSAGE = 'Usage: sentry local run -- <command>';", errors: 1, }, - // A literal command takes the tag, not the runtime form. + // A usage line in a constant is shown as written, so it needs the tag. { - filename: source, - code: "function hint() { return asInvocationCommand('sentry auth login'); }", + filename: commands, + code: "const USAGE_HINT = 'sentry issue list <org>/<project>';", errors: 1, }, // The tag adds the program name itself. @@ -646,7 +645,7 @@ tester.run( }, { filename: source, - code: "const HINT = asInvocationCommand(USAGE_HINT);", + code: "const config = { commandPrefix: sentry`team list` };", errors: 1, }, ],