From 74a1b072ad470a6a7b53b3d13e5a25d8e2601e61 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:43:29 +0000 Subject: [PATCH 1/4] feat(cli): Lint hardcoded sentry commands in mcp-server code Add the sentry-cli/no-hardcoded-sentry-command oxlint rule. In code that @sentry/mcp-server can run (all of packages/cli/src except app.ts and the commands the bundle does not ship), a `sentry ` string must go through asInvocationCommand(), so hints name the running program. Command help text (docs, fullDescription) is exempt. The rule also rejects a module-level asInvocationCommand() call, which runs before the entry point sets the app identity. Wrap the 127 existing hints, and move the few module-level hint constants into the functions that build them. Lookup tables that are rendered through asInvocationCommand(), the env var reference text, and one telemetry value keep their literals behind disable comments. Refs #2054 Refs #2050 --- .oxlintrc.json | 1 + packages/cli/AGENTS.md | 4 + packages/cli/lint-rules/cli-oxlint-plugin.js | 79 +++++++++++++++++++ packages/cli/src/commands/auth/list.ts | 13 ++- packages/cli/src/commands/auth/login.ts | 12 +-- packages/cli/src/commands/auth/logout.ts | 12 +-- packages/cli/src/commands/auth/refresh.ts | 3 +- packages/cli/src/commands/auth/status.ts | 5 +- packages/cli/src/commands/auth/switch.ts | 16 ++-- packages/cli/src/commands/auth/whoami.ts | 11 +-- packages/cli/src/commands/cli/fix.ts | 2 +- packages/cli/src/lib/account-selection.ts | 7 +- packages/cli/src/lib/api/infrastructure.ts | 12 +-- packages/cli/src/lib/api/issues.ts | 5 +- packages/cli/src/lib/api/releases.ts | 33 +++++--- packages/cli/src/lib/arg-parsing.ts | 11 +-- packages/cli/src/lib/command-suggestions.ts | 13 ++- packages/cli/src/lib/dashboard-arguments.ts | 7 +- packages/cli/src/lib/db/backup.ts | 5 +- packages/cli/src/lib/dsn/errors.ts | 17 +++- packages/cli/src/lib/env-registry.ts | 3 + .../cli/src/lib/envelope/event-send-dsn.ts | 15 ++-- packages/cli/src/lib/envelope/transport.ts | 3 +- packages/cli/src/lib/errors.ts | 29 ++++--- packages/cli/src/lib/event-attachments.ts | 3 +- packages/cli/src/lib/explore-errors.ts | 5 +- packages/cli/src/lib/formatters/accounts.ts | 5 +- packages/cli/src/lib/formatters/feedback.ts | 11 ++- packages/cli/src/lib/formatters/human.ts | 8 +- packages/cli/src/lib/formatters/issue.ts | 3 +- packages/cli/src/lib/formatters/replay.ts | 9 ++- packages/cli/src/lib/git.ts | 7 +- packages/cli/src/lib/help.ts | 13 +-- packages/cli/src/lib/hex-id-recovery.ts | 8 +- packages/cli/src/lib/hex-id.ts | 3 +- packages/cli/src/lib/id-arguments.ts | 9 ++- packages/cli/src/lib/issue-arguments.ts | 3 +- packages/cli/src/lib/mcp/server.ts | 1 + packages/cli/src/lib/metrics-transform.ts | 9 ++- packages/cli/src/lib/org-list.ts | 3 +- packages/cli/src/lib/resolve-target.ts | 39 +++++---- packages/cli/src/lib/sentry-client.ts | 5 +- packages/cli/src/lib/sentryclirc-import.ts | 3 +- packages/cli/src/lib/setup-code.ts | 3 +- packages/cli/src/lib/trace-target.ts | 7 +- packages/cli/src/lib/upgrade.ts | 5 +- packages/cli/src/lib/version-check.ts | 5 +- packages/cli/src/lib/wrangler.ts | 3 +- scripts/cli-lint-rules.test.mjs | 55 +++++++++++++ 49 files changed, 398 insertions(+), 145 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0b8532eb7..8b27deb7a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -103,6 +103,7 @@ "sentry-cli/no-regex-markdown-parsing": "error", "sentry-cli/json-transform-uses-fields": "error", "sentry-cli/no-runtime-output-schema-imports": "error", + "sentry-cli/no-hardcoded-sentry-command": "error", "no-restricted-imports": [ "error", { diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index fef5c8913..f98021c1e 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -156,6 +156,10 @@ Top-level layout: bundle (built by `packages/mcp-server/script/bundle.ts`). It must not import anything that only the CLI needs; the bundle script fails on commands, markdown rendering, agent skills, completions, terminal images, and the SQLite WASM fallback. + Hints in code the bundle can run name the running program: build them with + `asInvocationCommand("sentry …")` from `src/lib/app-identity.ts`, inside the + function that builds the hint. Lint (`sentry-cli/no-hardcoded-sentry-command`) + rejects a bare `sentry …` command outside command help text. - **`src/commands/`** — one directory per command group (`agent-conversation`, `alert`, `auth`, `build`, `cli`, `code-mappings`, `dart-symbol-map`, `dashboard`, `debug-files`, `docs`, `dsn`, `event`, `feedback`, `integration`, `issue`, `local`, diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index dd182a51c..a882bcde7 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -358,6 +358,52 @@ function untaggedParser(value) { return undefined; } +/** A `sentry ` hint: the CLI binary name, then a subcommand. */ +const SENTRY_COMMAND_RE = /(?:^|[\s`'"(])sentry [a-z]/; + +/** + * Files that `@sentry/mcp-server` can run: all of `src/` but the commands it + * does not ship and `app.ts`. Keep in step with `FORBIDDEN_INPUTS` in + * packages/mcp-server/script/bundle.ts. + */ +const CLI_ONLY_SOURCE_RE = + /\/src\/(?:app\.ts$|commands\/(?!auth\/|cli\/(?:defaults|fix)\.ts$|help\.ts$|mcp\.ts$))/; + +/** Calls that turn a `sentry …` command into the running program's command. */ +const INVOCATION_HELPERS = new Set([ + "asInvocationCommand", + "asRunningAppCommand", +]); + +/** Properties whose text is CLI help, rendered for the `sentry` binary. */ +const HELP_TEXT_KEYS = new Set(["docs", "fullDescription"]); + +/** Whether `node` sits inside a function body. */ +function hasFunctionAncestor(node) { + for (let current = node.parent; current; current = current.parent) { + if (FUNCTION_TYPES.has(current.type)) return true; + } + return false; +} + +/** Whether `node` sits in an invocation helper call or in command help text. */ +function isInvocationSafe(node) { + for (let current = node.parent; current; current = current.parent) { + if (FUNCTION_TYPES.has(current.type)) return false; + if ( + current.type === "CallExpression" && + INVOCATION_HELPERS.has(current.callee.name) + ) + return true; + if ( + current.type === "Property" && + HELP_TEXT_KEYS.has(current.key?.name ?? current.key?.value) + ) + return true; + } + return false; +} + const rules = { "no-silent-catch": rule( "Handle or report caught errors instead of discarding them.", @@ -802,6 +848,39 @@ const rules = { }; }, ), + "no-hardcoded-sentry-command": rule( + "Hints in code that @sentry/mcp-server runs must name the running program: wrap the `sentry …` command in asInvocationCommand() from lib/app-identity.js.", + (_context, file, report) => { + if (!file.includes("/src/") || CLI_ONLY_SOURCE_RE.test(file)) return {}; + const check = (node, text) => { + if (SENTRY_COMMAND_RE.test(text) && !isInvocationSafe(node)) + report(node); + }; + return { + // The running program is known only once an entry point has started, + // so a call evaluated at module load would still name `sentry`. + CallExpression(node) { + if ( + INVOCATION_HELPERS.has(node.callee.name) && + !hasFunctionAncestor(node) + ) + report( + node, + "Call asInvocationCommand() where the hint is built, not at module load: the running program is not known yet.", + ); + }, + Literal(node) { + if (typeof node.value === "string") check(node, node.value); + }, + TemplateLiteral(node) { + check( + node, + node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""), + ); + }, + }; + }, + ), "no-skipped-tests": rule( "Do not leave disabled tests in the suite.", (_context, file, report) => diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 77df081e4..3af83d120 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { listAccounts } from "../../lib/db/accounts.js"; import { @@ -64,11 +65,17 @@ export const listCommand = buildCommand({ }; } if (stored.length > 1) { - return { hint: "Switch accounts with 'sentry auth switch '." }; + return { + hint: `Switch accounts with '${asInvocationCommand("sentry auth switch ")}'.`, + }; } if (stored.length === 0) { - return { hint: "Log in with 'sentry auth login'." }; + return { + hint: `Log in with '${asInvocationCommand("sentry auth login")}'.`, + }; } - return { hint: "Add another account with 'sentry auth login'." }; + return { + hint: `Add another account with '${asInvocationCommand("sentry auth login")}'.`, + }; }, }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 52cba8634..46e578dda 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -132,8 +132,8 @@ function resolveLoginScope(flags: LoginFlags): string | undefined { `${conflicting} cannot be used with --token — OAuth scope is fixed when the token is issued, so the CLI cannot narrow an existing token's permissions.`, "", "To restrict OAuth scopes, drop --token and use the device flow:", - " sentry auth login --read-only", - " sentry auth login --scope project:read --scope org:read", + ` ${asInvocationCommand("sentry auth login --read-only")}`, + ` ${asInvocationCommand("sentry auth login --scope project:read --scope org:read")}`, "", "Or create a scoped User Auth Token in Sentry (Account → Auth Tokens) and pass it without --read-only/--scope.", ].join("\n"), @@ -305,8 +305,8 @@ export function rcTokenHint( : ` --url ${effectiveHost}`; return ( `Found a token in .sentryclirc (${rcConfig.sources.token}). ` + - "To import it: sentry cli import | " + - `To pass it directly: sentry auth login --token ${urlHint}` + `To import it: ${asInvocationCommand("sentry cli import")} | ` + + `To pass it directly: ${asInvocationCommand(`sentry auth login --token ${urlHint}`)}` ); } @@ -391,7 +391,7 @@ async function handleExistingAuth(force: boolean): Promise { // Non-interactive (piped, CI): print message and block if (!isatty(0)) { log.info( - "You are already authenticated. Use 'sentry auth login ' to add another account, " + + `You are already authenticated. Use '${asInvocationCommand("sentry auth login ")}' to add another account, ` + "or '--force' to replace the current login.", ); return false; @@ -402,7 +402,7 @@ async function handleExistingAuth(force: boolean): Promise { const identity = userInfo ? formatUserIdentity(userInfo) : "current user"; const confirmed = await prompt( `Already authenticated as ${identity}. Add another account? ` + - "(The current account stays saved; see 'sentry auth list'. Use --force to replace it.)", + `(The current account stays saved; see '${asInvocationCommand("sentry auth list")}'. Use --force to replace it.)`, { type: "confirm", initial: false }, ); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 82f98dbab..3cab5e77f 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -9,6 +9,7 @@ import { pointDefaultUrlAtAccount, resolveAccountSelector, } from "../../lib/account-selection.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { ACCOUNT_SERVER_TARGET, @@ -58,8 +59,6 @@ type LogoutFlags = { readonly fields?: string[]; }; -const COMMAND = "sentry auth logout"; - /** Selector that logs out of every stored account. */ const ALL_ACCOUNTS = "all"; @@ -178,7 +177,11 @@ export const logoutCommand = buildCommand({ if (selectors.length > 0) { targets = uniqueAccounts( selectors.map((sel) => - resolveAccountSelector(sel, COMMAND, { url: flags.url }), + resolveAccountSelector( + sel, + asInvocationCommand("sentry auth logout"), + { url: flags.url }, + ), ), ); } else { @@ -188,8 +191,7 @@ export const logoutCommand = buildCommand({ if (targets.length === 0) { return yield new CommandOutput({ loggedOut: false, - message: - "No account is active. Run 'sentry auth list' and pass an account to log out of, or 'all'.", + message: `No account is active. Run '${asInvocationCommand("sentry auth list")}' and pass an account to log out of, or 'all'.`, }); } diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index 01c2dbc99..cff2fea04 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -11,6 +11,7 @@ */ import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceListParser } from "../../lib/completion-target.js"; import { @@ -193,7 +194,7 @@ session (similar to \`gh auth refresh -s \`). throw new AuthError( "invalid", "No refresh token available. You may be using a manual API token.\n" + - "Run 'sentry auth login' to authenticate with OAuth and enable auto-refresh.", + `Run '${asInvocationCommand("sentry auth login")}' to authenticate with OAuth and enable auto-refresh.`, ); } diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index a714a6ca8..b7e686c34 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type AuthConfig, @@ -281,8 +282,8 @@ export const statusCommand = buildCommand({ classifySentryToken(effectiveToken) === "org-auth-token"; return { hint: isOrgToken - ? "Run `sentry auth whoami` to see which organization this token belongs to" - : "Run `sentry auth whoami` to see which user this token belongs to", + ? `Run \`${asInvocationCommand("sentry auth whoami")}\` to see which organization this token belongs to` + : `Run \`${asInvocationCommand("sentry auth whoami")}\` to see which user this token belongs to`, }; } }, diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index 1c37c7793..691f10fcb 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -10,6 +10,7 @@ import { promptForAccount, resolveAccountSelector, } from "../../lib/account-selection.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { ACCOUNT_SERVER_TARGET, @@ -36,8 +37,6 @@ import { import { CommandOutput } from "../../lib/formatters/output.js"; import { parseLoginUrl } from "./login.js"; -const COMMAND = "sentry auth switch"; - type SwitchFlags = { readonly url?: string; }; @@ -47,8 +46,9 @@ async function pickAccount( accounts: AccountSummary[], url: string | undefined, ): Promise { + const command = asInvocationCommand("sentry auth switch"); if (selector !== undefined) { - return resolveAccountSelector(selector, COMMAND, { url, fuzzy: true }); + return resolveAccountSelector(selector, command, { url, fuzzy: true }); } const candidates = url ? accounts.filter((a) => isAccountOnServer(a, url)) @@ -57,8 +57,10 @@ async function pickAccount( throw new ResolutionError( `Account on '${url}'`, "not found", - `${COMMAND} [account] --url `, - ["Run 'sentry auth list' to see stored accounts"], + `${command} [account] --url `, + [ + `Run '${asInvocationCommand("sentry auth list")}' to see stored accounts`, + ], ); } const [only] = candidates; @@ -68,7 +70,7 @@ async function pickAccount( return await promptForAccount( candidates, "Switch to which account?", - COMMAND, + command, ); } @@ -153,7 +155,7 @@ export const switchCommand = buildCommand({ if (!target.usable) { return { - hint: "This account's session has ended. Run 'sentry auth login' to log in again.", + hint: `This account's session has ended. Run '${asInvocationCommand("sentry auth login")}' to log in again.`, }; } if (getAuthConfig()?.source.startsWith("env:")) { diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index 7b5784f59..bc68cb680 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../../context.js"; import { getCurrentUser } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type AccountSummary, listAccounts } from "../../lib/db/accounts.js"; import { getAuthConfig, getAuthToken } from "../../lib/db/auth.js"; @@ -83,10 +84,10 @@ export const whoamiCommand = buildCommand({ throw new ResolutionError( "Organization auth tokens (sntrys_...)", "are not tied to a user — `whoami` needs a user-scoped credential", - "sentry auth status", + asInvocationCommand("sentry auth status"), [ - "Use an OAuth token from `sentry auth login` or a personal access token", - "Run `sentry org list` to list organizations this token can access", + `Use an OAuth token from \`${asInvocationCommand("sentry auth login")}\` or a personal access token`, + `Run \`${asInvocationCommand("sentry org list")}\` to list organizations this token can access`, ], ); } @@ -103,7 +104,7 @@ export const whoamiCommand = buildCommand({ }; yield new CommandOutput(data); return { - hint: "Run `sentry auth login` for user-scoped authentication", + hint: `Run \`${asInvocationCommand("sentry auth login")}\` for user-scoped authentication`, }; } @@ -160,6 +161,6 @@ export function formatOtherIdentitiesHint(user: { const you = user.name || user.username || user.email || `user ${user.id}`; return ( `You are ${you}, but you can be whoever you want — as long as it's one of: ` + - `${others.map(formatAccountLabel).join(", ")}. Run 'sentry auth switch'.` + `${others.map(formatAccountLabel).join(", ")}. Run '${asInvocationCommand("sentry auth switch")}'.` ); } diff --git a/packages/cli/src/commands/cli/fix.ts b/packages/cli/src/commands/cli/fix.ts index 3bbe5cf6a..34388d6b0 100644 --- a/packages/cli/src/commands/cli/fix.ts +++ b/packages/cli/src/commands/cli/fix.ts @@ -339,7 +339,7 @@ function ownershipInstructions( return ( "To fix ownership, run one of:\n\n" + ` sudo chown -R ${username} "${configDir}"\n\n` + - "Or let sentry fix it automatically:\n\n" + + "Or fix it automatically:\n\n" + ` sudo ${asInvocationCommand("sentry cli fix")}` ); } diff --git a/packages/cli/src/lib/account-selection.ts b/packages/cli/src/lib/account-selection.ts index 49cac0584..0db3dd920 100644 --- a/packages/cli/src/lib/account-selection.ts +++ b/packages/cli/src/lib/account-selection.ts @@ -5,6 +5,7 @@ */ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; +import { asInvocationCommand } from "./app-identity.js"; import { type AccountSummary, findAccounts, @@ -92,9 +93,11 @@ export function resolveAccountSelector( similar.length > 0 ? [ ...similar.map((a) => `Did you mean ${describeAccount(a)}?`), - "Run 'sentry auth list' to see stored accounts", + `Run '${asInvocationCommand("sentry auth list")}' to see stored accounts`, ] - : ["Run 'sentry auth list' to see stored accounts"], + : [ + `Run '${asInvocationCommand("sentry auth list")}' to see stored accounts`, + ], ); } diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index cdf9c3793..fb85038af 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -29,7 +29,7 @@ import * as Sentry from "@sentry/node"; import { type GenericSchema, safeParse } from "valibot"; import { extractRequiredScopes } from "../api-scope.js"; -import { getUrlLoginCommand } from "../app-identity.js"; +import { asInvocationCommand, getUrlLoginCommand } from "../app-identity.js"; import { type CredentialContext, getActiveEnvVarName, @@ -102,12 +102,12 @@ function enrich403Detail(rawDetail: string | undefined): string { const scopeArgs = scopes.map((s) => `--scope ${s}`).join(" "); lines.push( `Your token is missing the required scope(s) '${scopes.join("', '")}'.`, - `Re-authenticate with: sentry auth refresh ${scopeArgs}`, + `Re-authenticate with: ${asInvocationCommand(`sentry auth refresh ${scopeArgs}`)}`, ); } else { lines.push( "You may not have access to this resource.", - "Re-authenticate with: sentry auth login", + `Re-authenticate with: ${asInvocationCommand("sentry auth login")}`, ); } return lines.join("\n "); @@ -141,7 +141,7 @@ function enrich401Detail(rawDetail: string | undefined): string { "Your account is disabled in this organization because it is over its member limit.", "This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.", "Ask an org owner to upgrade the plan or free up a seat, then retry.", - "Or target a different org, e.g.: sentry issue list my-other-org/", + `Or target a different org, e.g.: ${asInvocationCommand("sentry issue list my-other-org/")}`, ].join("\n "); } @@ -158,7 +158,7 @@ function enrich401Detail(rawDetail: string | undefined): string { } else { lines.push( "Not authenticated or your session has expired.", - "Re-authenticate with: sentry auth login", + `Re-authenticate with: ${asInvocationCommand("sentry auth login")}`, ); } return lines.join("\n "); @@ -338,7 +338,7 @@ function buildNetworkErrorDetail(error: unknown): string { const steps = [causeHint ?? "Check your network connection and try again."]; steps.push( isSentrySaasUrl(baseUrl) - ? "Check Sentry's status with: sentry status" + ? `Check Sentry's status with: ${asInvocationCommand("sentry status")}` : describeUrlSource(), ); return [`Unable to reach Sentry at ${baseUrl}: ${cause}`, "", ...steps].join( diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index 654744594..ce1326cd2 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -9,6 +9,7 @@ import { listOrganizationIssues } from "@sentry/api"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; +import { asInvocationCommand } from "../app-identity.js"; import { buildTlsErrorDetail, customFetch, @@ -365,8 +366,8 @@ export async function getIssueByShortId( 404, [ "The issue may have been deleted or merged", - `Verify the short ID and org: sentry issue view ${orgSlug}/${normalizedShortId}`, - `List issues in this org: sentry issue list ${orgSlug}/`, + `Verify the short ID and org: ${asInvocationCommand(`sentry issue view ${orgSlug}/${normalizedShortId}`)}`, + `List issues in this org: ${asInvocationCommand(`sentry issue list ${orgSlug}/`)}`, ].join("\n "), ); } diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index 649e210cf..bb92d7d17 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -21,6 +21,7 @@ import type { SentryRelease, SentryRepository, } from "../../types/index.js"; +import { asInvocationCommand } from "../app-identity.js"; import { getCiCheckout } from "../ci.js"; import { getEnv } from "../env.js"; import { @@ -500,11 +501,13 @@ function resolveAutoCheckout( throw validationError( "Could not determine the repository: there is no 'origin' git remote and no CI repository variables (GITHUB_REPOSITORY, CI_PROJECT_PATH, VERCEL_GIT_REPO_SLUG, ...). --auto needs one of them to match a Sentry repository.", [ - `sentry release set-commits ${target} --commit owner/repo@`, + asInvocationCommand( + `sentry release set-commits ${target} --commit owner/repo@`, + ), ...(inGitCheckout ? [ "git remote add origin ", - `sentry release set-commits ${target} --local`, + asInvocationCommand(`sentry release set-commits ${target} --local`), ] : []), ], @@ -601,12 +604,13 @@ function noMatchingRepositoryError( const commit = checkout.commit ?? readHeadCommit(cwd) ?? ""; const target = `${orgSlug}/${version}`; const examples = [ - ...suggestions.map( - (name) => + ...suggestions.map((name) => + asInvocationCommand( `sentry release set-commits ${target} --commit '${name}@${commit}'`, + ), ), ...(isInsideGitWorkTree(cwd) - ? [`sentry release set-commits ${target} --local`] + ? [asInvocationCommand(`sentry release set-commits ${target} --local`)] : []), ]; const didYouMean = @@ -646,9 +650,13 @@ function noRepoIntegrationError( cwd?: string, ): ApiError { const detail = buildValidationMessage(connectIntegrationHint(orgSlug), [ - `sentry integration list ${orgSlug}/`, + asInvocationCommand(`sentry integration list ${orgSlug}/`), ...(isInsideGitWorkTree(cwd) - ? [`sentry release set-commits ${orgSlug}/${version} --local`] + ? [ + asInvocationCommand( + `sentry release set-commits ${orgSlug}/${version} --local`, + ), + ] : []), ]); return new ApiError( @@ -880,7 +888,9 @@ export async function explainSetCommitsError( : (ref?.commit ?? ""); const target = `${orgSlug}/${version}`; const commitExample = (name: string) => - `sentry release set-commits ${target} --commit '${name}@${commit}'`; + asInvocationCommand( + `sentry release set-commits ${target} --commit '${name}@${commit}'`, + ); const headline = `Sentry organization '${orgSlug}' has no repository named '${repository}'.`; let match: SentryRepository | undefined; @@ -909,7 +919,7 @@ export async function explainSetCommitsError( if (hasRepositories === false) { return validationError( `${headline} It has no repositories yet, so it cannot associate commits.`, - [`sentry integration list ${orgSlug}/`], + [asInvocationCommand(`sentry integration list ${orgSlug}/`)], COMMIT_REPOSITORY_FIELD, connectIntegrationHint(orgSlug), ); @@ -924,7 +934,10 @@ export async function explainSetCommitsError( : ""; return validationError( `${headline}${didYouMean}`, - [...suggestions.map(commitExample), `sentry repo list ${orgSlug}/`], + [ + ...suggestions.map(commitExample), + asInvocationCommand(`sentry repo list ${orgSlug}/`), + ], COMMIT_REPOSITORY_FIELD, `Add the repository to Sentry through your source code integration: ${buildRepositorySettingsUrl(orgSlug)}`, ); diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index 5e7cd3d1f..ea885df0c 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -8,6 +8,7 @@ import { parse as parseTarget } from "../generated/org-project-target-grammar.js"; import type { LogSortDirection } from "./api/logs.js"; +import { asInvocationCommand } from "./app-identity.js"; import { ContextError, ValidationError, validationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; import { isGlobFilter } from "./list-filter.js"; @@ -223,10 +224,10 @@ export function rejectIssueCommandTokenWithNumericSuffix( `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is an issue command name.`, [ - "sentry issue view PROJECT-1", - "sentry issue explain PROJECT-1", - "sentry issue explain 123456789", - "sentry issue explain my-org/project/PROJECT-G", + asInvocationCommand("sentry issue view PROJECT-1"), + asInvocationCommand("sentry issue explain PROJECT-1"), + asInvocationCommand("sentry issue explain 123456789"), + asInvocationCommand("sentry issue explain my-org/project/PROJECT-G"), ], "issue", ); @@ -677,7 +678,7 @@ function rejectAtSelector(value: string, label: string): void { const article = "aeiouAEIOU".includes(label.charAt(0)) ? "an" : "a"; throw new ValidationError( `'${value}' is an issue selector, not ${article} ${label}.\n` + - ` Use: sentry issue view ${value}`, + ` Use: ${asInvocationCommand(`sentry issue view ${value}`)}`, label, ); } diff --git a/packages/cli/src/lib/command-suggestions.ts b/packages/cli/src/lib/command-suggestions.ts index f89e2c785..49c0aeed9 100644 --- a/packages/cli/src/lib/command-suggestions.ts +++ b/packages/cli/src/lib/command-suggestions.ts @@ -9,6 +9,8 @@ * Populated from CLI-Q3 telemetry analysis (~100 events, 85 users). */ +import { asInvocationCommand } from "./app-identity.js"; + /** A suggestion for an unknown command token. */ export type CommandSuggestion = { /** The command to suggest (shown in "Tip:" hint) */ @@ -25,6 +27,8 @@ 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. */ +// getCommandSuggestion() passes each command through asInvocationCommand(). +/* oxlint-disable sentry-cli/no-hardcoded-sentry-command */ const SUGGESTIONS: ReadonlyMap = new Map([ // --- view synonyms (~15 events) --- ["issue/get", { command: "sentry issue view " }], @@ -92,6 +96,7 @@ const SUGGESTIONS: ReadonlyMap = new Map([ ["issue/search", { command: "sentry issue list --query " }], ["issue/find", { command: "sentry issue list --query " }], ]); +/* oxlint-enable sentry-cli/no-hardcoded-sentry-command */ /** * Look up a suggestion for an unknown command token. @@ -107,7 +112,13 @@ export function getCommandSuggestion( const key = routeContext ? `${routeContext}/${unknownToken.toLowerCase()}` : `/${unknownToken.toLowerCase()}`; - return SUGGESTIONS.get(key); + const suggestion = SUGGESTIONS.get(key); + return ( + suggestion && { + ...suggestion, + command: asInvocationCommand(suggestion.command), + } + ); } /** diff --git a/packages/cli/src/lib/dashboard-arguments.ts b/packages/cli/src/lib/dashboard-arguments.ts index 0da5409a2..a65b24569 100644 --- a/packages/cli/src/lib/dashboard-arguments.ts +++ b/packages/cli/src/lib/dashboard-arguments.ts @@ -17,6 +17,7 @@ * apply. */ +import { asInvocationCommand } from "./app-identity.js"; import { ValidationError } from "./errors.js"; import { ID_KINDS, parseIdArguments } from "./id-arguments.js"; import { type ParsedSentryUrl, parseSentryUrl } from "./sentry-url-parser.js"; @@ -63,7 +64,7 @@ export function parseDashboardArgs( throw new ValidationError( "Dashboard ID or title is required.\n\n" + "The URL provided contains an org but no dashboard ID.\n" + - `Try: sentry dashboard ${url.org}/ `, + `Try: ${asInvocationCommand(`sentry dashboard ${url.org}/ `)}`, "dashboard", ); } @@ -135,9 +136,9 @@ export function parseDashboardListArgs( const orgNote = url.org ? ` in '${url.org}'` : ""; throw new ValidationError( "This looks like a dashboard URL. To view a specific dashboard:\n\n" + - ` sentry dashboard view ${orgPrefix}${url.dashboardId}\n\n` + + ` ${asInvocationCommand(`sentry dashboard view ${orgPrefix}${url.dashboardId}`)}\n\n` + `To list dashboards${orgNote}:\n\n` + - ` sentry dashboard list${orgSuffix}`, + ` ${asInvocationCommand(`sentry dashboard list${orgSuffix}`)}`, "dashboard", ); } diff --git a/packages/cli/src/lib/db/backup.ts b/packages/cli/src/lib/db/backup.ts index e2a484359..e3f418323 100644 --- a/packages/cli/src/lib/db/backup.ts +++ b/packages/cli/src/lib/db/backup.ts @@ -23,6 +23,7 @@ import { stat, } from "node:fs/promises"; import { join } from "node:path"; +import { asInvocationCommand } from "../app-identity.js"; import { stringifyUnknown, UpgradeError } from "../errors.js"; import { logger } from "../logger.js"; import { @@ -188,7 +189,7 @@ export async function getUndoBackup( if (!last) { throw new UpgradeError( "unsupported_operation", - "Nothing to undo: no upgrade made with `sentry cli upgrade` was recorded.", + `Nothing to undo: no upgrade made with \`${asInvocationCommand("sentry cli upgrade")}\` was recorded.`, ); } if (last.toVersion !== currentVersion) { @@ -275,7 +276,7 @@ function carryOverAccounts( if (!current) { return; } - const loginHint = `Your logins are as they were in ${backup.version}; run \`sentry auth login\` if you need another.`; + const loginHint = `Your logins are as they were in ${backup.version}; run \`${asInvocationCommand("sentry auth login")}\` if you need another.`; if (!tableExists(restored, "accounts")) { if (current.rows.length > 0) { return `${backup.version} stores logins in an older format, so your current logins could not be kept. ${loginHint}`; diff --git a/packages/cli/src/lib/dsn/errors.ts b/packages/cli/src/lib/dsn/errors.ts index 7f2e7d001..d9eb205d8 100644 --- a/packages/cli/src/lib/dsn/errors.ts +++ b/packages/cli/src/lib/dsn/errors.ts @@ -5,6 +5,7 @@ * Provides helpful context and suggestions for resolving issues. */ +import { asInvocationCommand } from "../app-identity.js"; import { getDsnSourceDescription } from "./detector.js"; import { SENTRY_DSN_ENV } from "./env.js"; import { getAccessibleProjects } from "./resolver.js"; @@ -34,8 +35,12 @@ export function formatConflictError(result: DsnDetectionResult): string { lines.push(" sentry /"); lines.push(""); lines.push("Or set a default project:"); - lines.push(" sentry config set defaults.org "); - lines.push(" sentry config set defaults.project "); + lines.push( + ` ${asInvocationCommand("sentry config set defaults.org ")}`, + ); + lines.push( + ` ${asInvocationCommand("sentry config set defaults.project ")}`, + ); return lines.join("\n"); } @@ -95,8 +100,12 @@ export async function formatNoDsnError( lines.push(" sentry /"); lines.push(""); lines.push("4. Set default project:"); - lines.push(" sentry config set defaults.org "); - lines.push(" sentry config set defaults.project "); + lines.push( + ` ${asInvocationCommand("sentry config set defaults.org ")}`, + ); + lines.push( + ` ${asInvocationCommand("sentry config set defaults.project ")}`, + ); return lines.join("\n"); } diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 50d57a40f..33d85dcbd 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -39,6 +39,8 @@ export type EnvVarEntry = { * install → display → logging/telemetry → cache/pagination → database. * The generator preserves this order in the output. */ +// Reference text for the env var docs page, which documents the `sentry` CLI. +/* oxlint-disable sentry-cli/no-hardcoded-sentry-command */ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ // -- Auth -- { @@ -315,6 +317,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ example: "1", }, ]; +/* oxlint-enable sentry-cli/no-hardcoded-sentry-command */ /** * Subset of env vars surfaced in the branded `sentry --help` output and in diff --git a/packages/cli/src/lib/envelope/event-send-dsn.ts b/packages/cli/src/lib/envelope/event-send-dsn.ts index 91b5a2b02..dc5b2a0f3 100644 --- a/packages/cli/src/lib/envelope/event-send-dsn.ts +++ b/packages/cli/src/lib/envelope/event-send-dsn.ts @@ -8,6 +8,7 @@ import { isAbsolute } from "node:path"; import { getProjectKeys } from "../api/projects.js"; +import { asInvocationCommand } from "../app-identity.js"; import { getAuthConfig } from "../db/auth.js"; import { ConfigError } from "../errors.js"; import { logger } from "../logger.js"; @@ -19,7 +20,9 @@ const log = logger.withTag("event.send"); const DSN_PREFIX_RE = /^https?:\/\//i; /** Usage example shown on missing-DSN errors. */ -export const EVENT_SEND_DSN_HINT = "sentry event send -m 'My message'"; +function eventSendDsnHint(): string { + return asInvocationCommand("sentry event send -m 'My message'"); +} /** User-facing missing-DSN message covering every resolution source. */ export const EVENT_SEND_NO_DSN_MESSAGE = @@ -85,7 +88,7 @@ export async function resolveEventSendDsn( if (scanned) { return scanned; } - throw new ConfigError(EVENT_SEND_NO_DSN_MESSAGE, EVENT_SEND_DSN_HINT); + throw new ConfigError(EVENT_SEND_NO_DSN_MESSAGE, eventSendDsnHint()); } function looksLikeDsn(value: string): boolean { @@ -122,8 +125,8 @@ async function dsnFromProjectTarget( ): Promise { if (!getAuthConfig()) { throw new ConfigError( - `Cannot resolve project '${target}' without a logged-in session. Pass the DSN as the first argument or run sentry auth login.`, - "sentry auth login", + `Cannot resolve project '${target}' without a logged-in session. Pass the DSN as the first argument or run ${asInvocationCommand("sentry auth login")}.`, + asInvocationCommand("sentry auth login"), ); } const { org, project } = await resolveProjectBoundFromArg( @@ -143,14 +146,14 @@ async function dsnFromProjectTarget( if (activeDsns.length > 1) { throw new ConfigError( `Project ${org}/${project} has multiple active DSNs. Pass the desired DSN as the first argument.`, - EVENT_SEND_DSN_HINT, + eventSendDsnHint(), ); } const dsn = activeDsns[0]; if (!dsn) { throw new ConfigError( `No active DSN found for ${org}/${project}. Pass a DSN as the first argument.`, - EVENT_SEND_DSN_HINT, + eventSendDsnHint(), ); } log.debug(`Using DSN from ${org}/${project} client keys`); diff --git a/packages/cli/src/lib/envelope/transport.ts b/packages/cli/src/lib/envelope/transport.ts index a1affec29..d182a13a9 100644 --- a/packages/cli/src/lib/envelope/transport.ts +++ b/packages/cli/src/lib/envelope/transport.ts @@ -12,6 +12,7 @@ */ import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn } from "@sentry/core"; +import { asInvocationCommand } from "../app-identity.js"; import { detectDsn } from "../dsn/index.js"; import { ApiError, ConfigError, ValidationError } from "../errors.js"; import { logger } from "../logger.js"; @@ -107,7 +108,7 @@ export async function resolveIngestDsn( */ export function requireDsn( flags: DsnFlags, - usageHint = "sentry bash-hook --dsn ", + usageHint = asInvocationCommand("sentry bash-hook --dsn "), ): string { const dsn = resolveDsn(flags); if (dsn) { diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index ef1dab498..5852d5879 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -30,7 +30,11 @@ import { buildOrgSettingsUrl, buildSeerSettingsUrl, } from "./sentry-urls.js"; -import { getLoginCommand, getUrlLoginCommand } from "./app-identity.js"; +import { + asInvocationCommand, + getLoginCommand, + getUrlLoginCommand, +} from "./app-identity.js"; // --------------------------------------------------------------------------- // Exit code constants @@ -370,12 +374,15 @@ export class OutputError extends CliError { } } -const DEFAULT_CONTEXT_ALTERNATIVES = [ - "Run from a directory with a Sentry DSN in source code or .env files", - "Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables", - "Run 'sentry org list' to find your organization slug", - "Run 'sentry project list /' to find project slugs", -] as const; +/** Ways to provide context when auto-detection found none. */ +function defaultContextAlternatives(): string[] { + return [ + "Run from a directory with a Sentry DSN in source code or .env files", + "Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables", + `Run '${asInvocationCommand("sentry org list")}' to find your organization slug`, + `Run '${asInvocationCommand("sentry project list /")}' to find project slugs`, + ]; +} /** * Build the formatted context error message with usage hints. @@ -489,9 +496,7 @@ export class ContextError extends CliError { ) { // When alternatives is omitted, auto-detection was tried and failed const isAutoDetect = alternatives === undefined; - const resolvedAlternatives = alternatives ?? [ - ...DEFAULT_CONTEXT_ALTERNATIVES, - ]; + const resolvedAlternatives = alternatives ?? defaultContextAlternatives(); // Include full formatted message so it's shown even when caught by external handlers super( @@ -735,8 +740,8 @@ export class SeerError extends CliError { // where the prompt can't fire — hence the direct command suggestion // (CLI-1D/BW/98, 237 users combined). const trialHint = this.orgSlug - ? `\n\nStart a free trial:\n sentry trial start seer ${this.orgSlug}` - : "\n\nYou may be eligible for a free trial:\n sentry trial list"; + ? `\n\nStart a free trial:\n ${asInvocationCommand(`sentry trial start seer ${this.orgSlug}`)}` + : `\n\nYou may be eligible for a free trial:\n ${asInvocationCommand("sentry trial list")}`; // When org slug is known, provide direct URLs to settings if (this.orgSlug) { diff --git a/packages/cli/src/lib/event-attachments.ts b/packages/cli/src/lib/event-attachments.ts index 4111c8508..c8842e1d7 100644 --- a/packages/cli/src/lib/event-attachments.ts +++ b/packages/cli/src/lib/event-attachments.ts @@ -9,6 +9,7 @@ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { EventAttachmentDetailsResponse } from "@sentry/api"; import type { EventViewAttachment, SentryEvent } from "../types/index.js"; import { listEventAttachments } from "./api-client.js"; +import { asInvocationCommand } from "./app-identity.js"; import { renderMarkdown } from "./formatters/markdown.js"; import { formatBytes } from "./formatters/numbers.js"; import { logger } from "./logger.js"; @@ -168,5 +169,5 @@ export function attachmentDownloadHint( const name = first.name || "attachment"; const extra = attachments.length > 1 ? ` (${attachments.length - 1} more)` : ""; - return `Download attachment: sentry api ${shellQuote(first.download)} > ${shellQuote(name)}${extra}`; + return `Download attachment: ${asInvocationCommand(`sentry api ${shellQuote(first.download)} > ${shellQuote(name)}${extra}`)}`; } diff --git a/packages/cli/src/lib/explore-errors.ts b/packages/cli/src/lib/explore-errors.ts index 09f457c2d..8a79cfd39 100644 --- a/packages/cli/src/lib/explore-errors.ts +++ b/packages/cli/src/lib/explore-errors.ts @@ -6,6 +6,7 @@ import { parseFunction } from "@sentry/toolkit-core/events-fields"; import { getSortField } from "@sentry/toolkit-core/events-sort"; +import { asInvocationCommand } from "./app-identity.js"; import { ApiError, type FunctionArgument, validationError } from "./errors.js"; /** User-authored expressions and the request they contributed to. */ @@ -63,7 +64,7 @@ function recoveryHint( } if (detail === `Unknown function ${parsed.name}`) { - return "Aggregate functions depend on the dataset. Check the function name and selected dataset; sentry explore --help includes usage examples."; + return `Aggregate functions depend on the dataset. Check the function name and selected dataset; ${asInvocationCommand("sentry explore --help")} includes usage examples.`; } if ( @@ -79,7 +80,7 @@ function recoveryHint( const { argument } = rejected; const problem = `${parsed.name}() does not accept ${argument} on dataset "${dataset}": it may not exist there, or may not have the type ${parsed.name}() needs.`; return ATTRIBUTE_DATASETS.has(dataset) - ? `${problem} Reference a numeric custom attribute with its type, e.g. tags[${argument},number]; to check the key and its type, run: sentry attribute list / ${argument} --dataset ${dataset}` + ? `${problem} Reference a numeric custom attribute with its type, e.g. tags[${argument},number]; to check the key and its type, run: ${asInvocationCommand(`sentry attribute list / ${argument} --dataset ${dataset}`)}` : `${problem} Check the column name and its type.`; } diff --git a/packages/cli/src/lib/formatters/accounts.ts b/packages/cli/src/lib/formatters/accounts.ts index 41e9cbca4..10987e391 100644 --- a/packages/cli/src/lib/formatters/accounts.ts +++ b/packages/cli/src/lib/formatters/accounts.ts @@ -4,6 +4,7 @@ */ import { text } from "@sentry/toolkit-core/markdown"; +import { asInvocationCommand } from "../app-identity.js"; import { type AccountSummary, hostnameOf } from "../db/accounts.js"; import { colorTag, renderMarkdown } from "./markdown.js"; import { formatTable } from "./table.js"; @@ -55,7 +56,9 @@ export type AccountListResult = { /** Human output for `sentry auth list`. */ export function formatAccountList(data: AccountListResult): string { if (data.accounts.length === 0) { - return renderMarkdown("No stored accounts. Run `sentry auth login`."); + return renderMarkdown( + `No stored accounts. Run \`${asInvocationCommand("sentry auth login")}\`.`, + ); } return formatTable(data.accounts, [ { header: "", value: (a) => (a.active ? "*" : ""), minWidth: 1 }, diff --git a/packages/cli/src/lib/formatters/feedback.ts b/packages/cli/src/lib/formatters/feedback.ts index 3636546a4..f35220322 100644 --- a/packages/cli/src/lib/formatters/feedback.ts +++ b/packages/cli/src/lib/formatters/feedback.ts @@ -14,6 +14,7 @@ import type { SentryEvent, SentryFeedback, } from "../../types/index.js"; +import { asInvocationCommand } from "../app-identity.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails } from "./human.js"; import { colorTag, mdKvTable, renderMarkdown } from "./markdown.js"; @@ -316,8 +317,10 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { const project = feedback.project?.slug; const command = data.org && project - ? `sentry event view ${data.org}/${project}/${associatedEventId}` - : `sentry event view ${associatedEventId}`; + ? asInvocationCommand( + `sentry event view ${data.org}/${project}/${associatedEventId}`, + ) + : asInvocationCommand(`sentry event view ${associatedEventId}`); rows.push(["Linked error", feedbackCodeSpan(command)]); } @@ -363,8 +366,8 @@ function formatRelatedReplays(data: FeedbackViewResult): string { const lines = ["### Additional Replays", ""]; for (const replayId of additionalIds.slice(0, MAX_REPLAY_IDS_SHOWN)) { const command = data.org - ? `sentry replay view ${data.org}/${replayId}` - : `sentry replay view ${replayId}`; + ? asInvocationCommand(`sentry replay view ${data.org}/${replayId}`) + : asInvocationCommand(`sentry replay view ${replayId}`); lines.push( `- ${feedbackCodeSpan(replayId)} (${feedbackCodeSpan(command)})`, ); diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index 3cb1b7e48..f33e2f751 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -1828,7 +1828,7 @@ function buildAuthDetailRows(data: AuthStatusData): [string, string][] { if (data.accounts !== undefined && data.accounts > 1) { rows.push([ "Accounts", - `${data.accounts} stored (${inlineCode("sentry auth list")})`, + `${data.accounts} stored (${inlineCode(asInvocationCommand("sentry auth list"))})`, ]); } if (data.token) { @@ -2046,8 +2046,8 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { } else if (result.teamSource === "auto-selected") { lines.push( dry - ? `> **Note:** Would use team '${text(result.teamSlug)}'. See all teams: \`sentry team list\`` - : `> **Note:** Using team '${text(result.teamSlug)}'. See all teams: \`sentry team list\``, + ? `> **Note:** Would use team '${text(result.teamSlug)}'. See all teams: \`${asInvocationCommand("sentry team list")}\`` + : `> **Note:** Using team '${text(result.teamSlug)}'. See all teams: \`${asInvocationCommand("sentry team list")}\``, ); lines.push(""); } @@ -2072,7 +2072,7 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { if (!dry && !tipsSuppressed()) { lines.push(""); lines.push( - `*Tip: Use \`sentry project view ${result.orgSlug}/${result.project.slug}\` for details*`, + `*Tip: Use \`${asInvocationCommand(`sentry project view ${result.orgSlug}/${result.project.slug}`)}\` for details*`, ); } diff --git a/packages/cli/src/lib/formatters/issue.ts b/packages/cli/src/lib/formatters/issue.ts index e346ade89..3e6f413fe 100644 --- a/packages/cli/src/lib/formatters/issue.ts +++ b/packages/cli/src/lib/formatters/issue.ts @@ -4,6 +4,7 @@ import type { InferOutput } from "valibot"; import type { SentryIssue } from "../../types/index.js"; +import { asInvocationCommand } from "../app-identity.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails, formatIssueDetails } from "./human.js"; import { filterFieldsIfSet } from "./json.js"; @@ -44,7 +45,7 @@ function formatReplaySection(org: string | null, replayIds: string[]): string { for (const replayId of visibleReplayIds) { if (org) { lines.push( - `- \`${replayId}\` (view: \`sentry replay view ${org}/${replayId}\`)`, + `- \`${replayId}\` (view: \`${asInvocationCommand(`sentry replay view ${org}/${replayId}`)}\`)`, ); } else { lines.push(`- \`${replayId}\``); diff --git a/packages/cli/src/lib/formatters/replay.ts b/packages/cli/src/lib/formatters/replay.ts index 6d1a1ed87..2b8d7cbbf 100644 --- a/packages/cli/src/lib/formatters/replay.ts +++ b/packages/cli/src/lib/formatters/replay.ts @@ -14,6 +14,7 @@ import type { ReplayRelatedIssue, ReplayRelatedTrace, } from "../../types/index.js"; +import { asInvocationCommand } from "../app-identity.js"; import { getReplayUserLabel } from "../replay-search.js"; import { buildReplayUrl } from "../sentry-web-urls.js"; import { mdKvTable, renderMarkdown } from "./markdown.js"; @@ -514,7 +515,7 @@ function formatRelatedIssueLine( return `- Event \`${issue.eventId}\``; } - return `- \`${issue.shortId}\`: ${text(issue.title)} (view: \`sentry issue view ${org}/${issue.shortId}\`)`; + return `- \`${issue.shortId}\`: ${text(issue.title)} (view: \`${asInvocationCommand(`sentry issue view ${org}/${issue.shortId}`)}\`)`; } function buildRelatedTraceStats(trace: ReplayRelatedTrace): string[] { @@ -541,7 +542,7 @@ function formatRelatedTraceLine( ): string { const stats = buildRelatedTraceStats(trace); const suffix = stats.length > 0 ? ` (${stats.join(", ")})` : ""; - return `- Trace \`${trace.traceId}\`${suffix} (view: \`sentry trace view ${org}/${trace.traceId}\`)`; + return `- Trace \`${trace.traceId}\`${suffix} (view: \`${asInvocationCommand(`sentry trace view ${org}/${trace.traceId}`)}\`)`; } function pushRelatedSection( @@ -594,12 +595,12 @@ export function formatReplayDetails(data: ReplayViewData): string { export function replayHint(data: ReplayViewData): string | undefined { const traceId = data.replay.trace_ids?.[0]; if (traceId) { - return `Related trace: sentry trace view ${data.org}/${traceId}`; + return `Related trace: ${asInvocationCommand(`sentry trace view ${data.org}/${traceId}`)}`; } const issue = data.relatedIssues[0]; if (issue?.shortId) { - return `Related issue: sentry issue view ${data.org}/${issue.shortId}`; + return `Related issue: ${asInvocationCommand(`sentry issue view ${data.org}/${issue.shortId}`)}`; } return; diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index 3208223d7..c853545d5 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -13,6 +13,7 @@ import { execFileSync } from "node:child_process"; +import { asInvocationCommand } from "./app-identity.js"; import { ValidationError, validationError } from "./errors.js"; /** `execFileSync` failure shape when git exits non-zero. */ @@ -185,7 +186,7 @@ export function getCommitLog( if (from?.startsWith("-")) { throw validationError( `--from must be a git ref, not a CLI flag (received '${from}').`, - ["sentry release set-commits 1.0.0 --from v0.9.0"], + [asInvocationCommand("sentry release set-commits 1.0.0 --from v0.9.0")], "from", "Git refs cannot start with '-'. Tokens like '--format=x' are CLI flags, not refs — use a tag or commit (e.g. v0.9.0).", ); @@ -221,7 +222,9 @@ export function getCommitLog( `Unknown git ref '${from}': not found in this repository.`, [ `git rev-parse ${from}`, - "sentry release set-commits / --from v0.9.0", + asInvocationCommand( + "sentry release set-commits / --from v0.9.0", + ), ], "from", "Range is always ..HEAD — checkout the current release tag first.", diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index 3792f2164..bf3a3717b 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -6,7 +6,7 @@ * Commands are auto-generated from Stricli's route structure. */ -import { asRunningAppCommand } from "./app-identity.js"; +import { asInvocationCommand, asRunningAppCommand } from "./app-identity.js"; import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { detectAgent } from "./detect-agent.js"; @@ -217,10 +217,7 @@ function fitToTerminalHeight( ); } -const EXAMPLE_LOGGED_OUT = "sentry auth"; -const EXAMPLE_LOGGED_IN = "sentry issue list"; const DOCS_URL = "https://cli.sentry.dev/getting-started/"; -const FULL_HELP_COMMAND = "sentry help"; /** * Format the command list with aligned descriptions. @@ -320,7 +317,9 @@ type CustomHelpOptions = { */ export function printCustomHelp(options: CustomHelpOptions = {}): string { const loggedIn = isAuthenticated(); - const example = loggedIn ? EXAMPLE_LOGGED_IN : EXAMPLE_LOGGED_OUT; + const example = asInvocationCommand( + loggedIn ? "sentry issue list" : "sentry auth", + ); const overview = Boolean(options.overview) && !detectAgent(); const header: string[] = []; @@ -370,7 +369,9 @@ export function printCustomHelp(options: CustomHelpOptions = {}): string { // Example lines.push(` ${muted("try:")} ${magenta(example)}`); if (overview) { - lines.push(` ${muted("all commands:")} ${magenta(FULL_HELP_COMMAND)}`); + lines.push( + ` ${muted("all commands:")} ${magenta(asInvocationCommand("sentry help"))}`, + ); } lines.push(""); diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index 40d34cfe9..f13c22dec 100644 --- a/packages/cli/src/lib/hex-id-recovery.ts +++ b/packages/cli/src/lib/hex-id-recovery.ts @@ -28,6 +28,7 @@ import { addBreadcrumb } from "@sentry/node"; import type { SpanListItem } from "../types/index.js"; import { listLogs, listSpans, queryEvents } from "./api-client.js"; +import { asInvocationCommand } from "./app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -184,6 +185,8 @@ 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). */ +// buildSlugHint() passes each command through asInvocationCommand(). +/* oxlint-disable sentry-cli/no-hardcoded-sentry-command */ const SLUG_REDIRECT_COMMAND: Record = { event: "sentry issue list", trace: "sentry trace list", @@ -191,6 +194,7 @@ const SLUG_REDIRECT_COMMAND: Record = { // Spans are trace-scoped — a slug-only input can't map to a list command. span: "", }; +/* oxlint-enable sentry-cli/no-hardcoded-sentry-command */ // --------------------------------------------------------------------------- // Pure helpers (exported for tests) @@ -589,7 +593,7 @@ function buildSlugHint( const orgPart = org ? `${org}/` : "/"; const cmd = SLUG_REDIRECT_COMMAND[entityType]; if (cmd) { - 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. Try: ${asInvocationCommand(cmd)} ${orgPart}${input}`; } return `'${input}' looks like a project slug, not a ${entityType} ID. Spans are identified by 16 hex chars within a trace.`; } @@ -628,7 +632,7 @@ function buildNoMatchHint( return ( `No event matched this prefix in the last ${window}. ` + "Sentry cannot search by a partial event ID — pass the full " + - "32-character ID printed by `sentry event list`." + `32-character ID printed by \`${asInvocationCommand("sentry event list")}\`.` ); } return ( diff --git a/packages/cli/src/lib/hex-id.ts b/packages/cli/src/lib/hex-id.ts index 3047b418c..a95e7f00d 100644 --- a/packages/cli/src/lib/hex-id.ts +++ b/packages/cli/src/lib/hex-id.ts @@ -8,6 +8,7 @@ * rather than rolling their own patterns. */ +import { asInvocationCommand } from "./app-identity.js"; import { ValidationError } from "./errors.js"; /** @@ -165,7 +166,7 @@ export function validateHexId(value: string, label: string): string { // 16-char hex looks like a span ID message += "\n\nThis looks like a span ID (16 characters). " + - `If you have the trace ID, try: sentry span view ${display}`; + `If you have the trace ID, try: ${asInvocationCommand(`sentry span view ${display}`)}`; } else if (NON_HEX_RE.test(normalized)) { // Contains non-hex characters — likely a slug, name, or truncated input. // The hex-id-recovery module at the command layer provides more specific diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index 146ca4edc..128fa9dce 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -33,6 +33,7 @@ */ import { parse } from "../generated/id-arguments-grammar.js"; +import { asInvocationCommand } from "./app-identity.js"; import { looksLikeIssueShortId } from "./arg-parsing.js"; import { ContextError, ValidationError } from "./errors.js"; import { @@ -167,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 \`${search.command} --query ${shellQuote(value)}\`.\n\n` + + `Not a ${label}: "${value}". To search, try \`${asInvocationCommand(search.command)} --query ${shellQuote(value)}\`.\n\n` + `If it names a project, pass the ${label} after it: / <${label}>`, "positional", ); @@ -212,16 +213,19 @@ const ISSUE_KIND: IdKind & { /** The kinds of ID commands take. Commands use these instead of their own. */ export const ID_KINDS = { + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. 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", + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. "sentry replay list", (url) => (url.org ? url.replayId : undefined), "https://sentry.io/organizations/{org}/explore/replays/{replayId}/", ), + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. log: hexKind("Log ID", "sentry log list", () => undefined), span: searchable( { @@ -231,6 +235,7 @@ export const ID_KINDS = { validate: validateSpanId, fromUrl: () => undefined, }, + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. "sentry span list", ), // A numeric ID, or a title: `Latency/p95`, `Top 10`, or unquoted words @@ -267,7 +272,7 @@ export const ID_KINDS = { validate: (value) => { if (!isAllDigits(value)) { throw new ValidationError( - `Invalid integration ID '${value}'. Use the numeric ID from 'sentry integration list'.`, + `Invalid integration ID '${value}'. Use the numeric ID from '${asInvocationCommand("sentry integration list")}'.`, "integration-id", ); } diff --git a/packages/cli/src/lib/issue-arguments.ts b/packages/cli/src/lib/issue-arguments.ts index 3fb9a2970..440b9deb9 100644 --- a/packages/cli/src/lib/issue-arguments.ts +++ b/packages/cli/src/lib/issue-arguments.ts @@ -10,6 +10,7 @@ * names: each word is an issue. */ +import { asInvocationCommand } from "./app-identity.js"; import { firstNonBlankLine, splitNewlineArg } from "./arg-parsing.js"; import { ValidationError } from "./errors.js"; import { ID_KINDS, parseIdArguments } from "./id-arguments.js"; @@ -61,7 +62,7 @@ export function parseIssueArgument(arg: string): IssueArgument { // One token: an identifier (validated below, for its own messages) or a // name const { ids, name } = parseIdArguments([input], ID_KINDS.issue, { - usageHint: "sentry issue ", + usageHint: asInvocationCommand("sentry issue "), validate: false, }); if (name !== undefined) { diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 86f117e26..e5278af71 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -249,6 +249,7 @@ export function initMcpTelemetry(config: McpServerConfig): void { const scopeContext = { "app.server.version": CLI_VERSION, // Tells `@sentry/mcp-server` installs apart from `sentry mcp`. + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- a telemetry value, not a hint. "app.server.distribution": name === "sentry" ? "sentry mcp" : packageName, "app.transport": "stdio", "app.upstream.host": config.sentryHost, diff --git a/packages/cli/src/lib/metrics-transform.ts b/packages/cli/src/lib/metrics-transform.ts index aea79068c..5639539fb 100644 --- a/packages/cli/src/lib/metrics-transform.ts +++ b/packages/cli/src/lib/metrics-transform.ts @@ -7,6 +7,7 @@ */ import type { MetricMeta } from "./api/explore.js"; +import { asInvocationCommand } from "./app-identity.js"; import { ResolutionError } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; @@ -50,7 +51,9 @@ export function resolveMetricField( throw new ResolutionError( `Aggregation '${agg}'`, `not recognized. Valid aggregations: ${[...VALID_AGGS].join(", ")}`, - `sentry explore my-org/ -m ${metricName} --agg sum --dataset metrics`, + asInvocationCommand( + `sentry explore my-org/ -m ${metricName} --agg sum --dataset metrics`, + ), ); } @@ -66,7 +69,9 @@ export function resolveMetricField( throw new ResolutionError( `Metric '${metricName}'`, "not found in this project", - `sentry explore my-org/ -m ${metricName} --dataset metrics --period 7d`, + asInvocationCommand( + `sentry explore my-org/ -m ${metricName} --dataset metrics --period 7d`, + ), suggestions.length > 0 ? [`Similar metrics: ${suggestions.join(", ")}`] : ["Use a wider --period to search for older metrics"], diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index e77b2ad8e..f2bc1e973 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -43,6 +43,7 @@ import { listOrganizations, type PaginatedResponse, } from "./api-client.js"; +import { asInvocationCommand } from "./app-identity.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, @@ -1055,7 +1056,7 @@ export async function handleProjectSearch( "is an organization, not a project", `${config.commandPrefix} ${projectSlug}/`, [ - `List projects: sentry project list ${projectSlug}/`, + `List projects: ${asInvocationCommand(`sentry project list ${projectSlug}/`)}`, `Specify a project: ${config.commandPrefix} ${projectSlug}/`, ], ); diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index d76a0e74c..70c0fa537 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -33,6 +33,7 @@ import { type ProjectWithOrg, resolveOrgDisplayName, } from "./api-client.js"; +import { asInvocationCommand } from "./app-identity.js"; import { looksLikeIssueShortId, type ParsedOrgProject, @@ -186,7 +187,7 @@ export async function resolveTargetSlugs( throw new ResolutionError( "Organization", "could not be resolved to a slug", - "sentry org list", + asInvocationCommand("sentry org list"), ); } cacheResolvedProject(info, org, project); @@ -1030,7 +1031,7 @@ export function projectSearchNotFoundSuggestions( if (resolution.scopedOrg) { return [ `No project with this name found in organization '${resolution.scopedOrg}'`, - `Check the organization slug or try: sentry project list ${resolution.scopedOrg}/`, + `Check the organization slug or try: ${asInvocationCommand(`sentry project list ${resolution.scopedOrg}/`)}`, ]; } return ["No project with this slug found in any accessible organization"]; @@ -1112,7 +1113,7 @@ export async function fetchProjectId( throw new ResolutionError( `Project '${project}'`, `not found in organization '${org}'`, - `sentry project list ${org}/`, + asInvocationCommand(`sentry project list ${org}/`), buildProjectNotFoundSuggestions(org, project, similar), ); } @@ -1220,7 +1221,7 @@ function combineProjectNotFoundErrors( return new ResolutionError( `Projects ${misses.map(({ slug }) => `'${slug}'`).join(", ")}`, `not found in organization '${org}'`, - `sentry project list ${org}/`, + asInvocationCommand(`sentry project list ${org}/`), [...specific, ...shared], ); } @@ -1281,7 +1282,7 @@ export async function resolveProjectIdsInOrg( throw new ResolutionError( `Project '${slug}'`, `has no numeric ID in organization '${org}'`, - `sentry project list ${org}/`, + asInvocationCommand(`sentry project list ${org}/`), ); } return id; @@ -1916,7 +1917,7 @@ async function promptForOrgProject(): Promise { throw new ResolutionError( `Organization '${orgSlug}'`, "has no accessible projects", - `sentry project list ${orgSlug}/`, + asInvocationCommand(`sentry project list ${orgSlug}/`), ); } @@ -1938,7 +1939,7 @@ async function promptForOrgProject(): Promise { setDefaultOrganization(orgSlug); setDefaultProject(projectSlug); log.info( - `Saved ${orgSlug}/${projectSlug} as your default. Change it with: sentry cli defaults org `, + `Saved ${orgSlug}/${projectSlug} as your default. Change it with: ${asInvocationCommand("sentry cli defaults org ")}`, ); } catch (error) { log.debug("Failed to persist selected org/project as default", error); @@ -2049,14 +2050,16 @@ async function buildAccountContextError( ); } alternatives.push( - `List projects example: sentry project list ${shellQuote(`${exampleOrg}/`)}`, + `List projects example: ${asInvocationCommand(`sentry project list ${shellQuote(`${exampleOrg}/`)}`)}`, ); const notes = candidates.flatMap((candidate) => candidate.note ? [candidate.note] : [], ); if (shownOrgs.length < orgs.length) { notes.push(`Showing ${shownOrgs.length} of ${orgs.length} organizations.`); - alternatives.push("List all organizations: sentry org list"); + alternatives.push( + `List all organizations: ${asInvocationCommand("sentry org list")}`, + ); } return new ContextError( "Organization and project", @@ -2263,7 +2266,7 @@ export async function resolveProjectBoundSlug( "is an organization, not a project", usageHint.replace("/", `${projectSlug}/`), [ - `List projects: sentry project list ${projectSlug}/`, + `List projects: ${asInvocationCommand(`sentry project list ${projectSlug}/`)}`, `Specify a project: ${projectSlug}/`, ], ); @@ -2471,7 +2474,9 @@ export async function resolveProjectBoundTarget( `'${parsed.projectSlug}'`, "is an organization, not a project", `sentry ${commandName} ${parsed.projectSlug}/`, - [`List projects: sentry project list ${parsed.projectSlug}/`], + [ + `List projects: ${asInvocationCommand(`sentry project list ${parsed.projectSlug}/`)}`, + ], ); } @@ -2716,7 +2721,7 @@ export async function resolveProjectBoundTargets( throw new ResolutionError( `Organization '${org}'`, "has no accessible projects", - `sentry project list ${org}/`, + asInvocationCommand(`sentry project list ${org}/`), ["Check that you have access to projects in this organization"], ); } @@ -2740,8 +2745,10 @@ export async function resolveProjectBoundTargets( throw new ResolutionError( `'${displaySlug}'`, "looks like an issue short ID, not a project slug", - `sentry issue view ${displaySlug}`, - ["To list issues in a project: sentry issue list /"], + asInvocationCommand(`sentry issue view ${displaySlug}`), + [ + `To list issues in a project: ${asInvocationCommand("sentry issue list /")}`, + ], ); } @@ -2755,7 +2762,7 @@ export async function resolveProjectBoundTargets( "is an organization, not a project", `${prefix} ${parsed.projectSlug}/`, [ - `List projects: sentry project list ${parsed.projectSlug}/`, + `List projects: ${asInvocationCommand(`sentry project list ${parsed.projectSlug}/`)}`, `Specify a project: ${prefix} ${parsed.projectSlug}/`, ], ); @@ -2866,7 +2873,7 @@ export async function resolveOrgOptionalTarget( if (!resolved) { throw new ContextError("Organization", usageHint, [ "SENTRY_ORG environment variable", - "sentry cli defaults", + asInvocationCommand("sentry cli defaults"), ]); } return withTelemetryContext({ org: resolved.org }); diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 6805b093a..7aa0104ce 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -22,6 +22,7 @@ import { type RetryPolicy, type TransportCache, } from "@sentry/api/client"; +import { asInvocationCommand } from "./app-identity.js"; import { formatAuthHeader } from "./auth-header.js"; import { seedCompletions } from "./api/completion-seed.js"; import { maybeWarnEnvTokenIgnored } from "./auth-hint.js"; @@ -249,8 +250,8 @@ function buildTimeoutHint(idempotent: boolean): string { ? "Try again." : "The change may still have been applied; check before trying again."; const check = isSentrySaasUrl(baseUrl) - ? "Check Sentry's status with: sentry status" - : `Check the server's health with: sentry status --url ${baseUrl}`; + ? `Check Sentry's status with: ${asInvocationCommand("sentry status")}` + : `Check the server's health with: ${asInvocationCommand(`sentry status --url ${baseUrl}`)}`; return `The Sentry API at ${baseUrl} did not respond in time. ${retry}\n ${check}`; } diff --git a/packages/cli/src/lib/sentryclirc-import.ts b/packages/cli/src/lib/sentryclirc-import.ts index 1225ccf74..55259c846 100644 --- a/packages/cli/src/lib/sentryclirc-import.ts +++ b/packages/cli/src/lib/sentryclirc-import.ts @@ -31,6 +31,7 @@ import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; +import { asInvocationCommand } from "./app-identity.js"; import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { getActiveAccountId, @@ -535,7 +536,7 @@ async function validateAndFetchUser( // Transient network error — keep the token, warn the user log.debug("Token validation failed with transient error", error); result.warnings.push( - "Could not validate token (network error). Token was stored — run 'sentry auth status' to verify.", + `Could not validate token (network error). Token was stored — run '${asInvocationCommand("sentry auth status")}' to verify.`, ); return true; } diff --git a/packages/cli/src/lib/setup-code.ts b/packages/cli/src/lib/setup-code.ts index 0170e7161..4175874fa 100644 --- a/packages/cli/src/lib/setup-code.ts +++ b/packages/cli/src/lib/setup-code.ts @@ -3,6 +3,7 @@ import { looseObject, safeParse, string } from "valibot"; import type { BootstrapResult } from "../output-schemas/auth.js"; import { pointDefaultUrlAtAccount } from "./account-selection.js"; +import { asInvocationCommand } from "./app-identity.js"; import { normalizeAuthToken } from "./auth-header.js"; import { getAccountIdToReplace, @@ -98,7 +99,7 @@ export async function bootstrapAuthentication( !options.force ) { throw new ValidationError( - "A stored login already exists. To replace it, run:\nSENTRY_SETUP_CODE='' sentry auth bootstrap --force", + `A stored login already exists. To replace it, run:\nSENTRY_SETUP_CODE='' ${asInvocationCommand("sentry auth bootstrap --force")}`, "force", ); } diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index abaffff06..1c210dc06 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -10,6 +10,7 @@ * Used by: span list, span view, trace view, trace logs, log list. */ +import { asInvocationCommand } from "./app-identity.js"; import { type ParsedOrgProject, parseOrgProjectArg, @@ -301,7 +302,9 @@ async function recoverTraceId( const result = await recoverHexId(rawTraceId, "trace", ctx); return handleRecoveryResult(result, failure, { entityType: "trace", - canonicalCommand: `sentry trace view ${ctx.org || ""}/${ctx.project ?? ""}/`, + canonicalCommand: asInvocationCommand( + `sentry trace view ${ctx.org || ""}/${ctx.project ?? ""}/`, + ), logTag: "trace-target", }); } @@ -421,7 +424,7 @@ export async function resolveTraceOrgProject( case "org-scoped": throw new ContextError("Specific project", usageHint, [ `Use: ${usageHint.replace(USAGE_TARGET_RE, `${parsed.org}//${parsed.traceId}`)}`, - `List projects: sentry project list ${parsed.org}/`, + `List projects: ${asInvocationCommand(`sentry project list ${parsed.org}/`)}`, ]); case "auto-detect": { diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 750b3f246..2507997a1 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -21,6 +21,7 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, sep } from "node:path"; import { setTimeout } from "node:timers/promises"; import { prerelease as semverPrerelease, valid as semverValid } from "semver"; +import { asInvocationCommand } from "./app-identity.js"; import { acquireLock, cleanupOldBinary, @@ -1180,7 +1181,7 @@ async function waitForBinaryVisible(path: string): Promise { throw new UpgradeError( "execution_failed", `Downloaded binary is missing or empty at ${path}. ` + - "This is usually transient — rerun `sentry cli upgrade` to retry.", + `This is usually transient — rerun \`${asInvocationCommand("sentry cli upgrade")}\` to retry.`, ); } @@ -1247,7 +1248,7 @@ export async function downloadBinaryToTemp( "offline_cache_miss", offline === "explicit" ? `Cannot upgrade to ${version} in offline mode — no pre-downloaded update is available. ` + - "Run `sentry cli upgrade` without `--offline` to download the update directly." + `Run \`${asInvocationCommand("sentry cli upgrade")}\` without \`--offline\` to download the update directly.` : `Cannot upgrade to ${version} — the network is unavailable and no pre-downloaded update was found. ` + "Check your internet connection and try again.", ); diff --git a/packages/cli/src/lib/version-check.ts b/packages/cli/src/lib/version-check.ts index a8f6088de..ebd6c1b9b 100644 --- a/packages/cli/src/lib/version-check.ts +++ b/packages/cli/src/lib/version-check.ts @@ -10,6 +10,7 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node"; import { compare as semverCompare } from "semver"; +import { asInvocationCommand } from "./app-identity.js"; import type { UpgradeSource } from "./binary.js"; import { CLI_VERSION } from "./constants.js"; import { getReleaseChannel } from "./db/release-channel.js"; @@ -427,14 +428,14 @@ function formatStandardUpdateNotification(latestVersion: string): string { const label = channel === "nightly" ? "New nightly available:" : "Update available:"; - return `\n${muted(label)} ${cyan(CLI_VERSION)} -> ${cyan(latestVersion)} Run ${cyan('"sentry cli upgrade"')} to update.\n`; + return `\n${muted(label)} ${cyan(CLI_VERSION)} -> ${cyan(latestVersion)} Run ${cyan(`"${asInvocationCommand("sentry cli upgrade")}"`)} to update.\n`; } function formatContextualUpdateNotification(latestVersion: string): string { return ( `\n${muted("A new version of sentry-cli is available")} (${cyan(latestVersion)})${muted(".")} ` + `${muted("Upgrading may resolve this — we fix a lot of bugs in every release.")} ` + - `${muted("Run")} ${cyan('"sentry cli upgrade"')} ${muted("to update.")}\n` + `${muted("Run")} ${cyan(`"${asInvocationCommand("sentry cli upgrade")}"`)} ${muted("to update.")}\n` ); } diff --git a/packages/cli/src/lib/wrangler.ts b/packages/cli/src/lib/wrangler.ts index 959802df9..449b2ce6a 100644 --- a/packages/cli/src/lib/wrangler.ts +++ b/packages/cli/src/lib/wrangler.ts @@ -9,6 +9,7 @@ import { access, readFile } from "node:fs/promises"; import { basename, join } from "node:path"; +import { asInvocationCommand } from "./app-identity.js"; import { logger } from "./logger.js"; /** Wrangler project configuration filenames, in resolution order. */ @@ -420,7 +421,7 @@ function injectIntoPackageScript( } if (script.slice(packageWrangler.end).trim()) { logger.warn( - `Could not inject SENTRY_SPOTLIGHT into compound package script "${invocation.name}"; run sentry local run without an explicit command to use auto-detection`, + `Could not inject SENTRY_SPOTLIGHT into compound package script "${invocation.name}"; run ${asInvocationCommand("sentry local run")} without an explicit command to use auto-detection`, ); return unchanged; } diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 5b31400e5..3d6ca31ef 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -545,3 +545,58 @@ tester.run( ], }, ); + +const authCommand = "/repo/packages/cli/src/commands/auth/example.ts"; + +tester.run( + "no-hardcoded-sentry-command", + plugin.rules["no-hardcoded-sentry-command"], + { + valid: [ + { + filename: source, + code: "function hint() { return `Run '${asInvocationCommand(\"sentry auth login\")}' first.`; }", + }, + { + filename: source, + code: "function hint(org) { return asInvocationCommand(`sentry project list ${org}/`); }", + }, + { + filename: authCommand, + code: "buildCommand({ docs: { fullDescription: 'Run `sentry auth status` to check.', examples: [{ command: 'sentry auth login' }] } });", + }, + { + filename: source, + code: "const message = 'Sentry CLI could not reach sentry.io';", + }, + // Commands that @sentry/mcp-server does not ship only run as `sentry`. + { + filename: commands, + code: "function hint() { return 'Run sentry issue list'; }", + }, + ], + invalid: [ + { + filename: source, + code: "function hint() { return \"Run 'sentry auth login' first.\"; }", + errors: 1, + }, + { + filename: authCommand, + code: "function hint(org) { return `Try: sentry project list ${org}/`; }", + errors: 1, + }, + { + filename: "/repo/packages/cli/src/commands/cli/fix.ts", + code: "log.warn('Run sentry cli fix');", + errors: 1, + }, + // At module load the running program is not known yet. + { + filename: source, + code: "const HINT = asInvocationCommand('sentry auth login');", + errors: 1, + }, + ], + }, +); From 96ebd2a9c93bddaf1168ff1403ee43c8b18d50af Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:07:24 +0000 Subject: [PATCH 2/4] feat(cli): Apply the hardcoded sentry command lint to all CLI code The same code runs as sentry, npx sentry, pnpx sentry and sentry-mcp, so the rule now covers all of packages/cli/src, not only the code the mcp-server bundle ships. Hints inside functions wrap their command in asInvocationCommand(). Module-level usage lines stay plain data, and ContextError, ResolutionError, buildValidationMessage and org-list wrap them where they are printed. Refs #2054 Refs #2050 --- packages/cli/AGENTS.md | 5 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 35 ++++++---- .../src/commands/agent-conversation/list.ts | 9 ++- .../src/commands/agent-conversation/view.ts | 3 +- .../src/commands/alert/issues/rule-resolve.ts | 11 ++-- .../cli/src/commands/alert/issues/view.ts | 5 +- .../cli/src/commands/alert/metrics/create.ts | 3 +- .../commands/alert/metrics/rule-resolve.ts | 11 ++-- packages/cli/src/commands/attribute/list.ts | 9 ++- packages/cli/src/commands/cli/import.ts | 5 +- packages/cli/src/commands/cli/setup.ts | 12 +++- packages/cli/src/commands/cli/uninstall.ts | 9 ++- packages/cli/src/commands/cli/upgrade.ts | 16 +++-- .../cli/src/commands/code-mappings/upload.ts | 5 +- packages/cli/src/commands/dashboard/create.ts | 5 +- packages/cli/src/commands/dashboard/list.ts | 9 ++- .../cli/src/commands/dashboard/resolve.ts | 17 +++-- .../cli/src/commands/dashboard/revisions.ts | 11 +++- .../cli/src/commands/dashboard/widget/add.ts | 5 +- .../cli/src/commands/dashboard/widget/edit.ts | 3 +- .../commands/debug-files/bundle-sources.ts | 5 +- .../cli/src/commands/debug-files/check.ts | 3 +- .../src/commands/debug-files/print-sources.ts | 9 ++- .../cli/src/commands/debug-files/upload.ts | 7 +- packages/cli/src/commands/dsn/list.ts | 5 +- packages/cli/src/commands/event/list.ts | 13 +++- packages/cli/src/commands/event/send.ts | 3 +- packages/cli/src/commands/event/view.ts | 38 +++++++---- packages/cli/src/commands/explore.ts | 15 +++-- packages/cli/src/commands/feedback/list.ts | 7 +- .../cli/src/commands/feedback/positional.ts | 3 +- packages/cli/src/commands/feedback/utils.ts | 21 ++++-- packages/cli/src/commands/info.ts | 5 +- .../cli/src/commands/integration/delete.ts | 3 +- packages/cli/src/commands/issue/events.ts | 13 +++- packages/cli/src/commands/issue/explain.ts | 5 +- packages/cli/src/commands/issue/list.ts | 28 +++++--- packages/cli/src/commands/issue/merge.ts | 5 +- packages/cli/src/commands/issue/plan.ts | 3 +- packages/cli/src/commands/issue/positional.ts | 7 +- .../src/commands/issue/resolve-commit-spec.ts | 3 +- packages/cli/src/commands/issue/resolve.ts | 5 +- packages/cli/src/commands/issue/utils.ts | 16 +++-- packages/cli/src/commands/issue/view.ts | 3 +- packages/cli/src/commands/local/run.ts | 30 +++++---- packages/cli/src/commands/local/server.ts | 5 +- packages/cli/src/commands/local/ui.ts | 3 +- packages/cli/src/commands/log/list.ts | 7 +- packages/cli/src/commands/log/view.ts | 13 ++-- packages/cli/src/commands/monitor/run.ts | 5 +- packages/cli/src/commands/org/list.ts | 17 +++-- packages/cli/src/commands/proguard/uuid.ts | 3 +- packages/cli/src/commands/project/create.ts | 21 +++--- packages/cli/src/commands/project/delete.ts | 3 +- packages/cli/src/commands/project/list.ts | 33 +++++----- packages/cli/src/commands/project/view.ts | 15 +++-- packages/cli/src/commands/release/create.ts | 3 +- packages/cli/src/commands/release/list.ts | 5 +- packages/cli/src/commands/release/parse.ts | 3 +- .../cli/src/commands/release/set-commits.ts | 65 ++++++++++++++----- packages/cli/src/commands/replay/download.ts | 7 +- packages/cli/src/commands/replay/list.ts | 11 +++- packages/cli/src/commands/replay/view.ts | 15 +++-- packages/cli/src/commands/schema.ts | 48 ++++++++++---- packages/cli/src/commands/send-envelope.ts | 5 +- packages/cli/src/commands/sourcemap/inject.ts | 3 +- .../cli/src/commands/sourcemap/resolve.ts | 3 +- packages/cli/src/commands/span/list.ts | 23 +++++-- packages/cli/src/commands/span/view.ts | 13 ++-- packages/cli/src/commands/status/show.ts | 3 +- packages/cli/src/commands/token/delete.ts | 9 +-- packages/cli/src/commands/token/list.ts | 3 +- packages/cli/src/commands/token/shared.ts | 3 +- packages/cli/src/commands/trace/list.ts | 13 +++- packages/cli/src/commands/trace/logs.ts | 7 +- packages/cli/src/commands/trace/view.ts | 24 ++++--- packages/cli/src/commands/trial/list.ts | 7 +- packages/cli/src/commands/trial/start.ts | 5 +- packages/cli/src/commands/wasm-split.ts | 3 +- packages/cli/src/lib/command-suggestions.ts | 3 - packages/cli/src/lib/errors.ts | 17 +++-- packages/cli/src/lib/hex-id-recovery.ts | 3 - packages/cli/src/lib/id-arguments.ts | 10 +-- packages/cli/src/lib/org-list.ts | 28 ++++---- packages/cli/src/lib/resolve-team.ts | 14 ++-- packages/cli/src/lib/target-positional.ts | 5 +- packages/cli/src/lib/trace-target.ts | 2 +- packages/cli/test/lib/app-identity.test.ts | 24 ++++++- scripts/cli-lint-rules.test.mjs | 24 ++++--- 89 files changed, 643 insertions(+), 336 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index f98021c1e..cbf7e83e0 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -156,10 +156,6 @@ Top-level layout: bundle (built by `packages/mcp-server/script/bundle.ts`). It must not import anything that only the CLI needs; the bundle script fails on commands, markdown rendering, agent skills, completions, terminal images, and the SQLite WASM fallback. - Hints in code the bundle can run name the running program: build them with - `asInvocationCommand("sentry …")` from `src/lib/app-identity.ts`, inside the - function that builds the hint. Lint (`sentry-cli/no-hardcoded-sentry-command`) - rejects a bare `sentry …` command outside command help text. - **`src/commands/`** — one directory per command group (`agent-conversation`, `alert`, `auth`, `build`, `cli`, `code-mappings`, `dart-symbol-map`, `dashboard`, `debug-files`, `docs`, `dsn`, `event`, `feedback`, `integration`, `issue`, `local`, @@ -830,6 +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 build a hint's command with `asInvocationCommand("sentry …")` from `src/lib/app-identity.ts`, inside the function that builds the hint. 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`) enforces this. - **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 a882bcde7..0480094a5 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -361,13 +361,8 @@ function untaggedParser(value) { /** A `sentry ` hint: the CLI binary name, then a subcommand. */ const SENTRY_COMMAND_RE = /(?:^|[\s`'"(])sentry [a-z]/; -/** - * Files that `@sentry/mcp-server` can run: all of `src/` but the commands it - * does not ship and `app.ts`. Keep in step with `FORBIDDEN_INPUTS` in - * packages/mcp-server/script/bundle.ts. - */ -const CLI_ONLY_SOURCE_RE = - /\/src\/(?:app\.ts$|commands\/(?!auth\/|cli\/(?:defaults|fix)\.ts$|help\.ts$|mcp\.ts$))/; +/** A whole `sentry `, such as a usage line or a command prefix. */ +const WHOLE_SENTRY_COMMAND_RE = /^sentry [a-z]/; /** Calls that turn a `sentry …` command into the running program's command. */ const INVOCATION_HELPERS = new Set([ @@ -375,8 +370,13 @@ const INVOCATION_HELPERS = new Set([ "asRunningAppCommand", ]); -/** Properties whose text is CLI help, rendered for the `sentry` binary. */ -const HELP_TEXT_KEYS = new Set(["docs", "fullDescription"]); +/** Properties whose text is command help, written for the `sentry` binary. */ +const HELP_TEXT_KEYS = new Set([ + "docs", + "fullDescription", + "brief", + "examples", +]); /** Whether `node` sits inside a function body. */ function hasFunctionAncestor(node) { @@ -400,6 +400,11 @@ function isInvocationSafe(node) { HELP_TEXT_KEYS.has(current.key?.name ?? current.key?.value) ) return true; + if ( + current.type === "VariableDeclarator" && + HELP_TEXT_KEYS.has(current.id?.name) + ) + return true; } return false; } @@ -849,12 +854,16 @@ const rules = { }, ), "no-hardcoded-sentry-command": rule( - "Hints in code that @sentry/mcp-server runs must name the running program: wrap the `sentry …` command in asInvocationCommand() from lib/app-identity.js.", + "Hints must name the program that is running (`npx sentry`, `sentry-mcp`, ...): wrap the `sentry …` command in asInvocationCommand() from lib/app-identity.js.", (_context, file, report) => { - if (!file.includes("/src/") || CLI_ONLY_SOURCE_RE.test(file)) return {}; + if (!file.includes("/src/")) return {}; const check = (node, text) => { - if (SENTRY_COMMAND_RE.test(text) && !isInvocationSafe(node)) - report(node); + if (!SENTRY_COMMAND_RE.test(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 (WHOLE_SENTRY_COMMAND_RE.test(text) && !hasFunctionAncestor(node)) + return; + report(node); }; return { // The running program is known only once an entry point has started, diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index a77ac7aca..86441bcad 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { getProject, listConversations } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { parseText, textParser } from "../../lib/completion-target.js"; import { @@ -302,8 +303,12 @@ export const listCommand = buildListCommand("agent-conversation", { hint: paginationHint({ hasMore, hasPrev: !!hasPrev, - nextHint: `sentry agent-conversation list ${scope} -c next${flagSuffix}`, - prevHint: `sentry agent-conversation list ${scope} -c prev${flagSuffix}`, + nextHint: asInvocationCommand( + `sentry agent-conversation list ${scope} -c next${flagSuffix}`, + ), + prevHint: asInvocationCommand( + `sentry agent-conversation list ${scope} -c prev${flagSuffix}`, + ), }), }; }, diff --git a/packages/cli/src/commands/agent-conversation/view.ts b/packages/cli/src/commands/agent-conversation/view.ts index 1d0317091..2f05dbf26 100644 --- a/packages/cli/src/commands/agent-conversation/view.ts +++ b/packages/cli/src/commands/agent-conversation/view.ts @@ -6,6 +6,7 @@ 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 { @@ -91,7 +92,7 @@ export function parseConversationArguments( } if (ids.length > 1) { throw new ValidationError( - `View one conversation at a time: ${USAGE_HINT}`, + `View one conversation at a time: ${asInvocationCommand(USAGE_HINT)}`, "conversation-id", ); } diff --git a/packages/cli/src/commands/alert/issues/rule-resolve.ts b/packages/cli/src/commands/alert/issues/rule-resolve.ts index e7da9789b..184e4e9a9 100644 --- a/packages/cli/src/commands/alert/issues/rule-resolve.ts +++ b/packages/cli/src/commands/alert/issues/rule-resolve.ts @@ -10,6 +10,7 @@ import { isNotFoundApiError, listIssueAlertsPaginated, } from "../../../lib/api-client.js"; +import { asInvocationCommand } 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"; @@ -39,7 +40,7 @@ export function parseIssueRuleArg( const trimmed = arg.trim(); if (!trimmed) { throw new ValidationError( - `Rule id or name is required.\nUse: ${usageHint}`, + `Rule id or name is required.\nUse: ${asInvocationCommand(usageHint)}`, "rule", ); } @@ -51,7 +52,7 @@ export function parseIssueRuleArg( if (slashCount === 1) { throw new ValidationError( `Missing rule id or name after the project (got '${trimmed}').\n` + - `Use: ${usageHint}\n` + + `Use: ${asInvocationCommand(usageHint)}\n` + `Example: ${trimmed}/`, "rule", ); @@ -62,7 +63,7 @@ export function parseIssueRuleArg( const ref = trimmed.slice(lastSlash + 1).trim(); if (!ref) { throw new ValidationError( - `Invalid rule reference '${arg}'.\nUse: ${usageHint}`, + `Invalid rule reference '${arg}'.\nUse: ${asInvocationCommand(usageHint)}`, "rule", ); } @@ -133,7 +134,7 @@ export async function resolveIssueAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule ID '${ref}' matched multiple projects.\n` + - "Use an explicit target: sentry alert issues //", + `Use an explicit target: ${asInvocationCommand("sentry alert issues //")}`, ); } throw new ResolutionError( @@ -167,7 +168,7 @@ export async function resolveIssueAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule name '${ref}' matched multiple projects.\n` + - "Use an explicit target: sentry alert issues //", + `Use an explicit target: ${asInvocationCommand("sentry alert issues //")}`, ); } diff --git a/packages/cli/src/commands/alert/issues/view.ts b/packages/cli/src/commands/alert/issues/view.ts index eea783331..9dd8205db 100644 --- a/packages/cli/src/commands/alert/issues/view.ts +++ b/packages/cli/src/commands/alert/issues/view.ts @@ -1,4 +1,5 @@ import type { SentryContext } from "../../../context.js"; +import { asInvocationCommand } from "../../../lib/app-identity.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; import { ContextError } from "../../../lib/errors.js"; @@ -106,7 +107,9 @@ export const viewCommand = buildCommand({ const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: "sentry alert issues view //", + usageHint: asInvocationCommand( + "sentry alert issues view //", + ), }); if (targets.length === 0) { throw new ContextError("Organization and project", USAGE_HINT); diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index 15aa155e9..df7bfeebc 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../../context.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; +import { asInvocationCommand } from "../../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { @@ -79,7 +80,7 @@ function resolveMetricCreateOrg( parsed, cwd, "alert metrics create", - "sentry alert metrics create /", + asInvocationCommand("sentry alert metrics create /"), ); } diff --git a/packages/cli/src/commands/alert/metrics/rule-resolve.ts b/packages/cli/src/commands/alert/metrics/rule-resolve.ts index 60714ff62..c4be06360 100644 --- a/packages/cli/src/commands/alert/metrics/rule-resolve.ts +++ b/packages/cli/src/commands/alert/metrics/rule-resolve.ts @@ -10,6 +10,7 @@ import { listMetricAlertsPaginated, type MetricAlertRule, } from "../../../lib/api-client.js"; +import { asInvocationCommand } 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"; @@ -38,7 +39,7 @@ export function parseMetricRuleArg( const trimmed = arg.trim(); if (!trimmed) { throw new ValidationError( - `Rule id or name is required.\nUse: ${usageHint}`, + `Rule id or name is required.\nUse: ${asInvocationCommand(usageHint)}`, "rule", ); } @@ -50,7 +51,7 @@ export function parseMetricRuleArg( const slashCount = countChar(trimmed, "/"); if (slashCount > 1) { throw new ValidationError( - `Metric alerts are org-scoped — use '/', not '//'.\nUse: ${usageHint}`, + `Metric alerts are org-scoped — use '/', not '//'.\nUse: ${asInvocationCommand(usageHint)}`, "rule", ); } @@ -60,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: ${usageHint}`, + `Invalid rule reference '${arg}' (missing id or name after org).\nUse: ${asInvocationCommand(usageHint)}`, "rule", ); } @@ -129,7 +130,7 @@ export async function resolveMetricAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule ID '${ref}' matched multiple organizations.\n` + - "Use an explicit target: sentry alert metrics /", + `Use an explicit target: ${asInvocationCommand("sentry alert metrics /")}`, ); } throw new ResolutionError( @@ -163,7 +164,7 @@ export async function resolveMetricAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule name '${ref}' matched multiple organizations.\n` + - "Use an explicit target: sentry alert metrics /", + `Use an explicit target: ${asInvocationCommand("sentry alert metrics /")}`, ); } diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index f1df2164a..e099595e8 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -15,6 +15,7 @@ import type { TraceItemAttributeDataset, TraceItemAttributeDefinition, } from "../../lib/api/traces.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { ATTRIBUTE_TYPES, parseAttributeFilter, @@ -130,7 +131,7 @@ export function buildUsageTip( const { key } = example; if (dataset === "tracemetrics") { const target = project ? `${org}/${project}` : `${org}/`; - return `Tip: Group metrics with: sentry explore ${target} --dataset metrics -m -F ${shellQuote(key)}`; + return `Tip: Group metrics with: ${asInvocationCommand(`sentry explore ${target} --dataset metrics -m -F ${shellQuote(key)}`)}`; } const target = project ? `${org}/${project}` : `${org}/`; const command = dataset === "logs" ? "log list" : "span list"; @@ -174,7 +175,11 @@ function pageHint( filter: string | undefined, ): string { const filterArg = filter ? ` ${shellQuote(filter)}` : ""; - const parts = [`sentry attribute list ${scope}${filterArg} -c ${direction}`]; + const parts = [ + asInvocationCommand( + `sentry attribute list ${scope}${filterArg} -c ${direction}`, + ), + ]; if (flags.dataset !== DEFAULT_DATASET) { parts.push(`--dataset ${datasetDisplayName(flags.dataset)}`); } diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 50ee7a35c..995d877b1 100644 --- a/packages/cli/src/commands/cli/import.ts +++ b/packages/cli/src/commands/cli/import.ts @@ -13,6 +13,7 @@ import { isatty } from "node:tty"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { textParser } from "../../lib/completion-target.js"; import { getDefaultUrl } from "../../lib/db/defaults.js"; @@ -222,7 +223,7 @@ function enforceTrustGate(plan: ImportPlan): void { throw new HostScopeError( `Token (from ${tokenSource}) and URL (from ${urlSource}) come from different files.\n` + "To confirm you trust this URL, pass it explicitly:\n" + - ` sentry cli import --url ${plan.effective.url ?? ""}`, + ` ${asInvocationCommand(`sentry cli import --url ${plan.effective.url ?? ""}`)}`, ); } @@ -389,7 +390,7 @@ export const importCommand = buildCommand({ if (result.tokenValid === false) { return { - hint: "Token validation failed. Check your token and try: sentry auth login", + hint: `Token validation failed. Check your token and try: ${asInvocationCommand("sentry auth login")}`, }; } if (result.imported) { diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index 99904e9a7..99f815ff7 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -12,6 +12,7 @@ import { dirname, join } from "node:path"; import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; import { installAgentSkills } from "../../lib/agent-skills.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { assertAutoLoginHostTrusted } from "../../lib/auto-auth.js"; import { determineInstallDir, @@ -479,11 +480,14 @@ function printWelcomeMessage( binaryPath: string, ): void { emit(""); + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. emit(`Installed sentry v${version} to ${binaryPath}`); emit(""); emit("Get started:"); - emit(" sentry auth Authenticate with Sentry"); - emit(" sentry mcp Run the Sentry MCP server for your AI agent"); + emit(` ${asInvocationCommand("sentry auth")} Authenticate with Sentry`); + emit( + ` ${asInvocationCommand("sentry mcp")} Run the Sentry MCP server for your AI agent`, + ); emit(" sentry --help See all available commands"); emit(""); emit("https://cli.sentry.dev"); @@ -864,7 +868,9 @@ export const setupCommand = buildCommand({ await warmCachesAfterLogin(); } } else { - log.info("Run 'sentry auth login' to authenticate later."); + log.info( + `Run '${asInvocationCommand("sentry auth login")}' to authenticate later.`, + ); } }, warn, diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index 6fd354665..7d97cc435 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -28,6 +28,7 @@ import { homedir } from "node:os"; import { basename, dirname } from "node:path"; import type { SentryContext } from "../../context.js"; import { getSkillInstallPath } from "../../lib/agent-skills.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { isHomebrewInstall } from "../../lib/binary.js"; import { getCompletionPath } from "../../lib/completions.js"; import { @@ -257,12 +258,14 @@ function gatherArtifacts(home: string): UninstallArtifact[] { const sentryConfigs = findSentryConfigFiles(home); for (const configFile of sentryConfigs) { artifacts.push({ + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. label: `sentry entries in ${configFile}`, path: configFile, exists: true, remove: async () => { const removed = await removeSentryLinesFromConfig(configFile); if (!removed) { + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. throw new Error(`Failed to remove sentry entries from ${configFile}`); } }, @@ -294,7 +297,9 @@ function gatherArtifacts(home: string): UninstallArtifact[] { // self-deletion and tell the user to remove it manually. if (process.platform === "win32") { artifacts.push({ - label: "sentry binary (manual removal needed on Windows)", + label: asInvocationCommand( + "sentry binary (manual removal needed on Windows)", + ), path: binaryPath, exists: true, remove: () => @@ -306,7 +311,7 @@ function gatherArtifacts(home: string): UninstallArtifact[] { }); } else { artifacts.push({ - label: "sentry binary", + label: asInvocationCommand("sentry binary"), path: binaryPath, exists: true, remove: async () => { diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 83048238f..dbb03c1af 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -19,6 +19,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { setTimeout } from "node:timers/promises"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { compareVersions, determineInstallDir, @@ -97,7 +98,7 @@ function prepareLocalDatabaseForDowngrade(): void { } catch (error) { log.warn( `Could not prepare the local database for an older CLI: ${stringifyUnknown(error)}. ` + - `If the older CLI fails, delete ${getDbPath()} and run \`sentry auth login\`.`, + `If the older CLI fails, delete ${getDbPath()} and run \`${asInvocationCommand("sentry auth login")}\`.`, ); } } @@ -132,7 +133,7 @@ async function backupLocalDatabase( } catch (error) { log.warn( `Could not back up the local database: ${stringifyUnknown(error)}. ` + - "`sentry cli upgrade undo` won't be able to return to this version.", + `\`${asInvocationCommand("sentry cli upgrade undo")}\` won't be able to return to this version.`, ); return; } @@ -356,7 +357,7 @@ async function resolveTargetWithFallback(opts: { throw new UpgradeError( "unsupported_operation", "Cannot switch channels in offline mode — the cached version belongs to the current channel. " + - "Run 'sentry cli upgrade' with network access to switch channels.", + `Run '${asInvocationCommand("sentry cli upgrade")}' with network access to switch channels.`, ); } // Read the cached version BEFORE persisting the channel — setReleaseChannel @@ -423,7 +424,7 @@ function validateMethod( throw new UpgradeError( "unsupported_operation", `Homebrew can't install an earlier version, so undo can't return to ${pinnedVersion} through it. ` + - "Run `sentry cli upgrade undo --method curl` to return to it as a standalone install; " + + `Run \`${asInvocationCommand("sentry cli upgrade undo --method curl")}\` to return to it as a standalone install; ` + "the Homebrew one may still come first in PATH.", ); } @@ -581,8 +582,8 @@ function buildCheckResult(opts: { if (CLI_VERSION !== target) { const cmd = versionArg && !CHANNEL_VERSIONS.has(versionArg) - ? `sentry cli upgrade ${target}` - : "sentry cli upgrade"; + ? asInvocationCommand(`sentry cli upgrade ${target}`) + : asInvocationCommand("sentry cli upgrade"); result.warnings = [`Run '${cmd}' to update.`]; } @@ -692,7 +693,7 @@ async function spawnWithRetry( throw new UpgradeError( "execution_failed", `Downloaded binary not found at ${binaryPath}. ` + - "The download may have been interrupted — rerun `sentry cli upgrade`.", + `The download may have been interrupted — rerun \`${asInvocationCommand("sentry cli upgrade")}\`.`, ); } if (!isEbusyError(error) || attempt === SPAWN_MAX_ATTEMPTS) { @@ -1039,6 +1040,7 @@ async function migrateToStandaloneForNightly(opts: { }; const warnings: string[] = []; warnings.push( + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. `Your ${method}-installed sentry may still appear earlier in PATH.`, ); const hint = uninstallHints[method]; diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index 292e783c7..eaf8238dc 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -22,6 +22,7 @@ import { CodeMappingSchema, uploadCodeMappings, } from "../../lib/api/code-mappings.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath, @@ -258,7 +259,9 @@ export const uploadCommand = buildCommand({ if (!repository) { throw new ContextError( "Repository name", - "sentry code-mappings upload --repo ", + asInvocationCommand( + "sentry code-mappings upload --repo ", + ), [ "Could not auto-detect repository from git remotes", "Provide --repo explicitly", diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index a8a96a244..45d4c79a6 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { createDashboard, getProject } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -124,7 +125,7 @@ async function resolveDashboardTarget( case "project-search": { const found = await resolveProjectBoundSlug( parsed.projectSlug, - "sentry dashboard create / ", + asInvocationCommand("sentry dashboard create <org>/<project> <title>"), undefined, parsed.originalSlug, ); @@ -141,7 +142,7 @@ async function resolveDashboardTarget( if (!resolved) { throw new ContextError( "Organization", - "sentry dashboard create <org>/ <title>", + asInvocationCommand("sentry dashboard create <org>/ <title>"), ); } return { orgSlug: resolved.org, projectIds: [] }; diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 71dd3f79f..b62d0ecf4 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -13,6 +13,7 @@ import { API_MAX_PER_PAGE, listDashboardsPaginated, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { openInBrowser } from "../../lib/browser.js"; import { advancePaginationState, @@ -352,8 +353,12 @@ function buildHint( const navRaw = paginationHint({ hasPrev: !!result.hasPrev, hasMore: !!result.hasMore, - prevHint: `sentry dashboard list ${orgSlug}/${filterArg} -c prev`, - nextHint: `sentry dashboard list ${orgSlug}/${filterArg} -c next`, + prevHint: asInvocationCommand( + `sentry dashboard list ${orgSlug}/${filterArg} -c prev`, + ), + nextHint: asInvocationCommand( + `sentry dashboard list ${orgSlug}/${filterArg} -c next`, + ), }); const nav = navRaw ? ` ${navRaw}` : ""; const url = buildDashboardsListUrl(orgSlug); diff --git a/packages/cli/src/commands/dashboard/resolve.ts b/packages/cli/src/commands/dashboard/resolve.ts index 0001b9ee9..aecf79cac 100644 --- a/packages/cli/src/commands/dashboard/resolve.ts +++ b/packages/cli/src/commands/dashboard/resolve.ts @@ -12,6 +12,7 @@ import { getOrganization, listDashboardsPaginated, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; import { DATASET_FIELDS, @@ -464,19 +465,21 @@ async function build404Error( throw new ResolutionError( `Organization ${org}`, "not found or has no dashboards", - "sentry dashboard list <org>/", + asInvocationCommand("sentry dashboard list <org>/"), [ - "Verify the organization slug with: sentry org list", + `Verify the organization slug with: ${asInvocationCommand("sentry org list")}`, "Check that you have access to the organization", ], ); } - const listHint = `sentry dashboard list ${ctx.orgSlug ?? "<org>"}/`; + const listHint = asInvocationCommand( + `sentry dashboard list ${ctx.orgSlug ?? "<org>"}/`, + ); if (ctx.dashboardId) { // Fetch available dashboards to suggest alternatives const alternatives = [ "The dashboard may have been deleted", - "Check the dashboard ID or title with: sentry dashboard list", + `Check the dashboard ID or title with: ${asInvocationCommand("sentry dashboard list")}`, ]; if (ctx.orgSlug) { try { @@ -504,7 +507,7 @@ async function build404Error( throw new ResolutionError( `Organization ${org}`, "not found", - "sentry org list", + asInvocationCommand("sentry org list"), ["Verify the organization slug and your access"], ); } @@ -520,14 +523,14 @@ function build403Error( throw new ResolutionError( `Dashboard ${ctx.dashboardId} in ${org}`, "access denied", - `sentry dashboard list ${ctx.orgSlug ?? "<org>"}/`, + asInvocationCommand(`sentry dashboard list ${ctx.orgSlug ?? "<org>"}/`), [message, "Check your organization membership and role"], ); } throw new ResolutionError( `Dashboards in ${org}`, "access denied", - "sentry org list", + asInvocationCommand("sentry org list"), [message, "Check your organization membership and role"], ); } diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index dd54647c9..14a6ff2bb 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -8,6 +8,7 @@ import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { paginate } from "../../lib/api/infrastructure.js"; import { listDashboardRevisionsPaginated } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { advancePaginationState, @@ -210,8 +211,12 @@ export const revisionsCommand = buildCommand({ const nav = paginationHint({ hasPrev: !!hasPrev, hasMore, - prevHint: `sentry dashboard revisions ${orgSlug}/ ${dashboardId} -c prev`, - nextHint: `sentry dashboard revisions ${orgSlug}/ ${dashboardId} -c next`, + prevHint: asInvocationCommand( + `sentry dashboard revisions ${orgSlug}/ ${dashboardId} -c prev`, + ), + nextHint: asInvocationCommand( + `sentry dashboard revisions ${orgSlug}/ ${dashboardId} -c next`, + ), }); const navStr = nav ? ` ${nav}` : ""; @@ -222,7 +227,7 @@ export const revisionsCommand = buildCommand({ return { hint: `Showing ${revisions.length} revision(s) for dashboard ${dashboardId}.${navStr}\n` + - `Restore: sentry dashboard restore ${orgSlug}/ ${dashboardId} --revision <revision-id>\n` + + `Restore: ${asInvocationCommand(`sentry dashboard restore ${orgSlug}/ ${dashboardId} --revision <revision-id>`)}\n` + `Dashboard: ${url}`, }; }, diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index dc7784ab9..0b8f00b37 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; +import { asInvocationCommand } from "../../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { aggregateShorthandParser, @@ -89,14 +90,14 @@ function parseAddPositionalArgs(args: readonly string[]): { throw new ValidationError( "Widget title is required as a positional argument.\n\n" + "Example:\n" + - ' sentry dashboard widget add <dashboard> "My Widget" --display line --query count', + ` ${asInvocationCommand('sentry dashboard widget add <dashboard> "My Widget" --display line --query count')}`, "title", ); } if (args.length > 3) { throw new ValidationError( `Too many positional arguments (got ${args.length}, expected at most 3).\n\n` + - "Usage: sentry dashboard widget add [<org/project>] <dashboard> <title>", + `Usage: ${asInvocationCommand("sentry dashboard widget add [<org/project>] <dashboard> <title>")}`, "positional", ); } diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index b185924ab..4501e7778 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; +import { asInvocationCommand } from "../../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { aggregateShorthandParser, @@ -359,7 +360,7 @@ export const editCommand = buildCommand({ throw new ValidationError( "Specify --index or --title to identify the widget to edit.\n\n" + "Example:\n" + - " sentry dashboard widget edit <dashboard> --title 'My Widget' --display bar", + ` ${asInvocationCommand("sentry dashboard widget edit <dashboard> --title 'My Widget' --display bar")}`, "index", ); } diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 7e0d32cf4..17d0fbc1e 100644 --- a/packages/cli/src/commands/debug-files/bundle-sources.ts +++ b/packages/cli/src/commands/debug-files/bundle-sources.ts @@ -18,6 +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 } 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"; @@ -160,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: ${USAGE_HINT}`, + hint: `No source files found on disk for '${path}'. This is normally run on the build machine. Try: ${asInvocationCommand(USAGE_HINT)}`, }; } @@ -182,7 +183,7 @@ export const bundleSourcesCommand = buildCommand({ }); return { - hint: `Created ${outputPath} with ${result.fileCount} source file(s). Upload with: sentry debug-files upload`, + hint: `Created ${outputPath} with ${result.fileCount} source file(s). Upload with: ${asInvocationCommand("sentry debug-files upload")}`, }; }, }); diff --git a/packages/cli/src/commands/debug-files/check.ts b/packages/cli/src/commands/debug-files/check.ts index cc1c7b19a..cd363dd4f 100644 --- a/packages/cli/src/commands/debug-files/check.ts +++ b/packages/cli/src/commands/debug-files/check.ts @@ -13,6 +13,7 @@ */ 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"; @@ -186,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: ${USAGE_HINT}`, + hint: `No usable debug information found. Try: ${asInvocationCommand(USAGE_HINT)}`, }; } }, diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index 118e58e17..fab009212 100644 --- a/packages/cli/src/commands/debug-files/print-sources.ts +++ b/packages/cli/src/commands/debug-files/print-sources.ts @@ -14,6 +14,7 @@ import { inlineCode } from "@sentry/toolkit-core/markdown"; import { existsSync } from "node:fs"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type DifSourcesInfo, @@ -192,7 +193,9 @@ export const printSourcesCommand = buildCommand({ yield new CommandOutput({ path, objects }); if (objects.length === 0) { - return { hint: `No objects found in '${path}'. Try: ${USAGE_HINT}` }; + return { + hint: `No objects found in '${path}'. Try: ${asInvocationCommand(USAGE_HINT)}`, + }; } const total = objects.reduce((sum, object) => sum + object.files.length, 0); if (total === 0) { @@ -205,11 +208,11 @@ export const printSourcesCommand = buildCommand({ }; } return { - hint: `No referenced sources found in '${path}'. Try: ${USAGE_HINT}`, + hint: `No referenced sources found in '${path}'. Try: ${asInvocationCommand(USAGE_HINT)}`, }; } return { - hint: `Bundle these into a source bundle with: sentry debug-files bundle-sources ${path}`, + hint: `Bundle these into a source bundle with: ${asInvocationCommand(`sentry debug-files bundle-sources ${path}`)}`, }; }, }); diff --git a/packages/cli/src/commands/debug-files/upload.ts b/packages/cli/src/commands/debug-files/upload.ts index e2e2dbcac..99fc2e6d2 100644 --- a/packages/cli/src/commands/debug-files/upload.ts +++ b/packages/cli/src/commands/debug-files/upload.ts @@ -36,6 +36,7 @@ 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, @@ -392,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: ${USAGE_HINT}`, + : `No debug information files found. Try: ${asInvocationCommand(USAGE_HINT)}`, }; } @@ -431,7 +432,9 @@ function* doNothingToUpload( }; } log.warn("No debug information files found."); - return { hint: `No debug information files found. Try: ${USAGE_HINT}` }; + return { + hint: `No debug information files found. Try: ${asInvocationCommand(USAGE_HINT)}`, + }; } /** diff --git a/packages/cli/src/commands/dsn/list.ts b/packages/cli/src/commands/dsn/list.ts index dc04182f7..051a09f61 100644 --- a/packages/cli/src/commands/dsn/list.ts +++ b/packages/cli/src/commands/dsn/list.ts @@ -10,6 +10,7 @@ import { listOrganizationDsns, listProjectDsns, } from "../../lib/api/projects.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { @@ -86,7 +87,9 @@ async function listForOrganization( advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); const hasMore = !!nextCursor; - const command = `sentry dsn list ${target} --limit ${flags.limit}`; + const command = asInvocationCommand( + `sentry dsn list ${target} --limit ${flags.limit}`, + ); return { items: data.map((dsn) => ({ ...dsn, org })), diff --git a/packages/cli/src/commands/event/list.ts b/packages/cli/src/commands/event/list.ts index 6205e5eb2..a7694ad93 100644 --- a/packages/cli/src/commands/event/list.ts +++ b/packages/cli/src/commands/event/list.ts @@ -9,6 +9,7 @@ import type { SentryContext } from "../../context.js"; import { listIssueEvents } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -58,7 +59,10 @@ function nextPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`sentry event list ${issueArg} -c next`, flags); + return appendEventsFlags( + asInvocationCommand(`sentry event list ${issueArg} -c next`), + flags, + ); } /** Build the CLI hint for fetching the previous page, preserving active flags. */ @@ -66,7 +70,10 @@ function prevPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`sentry event list ${issueArg} -c prev`, flags); + return appendEventsFlags( + asInvocationCommand(`sentry event list ${issueArg} -c prev`), + flags, + ); } // --------------------------------------------------------------------------- @@ -208,7 +215,7 @@ export const listCommand = buildListCommand("event", { const countText = `Showing ${events.length} event${events.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use 'sentry event view <EVENT_ID>' to view full event details.`; + : `${countText} Use '${asInvocationCommand("sentry event view <EVENT_ID>")}' to view full event details.`; } yield new CommandOutput({ diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index 3d683c406..66616295e 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -11,6 +11,7 @@ import type { DsnComponents, Event } from "@sentry/core"; import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { buildEventFromFlags, @@ -382,7 +383,7 @@ built entirely from the file contents. if (!flags.message?.length) { throw new ConfigError( "Provide a message via -m/--message or a JSON event file as a positional argument.", - "sentry event send -m 'My message'", + asInvocationCommand("sentry event send -m 'My message'"), ); } const event = await buildEventFromFlags(flags); diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index 48ca7e7e3..a42ddf06a 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -17,6 +17,7 @@ import { type ResolvedEvent, resolveEventInOrg, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { type IssueSelector, issueSelectorHelp, @@ -186,7 +187,7 @@ export function jsonTransformEventView(data: EventViewData, fields?: string[]) { function replayHint(org: string, event: SentryEvent): string | undefined { const replayId = getReplayIdFromEvent(event); return replayId - ? `Related replay: sentry replay view ${org}/${replayId}` + ? `Related replay: ${asInvocationCommand(`sentry replay view ${org}/${replayId}`)}` : undefined; } @@ -364,7 +365,7 @@ export function parseEventViewInput(args: readonly string[]): EventViewInput { // 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, [ - "'latest' resolves the newest event of an issue — pass an issue ID, e.g. 'sentry event view 17370'", + `'latest' resolves the newest event of an issue — pass an issue ID, e.g. '${asInvocationCommand("sentry event view 17370")}'`, `Or use the @latest selector for the latest event of the most recent unresolved issue: ${SELECTOR_HINT}`, ]); } @@ -594,7 +595,9 @@ async function validateAndRecoverEventId( const result = await recoverHexId(rawEventId, "event", recoveryCtx); return handleRecoveryResult(result, err, { entityType: "event", - canonicalCommand: `sentry event view ${recoveryCtx.org || "<org>"}/${recoveryCtx.project ?? "<project>"}/<id>`, + canonicalCommand: asInvocationCommand( + `sentry event view ${recoveryCtx.org || "<org>"}/${recoveryCtx.project ?? "<project>"}/<id>`, + ), logTag: "event.view", }); } @@ -691,7 +694,9 @@ export async function resolveEventTarget( const resolved = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - `sentry event view <org>/${parsed.projectSlug} ${eventId}`, + asInvocationCommand( + `sentry event view <org>/${parsed.projectSlug} ${eventId}`, + ), parsed.originalSlug, ); target = { @@ -746,7 +751,7 @@ export async function resolveOrgAllTarget( throw new ResolutionError( `Event ${eventId} in organization "${org}"`, "not found", - `sentry event view ${org}/<project> ${eventId}`, + asInvocationCommand(`sentry event view ${org}/<project> ${eventId}`), ); } return { @@ -779,7 +784,7 @@ export async function resolveAutoDetectTarget( .withTag("event.view") .warn( `Found event in ${resolved.org}/${resolved.project}. ` + - `Use: sentry event view ${resolved.org}/${resolved.project} ${eventId}`, + `Use: ${asInvocationCommand(`sentry event view ${resolved.org}/${resolved.project} ${eventId}`)}`, ); return { org: resolved.org, @@ -961,14 +966,14 @@ export async function fetchEventWithContext( // Nudge the user when the event ID looks like an issue short ID if (issueForm(eventId) === "short-id") { suggestions.unshift( - `This looks like an issue short ID. Try: sentry issue view ${eventId}`, + `This looks like an issue short ID. Try: ${asInvocationCommand(`sentry issue view ${eventId}`)}`, ); } throw new ResolutionError( `Event '${eventId}'`, `not found in ${org}/${project}`, - `sentry event view ${org}/<project> ${eventId}`, + asInvocationCommand(`sentry event view ${org}/<project> ${eventId}`), suggestions, ); } @@ -1039,7 +1044,9 @@ async function resolveSelectorScope( return await resolveProjectBoundSlug( parsed.projectSlug, SELECTOR_HINT, - `sentry event view <org>/${parsed.projectSlug}/${selector}`, + asInvocationCommand( + `sentry event view <org>/${parsed.projectSlug}/${selector}`, + ), parsed.originalSlug, ); case ProjectSpecificationType.AutoDetect: { @@ -1083,7 +1090,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: ${USAGE_HINT}`, + hint: `Showing the latest event of ${issue.shortId} (${selector}). Pass an event ID to view a specific one: ${asInvocationCommand(USAGE_HINT)}`, }; } @@ -1127,7 +1134,10 @@ async function resolveIssueIdShortcut( : undefined; const resolved = await resolveOrg({ org: explicitOrg, cwd }); if (!resolved) { - throw new ContextError("Organization", `sentry event view ${issueId}`); + throw new ContextError( + "Organization", + asInvocationCommand(`sentry event view ${issueId}`), + ); } const org = resolved.org; log.info(`Fetching latest event for issue ${issueId}...`); @@ -1188,7 +1198,7 @@ async function resolveIssueShortcut( if (!resolved) { throw new ContextError( "Organization", - `sentry issue view ${issueShortId}`, + asInvocationCommand(`sentry issue view ${issueShortId}`), ); } @@ -1201,7 +1211,7 @@ async function resolveIssueShortcut( throw new ResolutionError( `Issue '${issueShortId}'`, "has no associated project", - `sentry event view <org>/<project> ${eventId}`, + asInvocationCommand(`sentry event view <org>/<project> ${eventId}`), ["Specify the project explicitly to view this event"], ); } @@ -1230,7 +1240,7 @@ async function resolveIssueShortcut( return { org: resolved.org, data, - hint: `Tip: Use 'sentry issue view ${issueShortId}' to view the full issue`, + hint: `Tip: Use '${asInvocationCommand(`sentry issue view ${issueShortId}`)}' to view the full issue`, }; } diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 90b2521c5..b55d1717f 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -21,6 +21,7 @@ import { queryEventsTimeSeries, queryMetricsMeta, } from "../lib/api-client.js"; +import { asInvocationCommand } from "../lib/app-identity.js"; import { validateLimit } from "../lib/arg-parsing.js"; import { choiceParser, @@ -432,8 +433,8 @@ function validateMetricsFields(fieldList: string[]): void { `Invalid metrics aggregate${badAggs.length > 1 ? "s" : ""}: ${badAggs.join(", ")}\n\n` + "The metrics dataset requires the format: aggregation(value,metric_name,metric_type,unit)\n\n" + "Examples:\n" + - ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics\n' + - ' sentry explore my-org/ -F gen_ai.request.model -F "avg(value,cache.hit_rate,distribution,none)" --dataset metrics\n\n' + + ` ${asInvocationCommand('sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics')}\n` + + ` ${asInvocationCommand('sentry explore my-org/ -F gen_ai.request.model -F "avg(value,cache.hit_rate,distribution,none)" --dataset metrics')}\n\n` + "Parameters:\n" + ' - value: literal string "value"\n' + " - metric_name: the metric name emitted by the SDK (e.g., llm.token_usage)\n" + @@ -963,10 +964,10 @@ export const exploreCommand = buildListCommand("explore", { throw new ValidationError( "The metrics dataset requires --metric or explicit --field flags.\n\n" + "Auto mode (recommended):\n" + - " sentry explore my-org/ -m llm.token_usage --dataset metrics\n" + - " sentry explore my-org/ -m llm.token_usage --agg avg --dataset metrics\n\n" + + ` ${asInvocationCommand("sentry explore my-org/ -m llm.token_usage --dataset metrics")}\n` + + ` ${asInvocationCommand("sentry explore my-org/ -m llm.token_usage --agg avg --dataset metrics")}\n\n` + "Manual mode (tracemetrics format):\n" + - ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics', + ` ${asInvocationCommand('sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics')}`, "field", ); } @@ -1073,11 +1074,11 @@ export const exploreCommand = buildListCommand("explore", { hasPrev, hasMore, prevHint: appendFlagHints( - `sentry explore ${baseTarget} -c prev`, + asInvocationCommand(`sentry explore ${baseTarget} -c prev`), hintFlags, ), nextHint: appendFlagHints( - `sentry explore ${baseTarget} -c next`, + asInvocationCommand(`sentry explore ${baseTarget} -c next`), hintFlags, ), }); diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 6f634a9b0..539466878 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -10,6 +10,7 @@ import { getProject, listFeedback, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { @@ -111,7 +112,9 @@ function pageHint( flags: Pick<ListFlags, "status" | "limit" | "query" | "period">, ): string { return appendFeedbackFlags( - `sentry feedback list ${formatScope(org, project)} -c ${direction}`, + asInvocationCommand( + `sentry feedback list ${formatScope(org, project)} -c ${direction}`, + ), flags, ); } @@ -286,7 +289,7 @@ export const listCommand = buildListCommand("feedback", { const count = `Showing ${feedback.length} feedback item${feedback.length === 1 ? "" : "s"}.`; const first = feedback[0]; const viewHint = first - ? `Use 'sentry feedback view ${resolved.org}/${first.shortId}' for details.` + ? `Use '${asInvocationCommand(`sentry feedback view ${resolved.org}/${first.shortId}`)}' for details.` : undefined; hint = nav ? `${count} ${nav}` diff --git a/packages/cli/src/commands/feedback/positional.ts b/packages/cli/src/commands/feedback/positional.ts index 1ca7460d1..5c9d52d4a 100644 --- a/packages/cli/src/commands/feedback/positional.ts +++ b/packages/cli/src/commands/feedback/positional.ts @@ -3,6 +3,7 @@ * identifier, parsed before the command runs. */ +import { asInvocationCommand } from "../../lib/app-identity.js"; import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -46,7 +47,7 @@ export function feedbackPositional(command: string) { if (!feedback) { throw new ContextError( "Feedback ID", - `sentry feedback ${command} <feedback>`, + asInvocationCommand(`sentry feedback ${command} <feedback>`), ); } return await resolveFeedback(feedback, context.cwd, command); diff --git a/packages/cli/src/commands/feedback/utils.ts b/packages/cli/src/commands/feedback/utils.ts index 1fbf95d9f..794defbaf 100644 --- a/packages/cli/src/commands/feedback/utils.ts +++ b/packages/cli/src/commands/feedback/utils.ts @@ -3,6 +3,7 @@ */ import { listFeedback, updateIssueStatus } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import type { IssueSelector } from "../../lib/arg-parsing.js"; import { ApiError, @@ -48,7 +49,7 @@ async function resolveFeedbackSelector( if (!org) { throw new ContextError( "Organization", - `sentry feedback ${command} <org>/@latest`, + asInvocationCommand(`sentry feedback ${command} <org>/@latest`), ); } @@ -61,7 +62,7 @@ async function resolveFeedbackSelector( throw new ResolutionError( "Selector '@latest'", "found no unresolved User Feedback", - `sentry feedback list ${org}/ --status all`, + asInvocationCommand(`sentry feedback list ${org}/ --status all`), ["The @latest selector only matches unresolved Feedback."], ); } @@ -102,15 +103,17 @@ export async function resolveFeedback( issueArg: feedbackArg, cwd, command, - commandBase: "sentry feedback", + commandBase: asInvocationCommand("sentry feedback"), }); } catch (error) { if (error instanceof ApiError && error.status === 404) { throw new ResolutionError( `Feedback '${text}'`, "not found", - buildCommandHint(command, text, "sentry feedback"), - ["List available Feedback: sentry feedback list"], + buildCommandHint(command, text, asInvocationCommand("sentry feedback")), + [ + `List available Feedback: ${asInvocationCommand("sentry feedback list")}`, + ], ); } throw error; @@ -121,8 +124,12 @@ export async function resolveFeedback( throw new ResolutionError( `Issue '${issue.shortId}'`, "is not User Feedback", - `sentry issue view ${org ? `${org}/` : ""}${issue.shortId}`, - ["Use a Feedback ID from: sentry feedback list"], + asInvocationCommand( + `sentry issue view ${org ? `${org}/` : ""}${issue.shortId}`, + ), + [ + `Use a Feedback ID from: ${asInvocationCommand("sentry feedback list")}`, + ], ); } diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 0514f8e51..03a4903b5 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../context.js"; import { getCurrentUser } from "../lib/api/users.js"; +import { asInvocationCommand } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { DEFAULT_SENTRY_URL } from "../lib/constants.js"; import { isAuthenticated } from "../lib/db/auth.js"; @@ -179,8 +180,8 @@ export const infoCommand = buildCommand({ this.process.exitCode = 1; return { hint: failed - ? "Not authenticated. Run `sentry auth login`." - : "No default organization/project. Set them with `sentry cli defaults org <org>` and `sentry cli defaults project <project>`.", + ? `Not authenticated. Run \`${asInvocationCommand("sentry auth login")}\`.` + : `No default organization/project. Set them with \`${asInvocationCommand("sentry cli defaults org <org>")}\` and \`${asInvocationCommand("sentry cli defaults project <project>")}\`.`, }; } return { hint: "Configuration verified." }; diff --git a/packages/cli/src/commands/integration/delete.ts b/packages/cli/src/commands/integration/delete.ts index 8c0b598c9..dfe6f42db 100644 --- a/packages/cli/src/commands/integration/delete.ts +++ b/packages/cli/src/commands/integration/delete.ts @@ -10,6 +10,7 @@ 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"; @@ -114,7 +115,7 @@ export function parseIntegrationArguments( // 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}: ${USAGE_HINT}`, + `Remove one integration at a time, not ${ids.length}: ${asInvocationCommand(USAGE_HINT)}`, "integration-id", ); } diff --git a/packages/cli/src/commands/issue/events.ts b/packages/cli/src/commands/issue/events.ts index 09abcba89..07b5c9514 100644 --- a/packages/cli/src/commands/issue/events.ts +++ b/packages/cli/src/commands/issue/events.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { listIssueEvents } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -52,7 +53,10 @@ function nextPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`sentry issue events ${issueArg} -c next`, flags); + return appendEventsFlags( + asInvocationCommand(`sentry issue events ${issueArg} -c next`), + flags, + ); } /** Build the CLI hint for fetching the previous page, preserving active flags. */ @@ -60,7 +64,10 @@ function prevPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`sentry issue events ${issueArg} -c prev`, flags); + return appendEventsFlags( + asInvocationCommand(`sentry issue events ${issueArg} -c prev`), + flags, + ); } // --------------------------------------------------------------------------- @@ -184,7 +191,7 @@ export const eventsCommand = buildListCommand("issue", { const countText = `Showing ${events.length} event${events.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use 'sentry event view <EVENT_ID>' to view full event details.`; + : `${countText} Use '${asInvocationCommand("sentry event view <EVENT_ID>")}' to view full event details.`; } yield new CommandOutput({ diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index aaaf8392c..af20c9b30 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -5,6 +5,7 @@ */ import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError, stringifyUnknown } from "../../lib/errors.js"; @@ -186,6 +187,8 @@ export const explainCommand = buildCommand({ }); return isBatch ? undefined - : { hint: `To create a plan, run: sentry issue plan ${primaryArg}` }; + : { + hint: `To create a plan, run: ${asInvocationCommand(`sentry issue plan ${primaryArg}`)}`, + }; }, }); diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 2a01252e7..24da1dd59 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -14,6 +14,7 @@ import { listIssuesAllPages, } from "../../lib/api-client.js"; import { extractRequiredScopes } from "../../lib/api-scope.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { CURSOR_KEYWORDS, formatProjectTarget, @@ -384,11 +385,11 @@ function formatListHeader(title: string): string { function formatListFooter(mode: "single" | "multi" | "none"): string { switch (mode) { case "single": - return "\nTip: Use 'sentry issue view <ID>' to view details (bold part works as shorthand)."; + return `\nTip: Use '${asInvocationCommand("sentry issue view <ID>")}' to view details (bold part works as shorthand).`; case "multi": - return "\nTip: Use 'sentry issue view <ALIAS>' to view details (see ALIAS column)."; + return `\nTip: Use '${asInvocationCommand("sentry issue view <ALIAS>")}' to view details (see ALIAS column).`; default: - return "\nTip: Use 'sentry issue view <SHORT_ID>' to view issue details."; + return `\nTip: Use '${asInvocationCommand("sentry issue view <SHORT_ID>")}' to view issue details.`; } } @@ -721,7 +722,7 @@ function trimWithProjectGuarantee( /** Re-run the failed search with an explicit target, preserving search and output flags. */ function contextRecoveryCommand(target: string, flags: ListFlags): string { const parts = [ - `sentry issue list ${shellQuote(target)}`, + asInvocationCommand(`sentry issue list ${shellQuote(target)}`), `--limit ${flags.limit}`, `--sort ${flags.sort}`, `--period ${shellQuote(formatTimeRangeFlag(flags.period))}`, @@ -765,7 +766,10 @@ function pageHint( target: string, flags: ListFlags, ): string { - return appendIssueFlags(`sentry issue list ${target} -c ${direction}`, flags); + return appendIssueFlags( + asInvocationCommand(`sentry issue list ${target} -c ${direction}`), + flags, + ); } /** @@ -1063,7 +1067,7 @@ function build400Detail( } suggestions.push( - "Verify you have access to the target project: sentry project list <org>/", + `Verify you have access to the target project: ${asInvocationCommand("sentry project list <org>/")}`, ); return formatDetailWithSuggestions(lines, suggestions); @@ -1182,10 +1186,14 @@ function build403Detail(originalDetail: unknown): string { " • Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/", ); } else { - lines.push(" • Re-authenticate with: sentry auth login"); + lines.push( + ` • Re-authenticate with: ${asInvocationCommand("sentry auth login")}`, + ); } - lines.push(" • Verify project membership: sentry project list <org>/"); + lines.push( + ` • Verify project membership: ${asInvocationCommand("sentry project list <org>/")}`, + ); return lines.join("\n "); } @@ -1198,7 +1206,7 @@ function build403Detail(originalDetail: unknown): string { */ function appendProjectMembershipHint(detail: string | undefined): string { const base = detail ?? "You do not have permission to perform this action."; - return `${base}\n Verify project membership: sentry project list <org>/`; + return `${base}\n Verify project membership: ${asInvocationCommand("sentry project list <org>/")}`; } /** @@ -1817,7 +1825,7 @@ export const listCommand = buildListCommand("issue", { compact: true, } satisfies IssueListResult); return { - hint: `Tip: Use 'sentry issue view ${shortId}' for full details`, + hint: `Tip: Use '${asInvocationCommand(`sentry issue view ${shortId}`)}' for full details`, }; } diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 358dcb624..56d357ff4 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -22,6 +22,7 @@ import type { SentryContext } from "../../context.js"; import { type MergeIssuesResult, mergeIssues } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseIssueReference } from "../../lib/completion-target.js"; import { @@ -337,8 +338,8 @@ export const mergeCommand = buildCommand({ if (effectiveArgs.length < 2) { const hint = args.length === 1 - ? "Tip: you can also write: sentry issue merge CLI-K9 --into CLI-15H" - : "Example: sentry issue merge CLI-K9 CLI-15H CLI-15N"; + ? `Tip: you can also write: ${asInvocationCommand("sentry issue merge CLI-K9 --into CLI-15H")}` + : `Example: ${asInvocationCommand("sentry issue merge CLI-K9 CLI-15H CLI-15N")}`; throw new ValidationError( `'sentry ${COMMAND_PATH}' needs at least 2 issue IDs (got ${args.length}).\n\n` + hint, diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 204b16f3b..096e76ca6 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -8,6 +8,7 @@ import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError, CliError } from "../../lib/errors.js"; @@ -318,7 +319,7 @@ export const planCommand = buildCommand({ "Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.", timeoutHint: "The plan may still be generated in the background.\n" + - ` Or retry: sentry issue plan ${issueArg.text}`, + ` Or retry: ${asInvocationCommand(`sentry issue plan ${issueArg.text}`)}`, }); if (finalState.status === "ERROR") { diff --git a/packages/cli/src/commands/issue/positional.ts b/packages/cli/src/commands/issue/positional.ts index 1e123a131..16eae8ea7 100644 --- a/packages/cli/src/commands/issue/positional.ts +++ b/packages/cli/src/commands/issue/positional.ts @@ -8,6 +8,7 @@ * pasted issue URL is applied. See `positional-parse.ts`. */ +import { asInvocationCommand } from "../../lib/app-identity.js"; import { ISSUE_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -66,7 +67,8 @@ export function requireIssueArgument( if (issue) { return issue; } - const { command, commandBase = "sentry issue" } = issueCommand; + const { command, commandBase = asInvocationCommand("sentry issue") } = + issueCommand; throw new ContextError("Issue ID", `${commandBase} ${command} <issue>`); } @@ -78,7 +80,8 @@ export function requireIssueArgument( function issueTarget( issueCommand: IssueCommand, ): (this: IssueTargetContext, input?: string) => IssueTarget { - const { command, commandBase = "sentry issue" } = issueCommand; + const { command, commandBase = asInvocationCommand("sentry issue") } = + issueCommand; return boundPositional( ISSUE_TARGET, parseIssueTargetInput, diff --git a/packages/cli/src/commands/issue/resolve-commit-spec.ts b/packages/cli/src/commands/issue/resolve-commit-spec.ts index b221d287b..0dcd53771 100644 --- a/packages/cli/src/commands/issue/resolve-commit-spec.ts +++ b/packages/cli/src/commands/issue/resolve-commit-spec.ts @@ -19,6 +19,7 @@ import { execFileSync } from "node:child_process"; import type { ResolveCommitSpec } from "../../lib/api/issues.js"; import { listRepositoriesCached } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { ValidationError } from "../../lib/errors.js"; import { getHeadCommit, @@ -88,7 +89,7 @@ function formatAvailableRepos(repos: SentryRepository[]): string { .join("\n"); const more = repos.length > MAX - ? `\n ... and ${repos.length - MAX} more (sentry repo list <org>/)` + ? `\n ... and ${repos.length - MAX} more (${asInvocationCommand("sentry repo list <org>/")})` : ""; return `Available repositories in this organization:\n${names}${more}`; } diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index a5cebcb47..d54ae5f89 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -24,6 +24,7 @@ import { type ResolveStatusDetails, updateIssueStatus, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; @@ -184,7 +185,9 @@ export const resolveCommand = buildCommand({ // issue's org — we can't resolve the commit without knowing the org. throw new ContextError( "Organization", - "sentry issue resolve <org>/<issue> --in @commit", + asInvocationCommand( + "sentry issue resolve <org>/<issue> --in @commit", + ), [], "--in @commit needs an organization context to look up the Sentry repo registry.", ); diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index d6994b6d1..1b388b0f3 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -21,6 +21,7 @@ import { triggerRootCauseAnalysis, tryGetIssueByShortId, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import type { IssueSelector } from "../../lib/arg-parsing.js"; import { clearCachedIssueOrg, @@ -130,7 +131,7 @@ export async function mapIssueArgsConcurrently<T>( export function buildCommandHint( command: string, issueId: string, - base = "sentry issue", + base = asInvocationCommand("sentry issue"), trailingArgs: readonly string[] = [], ): string { return appendCommandArguments( @@ -625,7 +626,9 @@ export async function findSelectorIssue( throw new ResolutionError( `Selector '${selector}'`, "no unresolved issues found", - `sentry issue list ${org}/${project ?? ""} -q "is:resolved"`, + asInvocationCommand( + `sentry issue list ${org}/${project ?? ""} -q "is:resolved"`, + ), [ `The ${SELECTOR_LABELS[selector]} issue selector only matches unresolved issues.`, ], @@ -670,7 +673,9 @@ async function resolveIssueSearch( return { org, issue }; } - const listCommand = `sentry issue list ${org}/ --query ${shellQuote(query)}`; + const listCommand = asInvocationCommand( + `sentry issue list ${org}/ --query ${shellQuote(query)}`, + ); if (!issue) { throw new ResolutionError( `Search ${shellQuote(query)}`, @@ -925,7 +930,8 @@ export async function resolveIssue( options: ResolveIssueOptions, ): Promise<ResolvedIssueResult> { const { cwd, command, commandBase, trailingArgs = [] } = options; - const effectiveCommandBase = commandBase ?? "sentry issue"; + const effectiveCommandBase = + commandBase ?? asInvocationCommand("sentry issue"); const { text, parsed, baseUrl } = issueArgumentOf(options.issueArg); if (baseUrl) { applySentryUrlContext(baseUrl); @@ -1216,7 +1222,7 @@ export async function pollAutofixState( timeoutHint ?? "The analysis may still complete in the background.\n" + ` View in Sentry: ${issueUrl}\n` + - ` Or retry: sentry issue explain ${issueId}`; + ` Or retry: ${asInvocationCommand(`sentry issue explain ${issueId}`)}`; return await poll<AutofixState>({ fetchState: () => getAutofixState(orgSlug, issueId), diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index 9f655c3dd..2159eceae 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { getLatestEvent, listReplayIdsForIssue } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { issueSelectorHelp, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -443,7 +444,7 @@ export const viewCommand = buildCommand({ requestedCount: issueArgs.length, }); return { - hint: `Tip: Use 'sentry issue explain ${primaryArg}' for AI root cause analysis`, + hint: `Tip: Use '${asInvocationCommand(`sentry issue explain ${primaryArg}`)}' for AI root cause analysis`, }; }, }); diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index ad7654034..1c3fd20be 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -21,6 +21,7 @@ import { import type { Server } from "node:http"; import { basename, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser, @@ -332,17 +333,20 @@ function augmentPathForNode( }; } -const AUTO_DETECT_ERROR_MESSAGE = [ - "No command provided and could not auto-detect a dev script.", - "Usage: sentry local run -- <command>", - "", - "Supported auto-detection:", - " - package.json (scripts: dev, develop, serve, start)", - " - manage.py (Django)", - " - app.py / main.py (Python)", - " - go.mod (Go)", - " - docker-compose.yml / compose.yml (Docker Compose)", -].join("\n"); +/** The error shown when no command was given and none was detected. */ +function autoDetectErrorMessage(): string { + return [ + "No command provided and could not auto-detect a dev script.", + `Usage: ${asInvocationCommand("sentry local run -- <command>")}`, + "", + "Supported auto-detection:", + " - package.json (scripts: dev, develop, serve, start)", + " - manage.py (Django)", + " - app.py / main.py (Python)", + " - go.mod (Go)", + " - docker-compose.yml / compose.yml (Docker Compose)", + ].join("\n"); +} /** Build the env vars for the child process. */ function buildChildEnv( @@ -379,7 +383,7 @@ async function resolveArgs( } const detected = await detectDevCommand(cwd); if (!detected) { - throw new ValidationError(AUTO_DETECT_ERROR_MESSAGE, "command"); + throw new ValidationError(autoDetectErrorMessage(), "command"); } logger.info(`Detected ${detected.source}: ${detected.args.join(" ")}`); return { args: detected.args, commandSource: detected.source }; @@ -593,7 +597,7 @@ export const runCommand = buildCommand({ logger.info(`Starting: ${bold(wrangler.args.join(" "))}`); logger.info( daemonSessionId - ? `SENTRY_SPOTLIGHT configured for session ${daemonSessionId}. Use sentry local session view ${daemonSessionId} --json for its ingest URL.` + ? `SENTRY_SPOTLIGHT configured for session ${daemonSessionId}. Use ${asInvocationCommand(`sentry local session view ${daemonSessionId} --json`)} for its ingest URL.` : `SENTRY_SPOTLIGHT=${spotlightUrl}`, ); diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index a4a12c01e..cab18991e 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1,3 +1,4 @@ +import { asInvocationCommand } from "../../lib/app-identity.js"; import { choiceParser, parseBindHost, @@ -1136,7 +1137,7 @@ export const serverCommand = buildCommand({ printLocalViewerHint(session.streamUrl, flags.format === "json"); logger.info(` Session: ${bold(session.id)} (${session.label})`); logger.info( - ` Capabilities: sentry local session view ${session.id} --json`, + ` Capabilities: ${asInvocationCommand(`sentry local session view ${session.id} --json`)}`, ); logger.info("Press Ctrl-C to stop."); await openLocalUiIfRequested( @@ -1247,7 +1248,7 @@ export const serverCommand = buildCommand({ ` Set ${bold("SENTRY_SPOTLIGHT")}=${listenUrl}/stream in your app`, ); logger.info( - ` Or run: ${bold(`sentry local run -p ${boundPort} -- <your-command>`)}`, + ` Or run: ${bold(asInvocationCommand(`sentry local run -p ${boundPort} -- <your-command>`))}`, ); if (activeFilters.size > 0) { logger.info(` Filtering: ${[...activeFilters].join(", ")}`); diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts index 41d86609b..e86f1c802 100644 --- a/packages/cli/src/commands/local/ui.ts +++ b/packages/cli/src/commands/local/ui.ts @@ -1,3 +1,4 @@ +import { asInvocationCommand } from "../../lib/app-identity.js"; import { isLoopbackHost, normalizeLocalHostname } from "./loopback.js"; import { openBrowser } from "../../lib/browser.js"; import { getCliEnvironment } from "../../lib/constants.js"; @@ -117,7 +118,7 @@ export async function openLocalUi( const streamUrl = new URL(receiverUrl); if (streamUrl.searchParams.has("cap") || directoryUrl) { logger.warn( - "Could not open browser. Use sentry local session view <session> --json to retrieve its stream URL.", + `Could not open browser. Use ${asInvocationCommand("sentry local session view <session> --json")} to retrieve its stream URL.`, ); return; } diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index 94328a474..e3fe68a9a 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -12,6 +12,7 @@ import { listLogs, listTraceLogs, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildProjectQuery, type ParsedOrgProject, @@ -581,7 +582,7 @@ async function executeTraceSingleFetch( result: { logs: [], traceId, hasMore: false }, hint: `No logs found for trace ${traceId} ${periodLabel}.\n\n` + - "Try 'sentry trace logs' for more options (e.g., --period 30d).", + `Try '${asInvocationCommand("sentry trace logs")}' for more options (e.g., --period 30d).`, }; } @@ -592,9 +593,9 @@ async function executeTraceSingleFetch( // Build hint with real values for easy copy-paste let hint = `${countText}${tip}`; if (projectFilter) { - hint += `\nFiltered to project '${projectFilter}'. Full trace logs: sentry log list ${org}/${traceId}`; + hint += `\nFiltered to project '${projectFilter}'. Full trace logs: ${asInvocationCommand(`sentry log list ${org}/${traceId}`)}`; } else { - hint += `\nFilter by project: sentry log list ${org}/<project>/${traceId}`; + hint += `\nFilter by project: ${asInvocationCommand(`sentry log list ${org}/<project>/${traceId}`)}`; } return { diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index f254b22b4..869229682 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -10,6 +10,7 @@ import pLimit from "p-limit"; import type { SentryContext } from "../../context.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { looksLikeIssueShortId, type OrgProjectInput, @@ -111,7 +112,7 @@ export function parsePositionalArgs(args: readonly string[]): { ...(args.length > 1 && first && looksLikeIssueShortId(first) && { - suggestion: `Did you mean: sentry issue view ${first}`, + suggestion: `Did you mean: ${asInvocationCommand(`sentry issue view ${first}`)}`, }), }; } @@ -139,7 +140,9 @@ async function validateAndRecoverLogId( }); return handleRecoveryResult(result, err, { entityType: "log", - canonicalCommand: `sentry log view ${target.org}/${target.project}/<id>`, + canonicalCommand: asInvocationCommand( + `sentry log view ${target.org}/${target.project}/<id>`, + ), logTag: "log.view", }); } @@ -178,7 +181,9 @@ async function resolveTarget( const result = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - `sentry log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, + asInvocationCommand( + `sentry log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, + ), parsed.originalSlug, ); if ( @@ -379,7 +384,7 @@ function throwNotFoundError( throw new ResolutionError( `Log '${id}'`, `not found in ${org}/${project}`, - `sentry log view ${org}/${project}/${id}`, + asInvocationCommand(`sentry log view ${org}/${project}/${id}`), suggestions, ); } diff --git a/packages/cli/src/commands/monitor/run.ts b/packages/cli/src/commands/monitor/run.ts index f48a85d1a..1a902e9a1 100644 --- a/packages/cli/src/commands/monitor/run.ts +++ b/packages/cli/src/commands/monitor/run.ts @@ -24,6 +24,7 @@ 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, @@ -283,13 +284,13 @@ The wrapped command receives the \`SENTRY_MONITOR_SLUG\` environment variable.`, if (!monitorSlug) { throw new ValidationError( - `No monitor slug provided. Usage: ${USAGE_HINT}`, + `No monitor slug provided. Usage: ${asInvocationCommand(USAGE_HINT)}`, "monitor-slug", ); } if (command.length === 0) { throw new ValidationError( - `No command provided. Usage: ${USAGE_HINT}`, + `No command provided. Usage: ${asInvocationCommand(USAGE_HINT)}`, "command", ); } diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index f50c03cc8..25ddb4508 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -10,6 +10,7 @@ import type { InferOutput } from "valibot"; import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseAccountSelector, @@ -320,11 +321,13 @@ function mergeAccountOrgs(results: AccountOrgs[]): { */ function reloginCommand(account: AccountSummary): string { if (account.email) { - return `sentry auth login ${accountSelectorOf(account)}`; + return asInvocationCommand( + `sentry auth login ${accountSelectorOf(account)}`, + ); } return account.host - ? `sentry auth login --url ${account.host}` - : "sentry auth login"; + ? asInvocationCommand(`sentry auth login --url ${account.host}`) + : asInvocationCommand("sentry auth login"); } /** @@ -351,7 +354,7 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { } const account = resolveAccountSelector( selector, - "sentry org list --account", + asInvocationCommand("sentry org list --account"), { fuzzy: true, }, @@ -510,10 +513,12 @@ export const listCommand = buildCommand({ hints.push(`Showing ${flags.limit} of ${orgs.length} organizations`); } if (entries.length > 0) { - hints.push("Tip: Use 'sentry org view <slug>' for details"); + hints.push( + `Tip: Use '${asInvocationCommand("sentry org view <slug>")}' for details`, + ); } else if (filter && merged.orgs.length > 0) { hints.push( - `None of your ${merged.orgs.length} organizations match '${filter}'. Run 'sentry org list' to see them all.`, + `None of your ${merged.orgs.length} organizations match '${filter}'. Run '${asInvocationCommand("sentry org list")}' to see them all.`, ); } diff --git a/packages/cli/src/commands/proguard/uuid.ts b/packages/cli/src/commands/proguard/uuid.ts index c79f9e55b..bb5f438bf 100644 --- a/packages/cli/src/commands/proguard/uuid.ts +++ b/packages/cli/src/commands/proguard/uuid.ts @@ -8,6 +8,7 @@ import { readFile } from "node:fs/promises"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -70,7 +71,7 @@ export const uuidCommand = buildCommand({ if (!path?.trim()) { throw new ContextError( "Mapping file path", - "sentry proguard uuid <path>", + asInvocationCommand("sentry proguard uuid <path>"), [], ); } diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index a86a3e656..104800401 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -26,6 +26,7 @@ import { listTeams, MEMBER_PROJECT_CREATION_DISABLED_DETAIL, } from "../../lib/api-client.js"; +import { asInvocationCommand } 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"; @@ -134,10 +135,10 @@ function buildPlatformError(nameArg: string, platform?: string): string { `${heading}\n` + didYouMean + "\nUsage:\n" + - ` sentry project create ${nameArg}:<platform>\n\n` + + ` ${asInvocationCommand(`sentry project create ${nameArg}:<platform>`)}\n\n` + `Common platforms:\n\n${platformTable}\n` + - "Run 'sentry platform list' to see all valid platform identifiers.\n" + - "Run 'sentry project create <name>:<platform>' with any valid Sentry platform identifier." + `Run '${asInvocationCommand("sentry platform list")}' to see all valid platform identifiers.\n` + + `Run '${asInvocationCommand("sentry project create <name>:<platform>")}' with any valid Sentry platform identifier.` ); } @@ -180,7 +181,9 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Team '${teamSlug}'`, `not found in ${orgSlug}`, - `sentry project create ${orgSlug}/${name}:${platform} --team <team-slug>`, + asInvocationCommand( + `sentry project create ${orgSlug}/${name}:${platform} --team <team-slug>`, + ), [`Available teams: ${teams.map((t) => t.slug).join(", ")}`], ); } @@ -200,10 +203,12 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Project '${name}' in ${orgSlug}`, "could not be created", - `sentry project create ${orgSlug}/${name}:${platform} --team <team-slug>`, + asInvocationCommand( + `sentry project create ${orgSlug}/${name}:${platform} --team <team-slug>`, + ), [ "The organization or team may not exist, or you may lack access", - `List teams: sentry team list ${orgSlug}/`, + `List teams: ${asInvocationCommand(`sentry team list ${orgSlug}/`)}`, ], ); } @@ -320,7 +325,7 @@ function projectExistsError(orgSlug: string, name: string): CliError { const slug = slugify(name); return new CliError( `A project named '${name}' already exists in ${orgSlug}.\n\n` + - `View it: sentry project view ${orgSlug}/${slug}`, + `View it: ${asInvocationCommand(`sentry project view ${orgSlug}/${slug}`)}`, ); } @@ -552,7 +557,7 @@ async function resolveProjectSpecsOrg( const resolved = await resolveOrg({ org: input.explicitOrg, cwd }); if (!resolved) { throw new ContextError("Organization", USAGE_HINT, [ - `Include org in name: ${USAGE_HINT}`, + `Include org in name: ${asInvocationCommand(USAGE_HINT)}`, ]); } return resolved; diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index bf1d8c7c3..6bc150ba4 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -25,6 +25,7 @@ import { getOrganization, getProject, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { getCachedOrgRole } from "../../lib/db/regions.js"; import { ApiError } from "../../lib/errors.js"; import { @@ -101,7 +102,7 @@ async function buildPermissionError( return new ApiError( `Permission denied: cannot delete ${label}.\n\n` + `This requires a ${rolesWithAccess} role, or a token with the 'project:admin' scope.\n` + - ` Check your role: sentry org view ${orgSlug}`, + ` Check your role: ${asInvocationCommand(`sentry org view ${orgSlug}`)}`, 403, ); } diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 30d76adf5..192e379bb 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -21,6 +21,7 @@ import { type PaginatedResponse, searchProjects, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { explicitProjectSlugs, formatProjectTarget, @@ -375,7 +376,9 @@ async function fetchAutoDetectProjects( /** Build a pagination hint for auto-detect JSON output. */ function autoDetectPaginationHint(orgs: string[], filter?: string): string { const org = orgs.length === 1 ? (orgs[0] as string) : "<org>"; - return `sentry project list ${orgFilterTarget(org, filter, shellQuote)} --json`; + return asInvocationCommand( + `sentry project list ${orgFilterTarget(org, filter, shellQuote)} --json`, + ); } /** Build the truncation header for auto-detect mode. */ @@ -392,7 +395,7 @@ function autoDetectHeader( const org = orgs[0] as string; return ( `Showing ${count} projects (more available). ` + - `Use 'sentry project list ${org}/' for paginated results.` + `Use '${asInvocationCommand(`sentry project list ${org}/`)}' for paginated results.` ); } return `Showing ${count} projects (more available). Use --limit to show more.`; @@ -407,7 +410,7 @@ function selfHostedWarning( } return ( `Note: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - "Specify the organization explicitly: sentry project list <org>/" + `Specify the organization explicitly: ${asInvocationCommand("sentry project list <org>/")}` ); } @@ -458,7 +461,7 @@ export async function handleAutoDetect( hintParts.push(footer); } hintParts.push( - "Tip: Use 'sentry project view <org>/<project>' for details", + `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`, ); } @@ -509,7 +512,7 @@ export async function handleExplicit( items: [], hint: `No project '${projectSlug}' in '${org}', and no projects matching '${projectSlug}' on platform '${flags.platform}'.\n` + - `Tip: Use 'sentry project list ${org}/' to see all projects`, + `Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, }; } } @@ -517,7 +520,7 @@ export async function handleExplicit( items: [], hint: `No project '${projectSlug}' found in organization '${org}'.\n` + - `Tip: Use 'sentry project list ${org}/' to see all projects`, + `Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, }; } const project: ProjectWithOrg = { ...projectResult.value, orgSlug: org }; @@ -533,7 +536,7 @@ export async function handleExplicit( return { items: filtered, - hint: `Tip: Use 'sentry project view ${org}/${projectSlug}' for details`, + hint: `Tip: Use '${asInvocationCommand(`sentry project view ${org}/${projectSlug}`)}' for details`, }; } @@ -573,14 +576,14 @@ async function handleExplicitProjects( items: [], hint: `No projects found among: ${slugs.map((slug) => `'${slug}'`).join(", ")}.\n` + - `Tip: Use 'sentry project list ${org}/' to see all projects`, + `Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, }; } const hintParts: string[] = []; if (missing.length > 0) { hintParts.push( - `Missing: ${missing.join(", ")}. Tip: Use 'sentry project list ${org}/' to see all projects`, + `Missing: ${missing.join(", ")}. Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, ); } if (nameFiltered.length > 0) { @@ -595,7 +598,7 @@ async function handleExplicitProjects( } if (hintParts.length === 0) { hintParts.push( - `Tip: Use 'sentry project view ${org}/<project>' for details`, + `Tip: Use '${asInvocationCommand(`sentry project view ${org}/<project>`)}' for details`, ); } @@ -660,7 +663,7 @@ export async function handleOrgAll( let hint: string | undefined; let header: string | undefined; - const base = `sentry project list ${org}/`; + const base = asInvocationCommand(`sentry project list ${org}/`); const suffix = flags.platform ? ` --platform ${flags.platform}` : ""; const navHint = paginationHint({ hasPrev, @@ -688,7 +691,7 @@ export async function handleOrgAll( } else { header = `Showing ${filtered.length} projects`; } - const tip = "Tip: Use 'sentry project view <org>/<project>' for details"; + const tip = `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`; hint = tip; } @@ -735,7 +738,7 @@ async function handleOrgAllFiltered( matches.length > items.length ? `Showing ${items.length} of ${matches.length} matches. Use --limit to show more.` : `Showing ${items.length} projects`, - hint: "Tip: Use 'sentry project view <org>/<project>' for details", + hint: `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`, }; } @@ -821,7 +824,7 @@ export async function handleProjectSearch( return { items: limited, header, - hint: "Tip: Use 'sentry project view <org>/<project>' for details", + hint: `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`, }; } @@ -836,7 +839,7 @@ function handleProjectSearchNotFound( throw new ResolutionError( `Project '${resolution.displaySlug}'`, "not found", - `sentry project list <org>/${projectSlug}`, + asInvocationCommand(`sentry project list <org>/${projectSlug}`), projectSearchNotFoundSuggestions(resolution), ); } diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index 66f8ae85f..bfa0657b9 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -11,6 +11,7 @@ import { resolveOrgDisplayName, tryGetPrimaryDsn, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -75,9 +76,13 @@ function buildContextError(skippedSelfHosted?: number): ContextError { */ async function handleWebView(resolvedTargets: ResolvedTarget[]): Promise<void> { if (resolvedTargets.length > 1) { - throw new ContextError("Single project", `${USAGE_HINT} -w`, [ - `Found ${resolvedTargets.length} projects — specify which project to open in browser`, - ]); + throw new ContextError( + "Single project", + `${asInvocationCommand(USAGE_HINT)} -w`, + [ + `Found ${resolvedTargets.length} projects — specify which project to open in browser`, + ], + ); } const target = resolvedTargets[0]; @@ -292,7 +297,7 @@ async function resolveViewTargets( const resolved = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - `sentry project view <org>/${parsed.projectSlug}`, + asInvocationCommand(`sentry project view <org>/${parsed.projectSlug}`), parsed.originalSlug, ); return { @@ -311,7 +316,7 @@ async function resolveViewTargets( case ProjectSpecificationType.OrgAll: throw new ContextError( "Specific project", - `sentry project view ${parsed.org}/<project>`, + asInvocationCommand(`sentry project view ${parsed.org}/<project>`), ["Specify the full org/project target, not just the organization"], ); diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index 7e0ff224b..d438c905a 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -7,6 +7,7 @@ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseProjectList, @@ -206,7 +207,7 @@ export const createCommand = buildCommand({ const hint = flags.finalize ? "Release created and finalized." - : `Release created. Finalize with: sentry release finalize ${version}`; + : `Release created. Finalize with: ${asInvocationCommand(`sentry release finalize ${version}`)}`; return { hint }; }, }); diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index fdcf83040..0261184e7 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -14,6 +14,7 @@ import { listReleasesPaginated, type ReleaseSortValue, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; import { choiceParser, @@ -260,7 +261,7 @@ function buildReleaseListConfig( return { paginationKey: PAGINATION_KEY, entityPlural: "releases", - commandPrefix: "sentry release list", + commandPrefix: asInvocationCommand("sentry release list"), listForOrg: async (org) => { const { data } = await listReleasesPaginated(org, { perPage: 100, @@ -503,7 +504,7 @@ async function handleAutoDetectWithProject( if (unique.length === 0) { return { items: [], - hint: "No project detected. Specify a target: sentry release list <org>/<project>", + hint: `No project detected. Specify a target: ${asInvocationCommand("sentry release list <org>/<project>")}`, }; } diff --git a/packages/cli/src/commands/release/parse.ts b/packages/cli/src/commands/release/parse.ts index 5788ad2d9..58a9f8506 100644 --- a/packages/cli/src/commands/release/parse.ts +++ b/packages/cli/src/commands/release/parse.ts @@ -6,6 +6,7 @@ * 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 { @@ -55,7 +56,7 @@ export function parseReleaseArg( if (!arg) { throw new ValidationError( - `Release version is required.\n\n Usage: ${usageHint}`, + `Release version is required.\n\n Usage: ${asInvocationCommand(usageHint)}`, "version", ); } diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index 95e5c278f..54f50432c 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -14,6 +14,7 @@ import { setCommitsLocal, setCommitsWithRefs, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { parseFilePath, @@ -70,8 +71,12 @@ function setCommitsFromLocal( throw validationError( `Cannot read local commits: ${cwd} is not a git checkout.`, [ - `sentry release set-commits ${org}/${version} --commit owner/repo@<commit-sha>`, - `sentry release set-commits ${org}/${version} --auto`, + asInvocationCommand( + `sentry release set-commits ${org}/${version} --commit owner/repo@<commit-sha>`, + ), + asInvocationCommand( + `sentry release set-commits ${org}/${version} --auto`, + ), ], "local", "--local reads git history, so it needs the .git directory. Builds that exclude it (Docker, Vercel) can pass the commit with --commit.", @@ -243,8 +248,12 @@ function parseCommitRefs( throw validationError( `Invalid commit format '${trimmed}'. Expected REPO@SHA or REPO@PREV..SHA.`, [ - "sentry release set-commits 1.0.0 --commit owner/repo@abc123", - "sentry release set-commits 1.0.0 --commit owner/repo@prev..abc123", + asInvocationCommand( + "sentry release set-commits 1.0.0 --commit owner/repo@abc123", + ), + asInvocationCommand( + "sentry release set-commits 1.0.0 --commit owner/repo@prev..abc123", + ), ], "commit", ); @@ -294,7 +303,11 @@ function parseLocalScope(flags: { if (flags.path !== undefined && paths.length === 0) { throw validationError( "--path requires at least one non-empty path.", - ["sentry release set-commits 1.0.0 --path apps/mobile,packages/shared"], + [ + asInvocationCommand( + "sentry release set-commits 1.0.0 --path apps/mobile,packages/shared", + ), + ], "path", ); } @@ -303,12 +316,20 @@ function parseLocalScope(flags: { "--path cannot be combined with --auto or --commit. Those modes expand commit ranges on the server; --path filters local git history only.", flags.commit ? [ - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - "sentry release set-commits 1.0.0 --local --path apps/mobile", + asInvocationCommand( + "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + ), + asInvocationCommand( + "sentry release set-commits 1.0.0 --local --path apps/mobile", + ), ] : [ - "sentry release set-commits 1.0.0 --local --path apps/mobile", - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + asInvocationCommand( + "sentry release set-commits 1.0.0 --local --path apps/mobile", + ), + asInvocationCommand( + "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + ), ], "path", ); @@ -318,7 +339,7 @@ function parseLocalScope(flags: { if (flags.from !== undefined && !from) { throw validationError( "--from requires a non-empty git ref (tag, branch, or commit).", - ["sentry release set-commits 1.0.0 --from v0.9.0"], + [asInvocationCommand("sentry release set-commits 1.0.0 --from v0.9.0")], "from", FROM_RANGE_NOTE, ); @@ -330,8 +351,10 @@ function parseLocalScope(flags: { throw validationError( `--from must be a git ref, not a CLI flag (received '${from}').`, [ - "sentry release set-commits 1.0.0 --from v0.9.0", - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + asInvocationCommand("sentry release set-commits 1.0.0 --from v0.9.0"), + asInvocationCommand( + "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + ), ], "from", FROM_DASHED_REF_NOTE, @@ -341,8 +364,12 @@ function parseLocalScope(flags: { throw validationError( "--from cannot be combined with --auto or --commit. Those modes expand commit ranges on the server; --from reads local git history.", [ - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - "sentry release set-commits 1.0.0 --local --initial-depth 50", + asInvocationCommand( + "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + ), + asInvocationCommand( + "sentry release set-commits 1.0.0 --local --initial-depth 50", + ), ], "from", FROM_RANGE_NOTE, @@ -508,9 +535,13 @@ export const setCommitsCommand = buildCommand({ throw validationError( "Only one of --auto, --local, or --commit can be used at a time.", [ - "sentry release set-commits 1.0.0 --auto", - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - "sentry release set-commits 1.0.0 --commit owner/repo@abc123..def456", + asInvocationCommand("sentry release set-commits 1.0.0 --auto"), + asInvocationCommand( + "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", + ), + asInvocationCommand( + "sentry release set-commits 1.0.0 --commit owner/repo@abc123..def456", + ), ], "commit", ); diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index a6228fb80..2259ca657 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -12,6 +12,7 @@ import { getReplayRecordingSegments, resolveReplay, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath } from "../../lib/completion-target.js"; import { ApiError, ResolutionError } from "../../lib/errors.js"; @@ -118,7 +119,9 @@ export const downloadCommand = buildCommand({ throw new ResolutionError( `Replay '${replayId}'`, "not found", - `sentry replay download ${resolved.org}/${replayId}`, + asInvocationCommand( + `sentry replay download ${resolved.org}/${replayId}`, + ), [ "Check that you are querying the right organization", "The replay may be past your retention window", @@ -179,7 +182,7 @@ function noRecordingError(org: string, replayId: string): ResolutionError { return new ResolutionError( `Replay '${replayId}'`, "has no recording to download", - `sentry replay view ${org}/${replayId}`, + asInvocationCommand(`sentry replay view ${org}/${replayId}`), ["The replay may be archived or past your retention window"], ); } diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 5a5d0e079..2f6b79f76 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -11,6 +11,7 @@ import { listReplays, type ReplaySortValue, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, @@ -200,7 +201,9 @@ function nextPageHint( flags: Pick<ListFlags, "environment" | "query" | "sort" | "period">, ): string { return appendReplayFlags( - `sentry replay list ${formatScope(org, project)} -c next`, + asInvocationCommand( + `sentry replay list ${formatScope(org, project)} -c next`, + ), flags, ); } @@ -211,7 +214,9 @@ function prevPageHint( flags: Pick<ListFlags, "environment" | "query" | "sort" | "period">, ): string { return appendReplayFlags( - `sentry replay list ${formatScope(org, project)} -c prev`, + asInvocationCommand( + `sentry replay list ${formatScope(org, project)} -c prev`, + ), flags, ); } @@ -426,7 +431,7 @@ export const listCommand = buildListCommand("replay", { const countText = `Showing ${replays.length} replay${replays.length === 1 ? "" : "s"}.`; const firstReplay = replays[0]; const replayHint = firstReplay - ? `Use 'sentry replay view ${resolved.org}/${firstReplay.id}' for details.` + ? `Use '${asInvocationCommand(`sentry replay view ${resolved.org}/${firstReplay.id}`)}' for details.` : undefined; hint = nav ? `${countText} ${nav}` diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index e8399d039..bc686ec10 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -12,6 +12,7 @@ import { listIssuesPaginated, resolveReplay, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -182,9 +183,11 @@ export async function validateReplayProjectScope( throw new ResolutionError( `Replay '${replayId}'`, "has no project association", - `sentry replay ${command} ${org}/${project}/${replayId}`, + asInvocationCommand( + `sentry replay ${command} ${org}/${project}/${replayId}`, + ), [ - `Use the org-scoped replay instead: sentry replay ${command} ${org}/${replayId}`, + `Use the org-scoped replay instead: ${asInvocationCommand(`sentry replay ${command} ${org}/${replayId}`)}`, ], ); } @@ -194,9 +197,11 @@ export async function validateReplayProjectScope( throw new ResolutionError( `Replay '${replayId}'`, `is not in project '${project}'`, - `sentry replay ${command} ${org}/${project}/${replayId}`, + asInvocationCommand( + `sentry replay ${command} ${org}/${project}/${replayId}`, + ), [ - `Use the org-scoped replay instead: sentry replay ${command} ${org}/${replayId}`, + `Use the org-scoped replay instead: ${asInvocationCommand(`sentry replay ${command} ${org}/${replayId}`)}`, ], ); } @@ -390,7 +395,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Replay '${replayId}'`, "not found", - `sentry replay view ${resolved.org}/${replayId}`, + asInvocationCommand(`sentry replay view ${resolved.org}/${replayId}`), [ "Check that you are querying the right organization", "The replay may be past your retention window", diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 68451c102..589f53522 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -20,6 +20,7 @@ import { type SchemaEndpoint, searchEndpoints, } from "../lib/api-schema.js"; +import { asInvocationCommand } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { choiceParser, parseText } from "../lib/completion-target.js"; import { OutputError, ResolutionError } from "../lib/errors.js"; @@ -231,13 +232,22 @@ function jsonTransformSchema(data: SchemaResult, fields?: string[]): unknown { function noResourceMatchError(resource: string): ResolutionError { const names = getResourceSummaries().map((r) => r.name); const [closest] = fuzzyMatch(resource, names, { maxResults: 1 }); - const browseHint = "sentry schema"; - const searchHint = `sentry schema --search ${resource} Search endpoints by keyword`; + const browseHint = asInvocationCommand("sentry schema"); + const searchHint = asInvocationCommand( + `sentry schema --search ${resource} Search endpoints by keyword`, + ); // Lead with the closest resource name when a typo has an obvious fix // (e.g. "committers" → "commits"); otherwise point at the full browse. - const primaryHint = closest ? `sentry schema ${closest}` : browseHint; + const primaryHint = closest + ? asInvocationCommand(`sentry schema ${closest}`) + : browseHint; const suggestions = closest - ? ["sentry schema Browse all resources", searchHint] + ? [ + asInvocationCommand( + "sentry schema Browse all resources", + ), + searchHint, + ] : [searchHint]; return new ResolutionError( `Resource "${resource}"`, @@ -290,8 +300,10 @@ function pathNotFoundError( `Endpoint '${label}'`, "does not exist in the schema", example - ? `sentry schema "${example.method} ${example.path}"` - : "sentry schema", + ? asInvocationCommand( + `sentry schema "${example.method} ${example.path}"`, + ) + : asInvocationCommand("sentry schema"), [`Available methods at this path: ${methods}`], ); } @@ -299,16 +311,22 @@ function pathNotFoundError( const segment = lastConcreteSegment(query.path); const resourceExists = segment !== undefined && getEndpointsByResource(segment).length > 0; - let hint = "sentry schema"; + let hint = asInvocationCommand("sentry schema"); if (resourceExists) { - hint = `sentry schema ${segment}`; + hint = asInvocationCommand(`sentry schema ${segment}`); } else if (segment) { - hint = `sentry schema --search ${segment}`; + hint = asInvocationCommand(`sentry schema --search ${segment}`); } - const suggestions = ["sentry schema Browse all resources"]; + const suggestions = [ + asInvocationCommand( + "sentry schema Browse all resources", + ), + ]; if (resourceExists && segment) { suggestions.unshift( - `sentry schema --search ${segment} Search endpoints by keyword`, + asInvocationCommand( + `sentry schema --search ${segment} Search endpoints by keyword`, + ), ); } throw new ResolutionError( @@ -364,8 +382,12 @@ function resolveGlobQuery(resource: string): SchemaResult { throw new ResolutionError( `Pattern "${resource}"`, "matched no resources", - "sentry schema", - ["sentry schema --all List every endpoint in a flat table"], + asInvocationCommand("sentry schema"), + [ + asInvocationCommand( + "sentry schema --all List every endpoint in a flat table", + ), + ], ); } const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); diff --git a/packages/cli/src/commands/send-envelope.ts b/packages/cli/src/commands/send-envelope.ts index 97e09b29f..1b4d60da8 100644 --- a/packages/cli/src/commands/send-envelope.ts +++ b/packages/cli/src/commands/send-envelope.ts @@ -6,6 +6,7 @@ */ import type { SentryContext } from "../context.js"; +import { asInvocationCommand } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { parseFilePath, parseText } from "../lib/completion-target.js"; import { CliError, EXIT } from "../lib/errors.js"; @@ -54,8 +55,8 @@ export const sendEnvelopeCommand = buildCommand({ ) { const fileArgs = files.length > 0 ? ` ${files.join(" ")}` : " <file>"; throw new CliError( - "`sentry send-envelope` has been removed.\n" + - `Use: sentry event send --raw${fileArgs}`, + `\`${asInvocationCommand("sentry send-envelope")}\` has been removed.\n` + + `Use: ${asInvocationCommand(`sentry event send --raw${fileArgs}`)}`, EXIT.GENERAL, ); }, diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index 9705e2492..989bf310e 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -6,6 +6,7 @@ */ import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceListParser, @@ -196,7 +197,7 @@ export const injectCommand = buildCommand({ if (modified > 0) { return { - hint: "Run `sentry sourcemap upload` to upload the injected files to Sentry", + hint: `Run \`${asInvocationCommand("sentry sourcemap upload")}\` to upload the injected files to Sentry`, }; } return {}; diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index 4f1599713..9765248fb 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -10,6 +10,7 @@ import { tableCell } from "@sentry/toolkit-core/markdown"; import { relative, resolve as resolvePath } from "node:path"; import type { SentryContext } from "../../context.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceListParser, @@ -236,7 +237,7 @@ export const resolveCommand = buildCommand({ } if (withDebugId < files.length) { return { - hint: "Run `sentry sourcemap inject` to add debug IDs to files missing them.", + hint: `Run \`${asInvocationCommand("sentry sourcemap inject")}\` to add debug IDs to files missing them.`, }; } return {}; diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index c9c5aed5a..4f6d57b6c 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -14,6 +14,7 @@ import type { SentryContext } from "../../context.js"; import type { SpanSortValue } from "../../lib/api/traces.js"; import { listSpans } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -293,7 +294,9 @@ function traceNextPageHint( flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { return appendFlagHints( - `sentry span list ${org}/${project}/${traceId} -c next`, + asInvocationCommand( + `sentry span list ${org}/${project}/${traceId} -c next`, + ), flags, ); } @@ -306,7 +309,9 @@ function tracePrevPageHint( flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { return appendFlagHints( - `sentry span list ${org}/${project}/${traceId} -c prev`, + asInvocationCommand( + `sentry span list ${org}/${project}/${traceId} -c prev`, + ), flags, ); } @@ -317,7 +322,10 @@ function projectNextPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendFlagHints(`sentry span list ${org}/${project} -c next`, flags); + return appendFlagHints( + asInvocationCommand(`sentry span list ${org}/${project} -c next`), + flags, + ); } /** Build the CLI hint for fetching the previous page in project mode. */ @@ -326,7 +334,10 @@ function projectPrevPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendFlagHints(`sentry span list ${org}/${project} -c prev`, flags); + return appendFlagHints( + asInvocationCommand(`sentry span list ${org}/${project} -c prev`), + flags, + ); } // --------------------------------------------------------------------------- @@ -493,7 +504,7 @@ async function handleTraceMode( const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use 'sentry span view ${traceId} <span-id>' to view span details.`; + : `${countText} Use '${asInvocationCommand(`sentry span view ${traceId} <span-id>`)}' to view span details.`; } return { @@ -580,7 +591,7 @@ async function handleProjectMode( const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use 'sentry span view <trace-id> <span-id>' to view span details.`; + : `${countText} Use '${asInvocationCommand("sentry span view <trace-id> <span-id>")}' to view span details.`; } return { diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index 4b2f29c84..73005bf87 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -11,6 +11,7 @@ import { getDetailedTrace, type TraceItemDetail, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; @@ -116,8 +117,8 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { first && ID_KINDS.span.isId(first) ? [ `'${first}' looks like a span ID (16 characters), not a trace ID`, - `Provide the trace ID first: sentry span view <trace-id> ${validateSpanId(first)}`, - `Use 'sentry trace list' to find trace IDs`, + `Provide the trace ID first: ${asInvocationCommand(`sentry span view <trace-id> ${validateSpanId(first)}`)}`, + `Use '${asInvocationCommand("sentry trace list")}' to find trace IDs`, ] : [], ); @@ -125,7 +126,7 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { if (first === undefined) { throw new ContextError("Span ID", USAGE_HINT, [ - `Use 'sentry span list ${target}' to find span IDs within this trace`, + `Use '${asInvocationCommand(`sentry span list ${target}`)}' to find span IDs within this trace`, ]); } @@ -150,7 +151,7 @@ function parseTrace(traceArg: string, spanId: string): TraceArgumentsInput { } throw new ContextError("Trace ID", USAGE_HINT, [ `'${traceArg}' is missing a trace ID before the span ID '${spanId}'`, - `Provide the trace ID: sentry span view ${traceArg}/<trace-id> ${spanId}`, + `Provide the trace ID: ${asInvocationCommand(`sentry span view ${traceArg}/<trace-id> ${spanId}`)}`, ]); } } @@ -199,7 +200,9 @@ async function validateAndRecoverSpanId( const result = await recoverHexId(rawSpanId, "span", ctx); return handleRecoveryResult(result, err, { entityType: "span", - canonicalCommand: `sentry span view ${ctx.org}/${ctx.project}/${ctx.traceId} <id>`, + canonicalCommand: asInvocationCommand( + `sentry span view ${ctx.org}/${ctx.project}/${ctx.traceId} <id>`, + ), logTag: "span.view", }); } diff --git a/packages/cli/src/commands/status/show.ts b/packages/cli/src/commands/status/show.ts index de5adbf5c..cd1e53d8d 100644 --- a/packages/cli/src/commands/status/show.ts +++ b/packages/cli/src/commands/status/show.ts @@ -11,6 +11,7 @@ import { fetchSentryStatus, SENTRY_STATUS_PAGE_URL, } from "../../lib/api/status-page.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseUrl } from "../../lib/completion-target.js"; import { formatSentryStatus } from "../../lib/formatters/human.js"; @@ -66,7 +67,7 @@ export const showCommand = buildCommand({ if (status.indicator !== "none") { return { - hint: `Run \`sentry status\` again to refresh, or open ${status.url}`, + hint: `Run \`${asInvocationCommand("sentry status")}\` again to refresh, or open ${status.url}`, }; } }, diff --git a/packages/cli/src/commands/token/delete.ts b/packages/cli/src/commands/token/delete.ts index 6760df266..343d4fc65 100644 --- a/packages/cli/src/commands/token/delete.ts +++ b/packages/cli/src/commands/token/delete.ts @@ -10,6 +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 } 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"; @@ -108,7 +109,7 @@ export function parseTokenArguments(args: readonly string[]): TokenInput { if (args.some((arg) => TOKEN_VALUE_PATTERN.test(arg))) { throw new ValidationError( "That looks like a token value, not a token ID or name. Use the ID " + - "or name from 'sentry token list'.", + `or name from '${asInvocationCommand("sentry token list")}'.`, "token-id", ); } @@ -120,7 +121,7 @@ export function parseTokenArguments(args: readonly string[]): TokenInput { // 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}: ${USAGE_HINT}`, + `Revoke one token at a time, not ${ids.length}: ${asInvocationCommand(USAGE_HINT)}`, "token-id", ); } @@ -167,7 +168,7 @@ async function findToken(org: string, ref: TokenRef): Promise<OrgAuthToken> { throw new ResolutionError( `Org auth token ${label}`, `not found in ${org}`, - `sentry token list ${org}`, + asInvocationCommand(`sentry token list ${org}`), ["It may already be revoked."], ); } @@ -176,7 +177,7 @@ async function findToken(org: string, ref: TokenRef): Promise<OrgAuthToken> { const ids = matches.map((t) => t.id).join(", "); throw new ValidationError( `${matches.length} org auth tokens in ${org} are named '${ref.name}' ` + - `(IDs ${ids}). Pass the ID instead: sentry token delete ${org}/<token-id>`, + `(IDs ${ids}). Pass the ID instead: ${asInvocationCommand(`sentry token delete ${org}/<token-id>`)}`, "token-id", ); } diff --git a/packages/cli/src/commands/token/list.ts b/packages/cli/src/commands/token/list.ts index 8ccfaf5b6..83f4debf6 100644 --- a/packages/cli/src/commands/token/list.ts +++ b/packages/cli/src/commands/token/list.ts @@ -8,6 +8,7 @@ import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { OrgAuthTokenSchema } from "../../generated/output-schemas/token.js"; import { listOrgAuthTokens } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { selectAccountForOrg } from "../../lib/db/auth.js"; import { colorTag } from "../../lib/formatters/markdown.js"; @@ -107,7 +108,7 @@ export const listCommand = buildCommand({ yield new CommandOutput(tokens); if (tokens.length === 0) { return { - hint: `Create one with: sentry token create ${resolved.org}/<name>`, + hint: `Create one with: ${asInvocationCommand(`sentry token create ${resolved.org}/<name>`)}`, }; } }, diff --git a/packages/cli/src/commands/token/shared.ts b/packages/cli/src/commands/token/shared.ts index af6bd3c01..bcae98d9f 100644 --- a/packages/cli/src/commands/token/shared.ts +++ b/packages/cli/src/commands/token/shared.ts @@ -7,6 +7,7 @@ * for it (plus whatever the endpoint needs) on first use. */ +import { asInvocationCommand } from "../../lib/app-identity.js"; import { ApiError, AuthError, ContextError } from "../../lib/errors.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { ensureScopesForAction } from "../../lib/scope-recovery.js"; @@ -94,7 +95,7 @@ export function explainTokenForbidden( const detail = scopeMissing ? `Your saved login doesn't include ${scopes}. Re-authorization needs an interactive terminal and is skipped with --yes, --json, or --dry-run.\n ` + "Run this command once in a terminal without --yes, --json, or --dry-run to re-authorize." - : "Org auth tokens can only be managed with a CLI login from 'sentry auth login'. " + + : `Org auth tokens can only be managed with a CLI login from '${asInvocationCommand("sentry auth login")}'. ` + "Personal tokens, org auth tokens, and SENTRY_AUTH_TOKEN can't manage them.\n " + `The login also needs ${scopes}, and your org role must allow it.`; return new ApiError( diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 38b58a82c..72efe7ddc 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { listTransactions } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, textParser } from "../../lib/completion-target.js"; import { @@ -110,7 +111,10 @@ function nextPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendTraceFlags(`sentry trace list ${org}/${project} -c next`, flags); + return appendTraceFlags( + asInvocationCommand(`sentry trace list ${org}/${project} -c next`), + flags, + ); } /** Build the CLI hint for fetching the previous page, preserving active flags. */ @@ -119,7 +123,10 @@ function prevPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendTraceFlags(`sentry trace list ${org}/${project} -c prev`, flags); + return appendTraceFlags( + asInvocationCommand(`sentry trace list ${org}/${project} -c prev`), + flags, + ); } /** @@ -335,7 +342,7 @@ export const listCommand = buildListCommand("trace", { const countText = `Showing ${traces.length} trace${traces.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use 'sentry trace view <TRACE_ID>' to view the full span tree.`; + : `${countText} Use '${asInvocationCommand("sentry trace view <TRACE_ID>")}' to view the full span tree.`; } yield new CommandOutput({ diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index 8e55e2385..05c0b92db 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -6,6 +6,7 @@ import type { SentryContext } from "../../context.js"; import { type LogSortDirection, listTraceLogs } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildProjectQuery, parseLogSort, @@ -282,7 +283,7 @@ export const logsCommand = buildCommand({ const emptyMessage = `No logs found for trace ${traceId} in the last ${formatTimeRangeFlag(flags.period)}.\n\n` + - `Try a longer period: sentry trace logs --period 30d ${traceId}`; + `Try a longer period: ${asInvocationCommand(`sentry trace logs --period 30d ${traceId}`)}`; yield new CommandOutput({ logs, @@ -294,11 +295,11 @@ export const logsCommand = buildCommand({ // Build hint with real values for easy copy-paste if (projectFilter) { return { - hint: `Filtered to project '${projectFilter}'. Full trace logs: sentry trace logs ${org}/${traceId}`, + hint: `Filtered to project '${projectFilter}'. Full trace logs: ${asInvocationCommand(`sentry trace logs ${org}/${traceId}`)}`, }; } return { - hint: `Filter by project: sentry trace logs ${org}/<project>/${traceId}`, + hint: `Filter by project: ${asInvocationCommand(`sentry trace logs ${org}/<project>/${traceId}`)}`, }; }, }); diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index c0cb654e8..3091e87f5 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -15,6 +15,7 @@ import { getProject, type TraceItemDetail, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { looksLikeIssueShortId, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -79,14 +80,14 @@ function buildViewHint( summary: ReturnType<typeof computeTraceSummary>, ): string { if (projectFilter) { - return `Filtered to project '${projectFilter}'. Full trace: sentry trace view ${org}/${traceId}`; + return `Filtered to project '${projectFilter}'. Full trace: ${asInvocationCommand(`sentry trace view ${org}/${traceId}`)}`; } if (summary.projects.length > 1) { const projectList = summary.projects.join(", "); const firstProject = summary.projects[0]; - return `This trace spans ${summary.projects.length} projects (${projectList}). Filter: sentry trace view ${org}/${firstProject}/${traceId}`; + return `This trace spans ${summary.projects.length} projects (${projectList}). Filter: ${asInvocationCommand(`sentry trace view ${org}/${firstProject}/${traceId}`)}`; } - return `Tip: Open in browser with 'sentry trace view --web ${traceId}'`; + return `Tip: Open in browser with '${asInvocationCommand(`sentry trace view --web ${traceId}`)}'`; } /** @@ -120,7 +121,9 @@ export function parseTraceViewInput(args: readonly string[]): TraceViewInput { } return { trace: parseTraceArgumentsInput(args, USAGE_HINT), - ...(shortId && { suggestion: `Did you mean: sentry issue view ${first}` }), + ...(shortId && { + suggestion: `Did you mean: ${asInvocationCommand(`sentry issue view ${first}`)}`, + }), }; } @@ -404,7 +407,10 @@ async function resolveTraceFromIssue( const resolved = await resolveOrg({ cwd }); if (!resolved) { - throw new ContextError("Organization", `sentry issue view ${issueShortId}`); + throw new ContextError( + "Organization", + asInvocationCommand(`sentry issue view ${issueShortId}`), + ); } const org = resolved.org; @@ -420,7 +426,7 @@ async function resolveTraceFromIssue( if (!traceId) { throw new ValidationError( `Could not find a trace for issue '${issueShortId}'. The latest event has no trace context.\n\n` + - `Try: sentry issue view ${issueShortId}`, + `Try: ${asInvocationCommand(`sentry issue view ${issueShortId}`)}`, ); } @@ -449,7 +455,7 @@ function fetchTraceSpanDetails( if (totalCount > LARGE_TRACE_WARN_THRESHOLD) { log.warn( `Trace has ${totalCount} spans \u2014 this may take a moment. ` + - "Use 'sentry span view' for specific spans.", + `Use '${asInvocationCommand("sentry span view")}' for specific spans.`, ); } @@ -588,7 +594,9 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Trace '${traceId}'`, "not found", - `sentry trace view ${org}/${project ?? "<project>"}/${traceId}`, + asInvocationCommand( + `sentry trace view ${org}/${project ?? "<project>"}/${traceId}`, + ), [ "Check that you are querying the right org/project", "The trace may be past your plan's retention window", diff --git a/packages/cli/src/commands/trial/list.ts b/packages/cli/src/commands/trial/list.ts index dcc985903..ad68818f8 100644 --- a/packages/cli/src/commands/trial/list.ts +++ b/packages/cli/src/commands/trial/list.ts @@ -8,6 +8,7 @@ import type { SentryContext } from "../../context.js"; import { getCustomerTrialInfo } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -245,11 +246,13 @@ export const listCommand = buildCommand({ (e) => e.status === "available" && e.category !== "plan", ); if (hasAvailableProduct) { - hints.push("Tip: Use 'sentry trial start <name>' to start a trial"); + hints.push( + `Tip: Use '${asInvocationCommand("sentry trial start <name>")}' to start a trial`, + ); } if (planEntry?.status === "available") { hints.push( - "Tip: Use 'sentry trial start plan' to start a Business plan trial", + `Tip: Use '${asInvocationCommand("sentry trial start plan")}' to start a Business plan trial`, ); } diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 951032158..d62ff043e 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -17,6 +17,7 @@ import { getProductTrials, startProductTrial, } from "../../lib/api-client.js"; +import { asInvocationCommand } from "../../lib/app-identity.js"; import { detectSwappedTrialArgs } from "../../lib/arg-parsing.js"; import { openBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -123,7 +124,7 @@ export function parseTrialStartArguments( } if (extra.length > 0) { throw new ValidationError( - `Too many arguments: ${extra.join(" ")}. Expected: ${USAGE_HINT}`, + `Too many arguments: ${extra.join(" ")}. Expected: ${asInvocationCommand(USAGE_HINT)}`, "positional", ); } @@ -193,7 +194,7 @@ export const startCommand = buildCommand({ const { org } = await resolveOrgInput( input.org, context.cwd, - "sentry trial start <name> <org>", + asInvocationCommand("sentry trial start <name> <org>"), ); return { name: input.name, org }; }, diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts index a4673b003..860849d08 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -18,6 +18,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { basename } from "node:path"; import type { SentryContext } from "../context.js"; +import { asInvocationCommand } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { parseFilePath, @@ -169,7 +170,7 @@ export const wasmSplitCommand = buildCommand({ async *func(this: SentryContext, flags: WasmSplitFlags, input: string) { if (!input?.trim()) { throw new ValidationError( - "Wasm file path is required: sentry wasm-split <input>", + `Wasm file path is required: ${asInvocationCommand("sentry wasm-split <input>")}`, "input", ); } diff --git a/packages/cli/src/lib/command-suggestions.ts b/packages/cli/src/lib/command-suggestions.ts index 49c0aeed9..da1bff038 100644 --- a/packages/cli/src/lib/command-suggestions.ts +++ b/packages/cli/src/lib/command-suggestions.ts @@ -27,8 +27,6 @@ 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. */ -// getCommandSuggestion() passes each command through asInvocationCommand(). -/* oxlint-disable sentry-cli/no-hardcoded-sentry-command */ const SUGGESTIONS: ReadonlyMap<string, CommandSuggestion> = new Map([ // --- view synonyms (~15 events) --- ["issue/get", { command: "sentry issue view <issue-id>" }], @@ -96,7 +94,6 @@ const SUGGESTIONS: ReadonlyMap<string, CommandSuggestion> = new Map([ ["issue/search", { command: "sentry issue list --query <search>" }], ["issue/find", { command: "sentry issue list --query <search>" }], ]); -/* oxlint-enable sentry-cli/no-hardcoded-sentry-command */ /** * Look up a suggestion for an unknown command token. diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 5852d5879..15666acb8 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -497,10 +497,12 @@ 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, command, resolvedAlternatives, { + buildContextMessage(resource, usage, resolvedAlternatives, { note, isAutoDetect, }), @@ -508,7 +510,7 @@ export class ContextError extends CliError { ); this.name = "ContextError"; this.resource = resource; - this.command = command; + this.command = usage; this.alternatives = resolvedAlternatives; this.note = note; @@ -563,15 +565,18 @@ 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, hint, suggestions), + buildResolutionMessage(resource, headline, usage, commands), EXIT.RESOLUTION, ); this.name = "ResolutionError"; this.resource = resource; this.headline = headline; - this.hint = hint; - this.suggestions = suggestions; + this.hint = usage; + this.suggestions = commands; } override format(): string { @@ -600,7 +605,7 @@ export function buildValidationMessage( if (examples.length > 0) { lines.push("", "Try:"); for (const example of examples) { - lines.push(` ${example}`); + lines.push(` ${asInvocationCommand(example)}`); } } if (note) { diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index f13c22dec..118d00f22 100644 --- a/packages/cli/src/lib/hex-id-recovery.ts +++ b/packages/cli/src/lib/hex-id-recovery.ts @@ -185,8 +185,6 @@ 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). */ -// buildSlugHint() passes each command through asInvocationCommand(). -/* oxlint-disable sentry-cli/no-hardcoded-sentry-command */ const SLUG_REDIRECT_COMMAND: Record<HexEntityType, string> = { event: "sentry issue list", trace: "sentry trace list", @@ -194,7 +192,6 @@ const SLUG_REDIRECT_COMMAND: Record<HexEntityType, string> = { // Spans are trace-scoped — a slug-only input can't map to a list command. span: "", }; -/* oxlint-enable sentry-cli/no-hardcoded-sentry-command */ // --------------------------------------------------------------------------- // Pure helpers (exported for tests) diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index 128fa9dce..e3d439fd0 100644 --- a/packages/cli/src/lib/id-arguments.ts +++ b/packages/cli/src/lib/id-arguments.ts @@ -213,19 +213,16 @@ const ISSUE_KIND: IdKind & { /** The kinds of ID commands take. Commands use these instead of their own. */ export const ID_KINDS = { - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. 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", - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. "sentry replay list", (url) => (url.org ? url.replayId : undefined), "https://sentry.io/organizations/{org}/explore/replays/{replayId}/", ), - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. log: hexKind("Log ID", "sentry log list", () => undefined), span: searchable( { @@ -235,7 +232,6 @@ export const ID_KINDS = { validate: validateSpanId, fromUrl: () => undefined, }, - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- searchable() builds the hint. "sentry span list", ), // A numeric ID, or a title: `Latency/p95`, `Top 10`, or unquoted words @@ -400,7 +396,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: ${usageHint}`, + `Pass IDs from one project per call: ${asInvocationCommand(usageHint)}`, "positional", ); } @@ -434,7 +430,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: ${usageHint}`, + `Extra arguments ignored: ${extra.map((token) => token.text).join(" ")}. Expected: ${asInvocationCommand(usageHint)}`, ); } @@ -547,7 +543,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: ${usageHint}`, + `Invalid format: "${text}" needs an organization and a project before the ID.\n\nUsage: ${asInvocationCommand(usageHint)}`, "positional", ); } diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index f2bc1e973..c7c11a6b6 100644 --- a/packages/cli/src/lib/org-list.ts +++ b/packages/cli/src/lib/org-list.ts @@ -710,7 +710,7 @@ export async function handleOrgAll<TEntity, TWithOrg>( let hint: string | undefined; let header: string | undefined; - const base = `${config.commandPrefix} ${org}/`; + const base = `${asInvocationCommand(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: ${config.commandPrefix} <org>/`, + `Specify the organization explicitly: ${asInvocationCommand(config.commandPrefix)} <org>/`, ); } if (limited.length > 0) { hintParts.push( - `Tip: Use '${config.commandPrefix} ${orgFilterTarget("<org>", filter)}' to filter by organization`, + `Tip: Use '${asInvocationCommand(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 '${config.commandPrefix} ${orgSlugForHint}/' for paginated results.` + ? ` Use '${asInvocationCommand(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 '${config.commandPrefix} ${org}/' for paginated results`; + const tip = `Tip: Use '${asInvocationCommand(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 '${config.commandPrefix} ${org}/' to see all ${config.entityPlural} in the org`; + const tip = `Tip: Use '${asInvocationCommand(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", - `${config.commandPrefix} ${projectSlug}/`, + `${asInvocationCommand(config.commandPrefix)} ${projectSlug}/`, [ `List projects: ${asInvocationCommand(`sentry project list ${projectSlug}/`)}`, - `Specify a project: ${config.commandPrefix} ${projectSlug}/<project>`, + `Specify a project: ${asInvocationCommand(config.commandPrefix)} ${projectSlug}/<project>`, ], ); } @@ -1074,7 +1074,7 @@ export async function handleProjectSearch<TEntity, TWithOrg>( throw new ResolutionError( `Project '${resolution.displaySlug}'`, "not found", - `${config.commandPrefix} <org>/${projectSlug}`, + `${asInvocationCommand(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 '${config.commandPrefix} ${org}/' to see all ${config.entityPlural}`, + `Tip: Use '${asInvocationCommand(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( - `'${config.commandPrefix}' got a name filter but its config has no namesOf.`, + `'${asInvocationCommand(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 '${config.commandPrefix}'. ` + + `No handler for '${mode}' mode in '${asInvocationCommand(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 '${config.commandPrefix} ${effectiveParsed.projectSlug}/'? ` + + ? `\n\nDid you mean '${asInvocationCommand(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., ${config.commandPrefix} my-org/).` + + `(e.g., ${asInvocationCommand(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 c67b010b7..e28a8a626 100644 --- a/packages/cli/src/lib/resolve-team.ts +++ b/packages/cli/src/lib/resolve-team.ts @@ -22,6 +22,7 @@ import type { SentryTeam } from "../types/index.js"; import { createTeam, listOrganizations, listTeams } from "./api-client.js"; +import { asInvocationCommand } from "./app-identity.js"; import { ApiError, AuthError, @@ -122,7 +123,7 @@ async function handleListTeamsError( throw new ResolutionError( `Organization '${orgSlug}'`, `could not be accessed (${error.status})`, - `${options.usageHint} --team <team-slug>`, + `${asInvocationCommand(options.usageHint)} --team <team-slug>`, ["The organization may not exist, or you may lack access"], ); } @@ -208,10 +209,11 @@ function resolveEmptyTeams( ): Promise<ResolvedTeam> | ResolvedTeam { if (!options.autoCreateSlug) { const teamsUrl = `${getSentryBaseUrl()}/settings/${orgSlug}/teams/`; - throw new ContextError("Team", `${options.usageHint} --team <team-slug>`, [ - `No teams found in org '${orgSlug}'`, - `Create a team at ${teamsUrl}`, - ]); + throw new ContextError( + "Team", + `${asInvocationCommand(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" }; @@ -278,7 +280,7 @@ export async function buildOrgNotFoundError( } const orgHint = await fetchOrgListHint( - `Specify org explicitly: ${usageHint}`, + `Specify org explicitly: ${asInvocationCommand(usageHint)}`, ); const suggestions: string[] = []; diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index c59f9bd96..fb59b4580 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -23,6 +23,7 @@ * `func` runs. */ +import { asInvocationCommand } from "./app-identity.js"; import { firstNonBlankLine, type ListTarget, @@ -170,7 +171,9 @@ export function parseOrgInput( } const org = text.endsWith("/") ? text.slice(0, -1) : text; if (!org || org.includes("/")) { - const usage = usageHint ? `\n\nUsage: ${usageHint}` : ""; + const usage = usageHint + ? `\n\nUsage: ${asInvocationCommand(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 1c210dc06..a088f560b 100644 --- a/packages/cli/src/lib/trace-target.ts +++ b/packages/cli/src/lib/trace-target.ts @@ -423,7 +423,7 @@ export async function resolveTraceOrgProject( case "org-scoped": throw new ContextError("Specific project", usageHint, [ - `Use: ${usageHint.replace(USAGE_TARGET_RE, `${parsed.org}/<project>/${parsed.traceId}`)}`, + `Use: ${asInvocationCommand(usageHint.replace(USAGE_TARGET_RE, `${parsed.org}/<project>/${parsed.traceId}`))}`, `List projects: ${asInvocationCommand(`sentry project list ${parsed.org}/`)}`, ]); diff --git a/packages/cli/test/lib/app-identity.test.ts b/packages/cli/test/lib/app-identity.test.ts index 28a7f1237..04dce3ce3 100644 --- a/packages/cli/test/lib/app-identity.test.ts +++ b/packages/cli/test/lib/app-identity.test.ts @@ -6,7 +6,11 @@ import { getUrlLoginCommand, setAppIdentity, } from "../../src/lib/app-identity.js"; -import { AuthError } from "../../src/lib/errors.js"; +import { + AuthError, + ContextError, + ResolutionError, +} from "../../src/lib/errors.js"; import { useEnvSandbox } from "../helpers.js"; useEnvSandbox(["npm_command"]); @@ -65,4 +69,22 @@ describe("app identity", () => { "npx -y @sentry/mcp-server cli fix", ); }); + + test("error usage lines name the running program under npx", () => { + 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>/"], + ); + expect(resolution.hint).toBe("npx -y sentry project view <org>/web"); + expect(resolution.suggestions).toEqual([ + "npx -y sentry project list <org>/", + ]); + }); }); diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index 3d6ca31ef..ecc8303a3 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -546,8 +546,6 @@ tester.run( }, ); -const authCommand = "/repo/packages/cli/src/commands/auth/example.ts"; - tester.run( "no-hardcoded-sentry-command", plugin.rules["no-hardcoded-sentry-command"], @@ -562,17 +560,21 @@ tester.run( code: "function hint(org) { return asInvocationCommand(`sentry project list ${org}/`); }", }, { - filename: authCommand, + filename: commands, code: "buildCommand({ docs: { fullDescription: 'Run `sentry auth status` to check.', examples: [{ command: 'sentry auth login' }] } });", }, { filename: source, code: "const message = 'Sentry CLI could not reach sentry.io';", }, - // Commands that @sentry/mcp-server does not ship only run as `sentry`. + // A module-level usage line is data: ContextError and friends wrap it. { filename: commands, - code: "function hint() { return 'Run sentry issue list'; }", + code: "const USAGE_HINT = 'sentry issue list <org>/<project>';", + }, + { + filename: commands, + code: "const docs = { examples: [{ command: 'sentry issue list' }] };", }, ], invalid: [ @@ -582,13 +584,19 @@ tester.run( errors: 1, }, { - filename: authCommand, + filename: commands, code: "function hint(org) { return `Try: sentry project list ${org}/`; }", errors: 1, }, + // Every command runs under npx or another name, not only sentry-mcp. { - filename: "/repo/packages/cli/src/commands/cli/fix.ts", - code: "log.warn('Run sentry cli fix');", + filename: commands, + code: "function hint() { return 'Run sentry issue list'; }", + errors: 1, + }, + { + filename: commands, + code: "const MESSAGE = 'Usage: sentry local run -- <command>';", errors: 1, }, // At module load the running program is not known yet. From 08417bbe7e6e0969840614e068b59080cf4a7450 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:28:58 +0000 Subject: [PATCH 3/4] fix(cli): Lint interpolated and placeholder sentry commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The no-hardcoded-sentry-command rule now treats each template interpolation as a word and matches a <command> placeholder, so `sentry ${commandName} …` and `sentry <command> …` hints are caught. The new hits in resolve-target, target-positional, the DSN errors and the --help tips now go through asInvocationCommand(). The help listing names the running program. Command paths and the uninstall labels that name the binary keep `sentry` behind disable comments. Refs #2054 Refs #2050 --- packages/cli/lint-rules/cli-oxlint-plugin.js | 12 +++++--- packages/cli/src/cli.ts | 3 +- packages/cli/src/commands/attribute/list.ts | 2 +- packages/cli/src/commands/cli/setup.ts | 4 ++- packages/cli/src/commands/cli/uninstall.ts | 9 +++--- packages/cli/src/commands/issue/merge.ts | 2 +- packages/cli/src/lib/command.ts | 4 +-- packages/cli/src/lib/dsn/errors.ts | 6 ++-- packages/cli/src/lib/dsn/resolver.ts | 3 +- packages/cli/src/lib/help.ts | 9 ++++-- packages/cli/src/lib/introspect.ts | 3 ++ packages/cli/src/lib/list-command.ts | 3 +- packages/cli/src/lib/resolve-target.ts | 32 ++++++++++++++------ packages/cli/src/lib/shell.ts | 1 + packages/cli/src/lib/target-positional.ts | 4 +-- scripts/cli-lint-rules.test.mjs | 11 +++++++ 16 files changed, 73 insertions(+), 35 deletions(-) diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js index 0480094a5..016c89545 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -358,11 +358,14 @@ function untaggedParser(value) { return undefined; } -/** A `sentry <command>` hint: the CLI binary name, then a subcommand. */ -const SENTRY_COMMAND_RE = /(?:^|[\s`'"(])sentry [a-z]/; +/** + * A `sentry <command>` hint: the CLI binary name, then a subcommand or a + * `<placeholder>` for one. + */ +const SENTRY_COMMAND_RE = /(?:^|[\s`'"(])sentry [a-z<]/; /** A whole `sentry <command>`, such as a usage line or a command prefix. */ -const WHOLE_SENTRY_COMMAND_RE = /^sentry [a-z]/; +const WHOLE_SENTRY_COMMAND_RE = /^sentry [a-z<]/; /** Calls that turn a `sentry …` command into the running program's command. */ const INVOCATION_HELPERS = new Set([ @@ -882,9 +885,10 @@ const rules = { if (typeof node.value === "string") check(node, node.value); }, TemplateLiteral(node) { + // Each interpolation stands in for a word, so `sentry ${cmd}` counts. check( node, - node.quasis.map((quasi) => quasi.value.cooked ?? "").join(""), + node.quasis.map((quasi) => quasi.value.cooked ?? "").join("x"), ); }, }; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 57e27562b..ffdd3389c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,6 +14,7 @@ * stream error handlers and calls `startCli()`. */ +import { asInvocationCommand } from "./lib/app-identity.js"; import type { CliApplication } from "./lib/application.js"; import { redactCredentialText } from "./lib/credential-redaction.js"; import { CliError, formatError } from "./lib/errors.js"; @@ -157,7 +158,7 @@ async function recoverUnknownCommandHelp( const { warning } = await import("./lib/formatters/colors.js"); process.stderr.write( warning( - `Tip: use --help for help (e.g., sentry ${groupArgs.join(" ")} --help)\n`, + `Tip: use --help for help (e.g., ${asInvocationCommand(`sentry ${groupArgs.join(" ")} --help`)})\n`, ), ); await executor(["help", ...groupArgs]); diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index e099595e8..daee681f5 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -135,7 +135,7 @@ export function buildUsageTip( } const target = project ? `${org}/${project}` : `${org}/<project>`; const command = dataset === "logs" ? "log list" : "span list"; - return `Tip: Filter or add to columns with: sentry ${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`; + return `Tip: Filter or add to columns with: ${asInvocationCommand(`sentry ${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`)}`; } /** diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index 99f815ff7..df0b6eb10 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -488,7 +488,9 @@ function printWelcomeMessage( emit( ` ${asInvocationCommand("sentry mcp")} Run the Sentry MCP server for your AI agent`, ); - emit(" sentry --help See all available commands"); + emit( + ` ${asInvocationCommand("sentry --help")} See all available commands`, + ); emit(""); emit("https://cli.sentry.dev"); } diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index 7d97cc435..b965f6504 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -28,7 +28,6 @@ import { homedir } from "node:os"; import { basename, dirname } from "node:path"; import type { SentryContext } from "../../context.js"; import { getSkillInstallPath } from "../../lib/agent-skills.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; import { isHomebrewInstall } from "../../lib/binary.js"; import { getCompletionPath } from "../../lib/completions.js"; import { @@ -297,9 +296,8 @@ function gatherArtifacts(home: string): UninstallArtifact[] { // self-deletion and tell the user to remove it manually. if (process.platform === "win32") { artifacts.push({ - label: asInvocationCommand( - "sentry binary (manual removal needed on Windows)", - ), + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. + label: "sentry binary (manual removal needed on Windows)", path: binaryPath, exists: true, remove: () => @@ -311,7 +309,8 @@ function gatherArtifacts(home: string): UninstallArtifact[] { }); } else { artifacts.push({ - label: asInvocationCommand("sentry binary"), + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. + label: "sentry binary", path: binaryPath, exists: true, remove: async () => { diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 56d357ff4..e9fcf6d24 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -341,7 +341,7 @@ export const mergeCommand = buildCommand({ ? `Tip: you can also write: ${asInvocationCommand("sentry issue merge CLI-K9 --into CLI-15H")}` : `Example: ${asInvocationCommand("sentry issue merge CLI-K9 CLI-15H CLI-15N")}`; throw new ValidationError( - `'sentry ${COMMAND_PATH}' needs at least 2 issue IDs (got ${args.length}).\n\n` + + `'${asInvocationCommand(`sentry ${COMMAND_PATH}`)}' needs at least 2 issue IDs (got ${args.length}).\n\n` + hint, ); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 1edb64535..cef2999ef 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 { asRunningAppCommand } from "./app-identity.js"; +import { asInvocationCommand, asRunningAppCommand } from "./app-identity.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { @@ -744,7 +744,7 @@ export function buildCommand< } ctx.stderr.write( warning( - `Tip: use --help for help (e.g., sentry ${pathSegments.join(" ")} --help)\n\n`, + `Tip: use --help for help (e.g., ${asInvocationCommand(`sentry ${pathSegments.join(" ")} --help`)})\n\n`, ), ); stdout.write(`${formatHelpHuman(result)}\n`); diff --git a/packages/cli/src/lib/dsn/errors.ts b/packages/cli/src/lib/dsn/errors.ts index d9eb205d8..cdb0d0274 100644 --- a/packages/cli/src/lib/dsn/errors.ts +++ b/packages/cli/src/lib/dsn/errors.ts @@ -32,7 +32,7 @@ export function formatConflictError(result: DsnDetectionResult): string { }); lines.push("To resolve, specify which project to use:"); - lines.push(" sentry <command> <org>/<project>"); + lines.push(` ${asInvocationCommand("sentry <command> <org>/<project>")}`); lines.push(""); lines.push("Or set a default project:"); lines.push( @@ -97,7 +97,7 @@ export async function formatNoDsnError( ); lines.push(""); lines.push("3. Specify project explicitly:"); - lines.push(" sentry <command> <org>/<project>"); + lines.push(` ${asInvocationCommand("sentry <command> <org>/<project>")}`); lines.push(""); lines.push("4. Set default project:"); lines.push( @@ -129,7 +129,7 @@ export function formatResolutionError(error: Error, dsnRaw: string): string { " - The DSN is invalid or expired", "", "Try specifying the project explicitly:", - " sentry <command> <org>/<project>", + ` ${asInvocationCommand("sentry <command> <org>/<project>")}`, ]; return lines.join("\n"); diff --git a/packages/cli/src/lib/dsn/resolver.ts b/packages/cli/src/lib/dsn/resolver.ts index ae8645822..0e30fc2d0 100644 --- a/packages/cli/src/lib/dsn/resolver.ts +++ b/packages/cli/src/lib/dsn/resolver.ts @@ -11,6 +11,7 @@ import { listProjects, resolveOrgDisplayName, } from "../api-client.js"; +import { asInvocationCommand } from "../app-identity.js"; import { getCachedDsn, updateCachedResolution } from "../db/dsn-cache.js"; import { getDsnSourceDescription } from "./detector.js"; import type { @@ -60,7 +61,7 @@ export async function resolveProject( if (!project?.organization) { throw new Error( "Cannot resolve project: DSN could not be matched to any accessible project. " + - "You may not have access, or specify the target explicitly: sentry <command> <org>/<project>", + `You may not have access, or specify the target explicitly: ${asInvocationCommand("sentry <command> <org>/<project>")}`, ); } diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index bf3a3717b..4c4733725 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -137,7 +137,9 @@ function toHelpCommand( .filter((sub: RouteMapEntry) => !sub.hidden) .map((sub: RouteMapEntry) => sub.name.original); return { - usage: `sentry ${routeName} ${pickSubcommands(routeName, subNames).join(" | ")}`, + usage: asRunningAppCommand( + `sentry ${routeName} ${pickSubcommands(routeName, subNames).join(" | ")}`, + ), description: brief, }; } @@ -149,13 +151,13 @@ function toHelpCommand( getPositionalString(entry.target.parameters.positional); const usageSuffix = placeholder ? ` ${placeholder}` : ""; return { - usage: `sentry ${routeName}${usageSuffix}`, + usage: asRunningAppCommand(`sentry ${routeName}${usageSuffix}`), description: brief, }; } return { - usage: `sentry ${routeName}`, + usage: asRunningAppCommand(`sentry ${routeName}`), description: brief, }; } @@ -873,6 +875,7 @@ function formatGroupHuman(group: RouteInfo): string { // Strip "sentry <group> " prefix to get subcommand name. // For nested routes like "sentry dashboard widget add", this yields "widget add". + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- matches command paths, not a hint. const prefix = `sentry ${group.name} `; const subName = (cmd: CommandInfo) => cmd.path.startsWith(prefix) diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 26711cf11..6ac31b720 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -350,6 +350,7 @@ export function extractRouteGroupCommands( const subTarget = subEntry.target; if (isCommand(subTarget)) { + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- a command path, not a hint. const path = `sentry ${routeName} ${subEntry.name.original}`; commands.push(buildCommandInfo(subTarget, path)); } else if (isRouteMap(subTarget)) { @@ -385,6 +386,7 @@ export function extractAllRoutes(routeMap: RouteMap): RouteInfo[] { commands: extractRouteGroupCommands(target, routeName), }); } else if (isCommand(target)) { + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- a command path, not a hint. const path = `sentry ${routeName}`; result.push({ name: routeName, @@ -696,6 +698,7 @@ export function resolveCommandPath( if (isCommand(target)) { return { kind: "command", + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- a command path, not a hint. info: buildCommandInfo(target, `sentry ${name}`), }; } diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index 758c4a75f..a419522b8 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -16,6 +16,7 @@ import type { Aliases, Command, CommandContext } from "@stricli/core"; import type { GenericSchema } from "valibot"; import type { SentryContext } from "../context.js"; +import { asInvocationCommand } from "./app-identity.js"; import { type ListTargetOptions, parseCursorFlag } from "./arg-parsing.js"; import { buildCommand, @@ -474,7 +475,7 @@ export function interceptSubcommand( } stderr.write( warning( - `Tip: "${target.trim()}" is a subcommand. Running: sentry ${routeName} ${target.trim()}\n`, + `Tip: "${target.trim()}" is a subcommand. Running: ${asInvocationCommand(`sentry ${routeName} ${target.trim()}`)}\n`, ), ); return; diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 70c0fa537..9b38b23e3 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -1447,7 +1447,8 @@ export async function resolveAllTargets( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? "sentry <command> <org>/<project>", + options.usageHint ?? + asInvocationCommand("sentry <command> <org>/<project>"), [], ); } @@ -1666,7 +1667,8 @@ export async function resolveOrgAndProject( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? "sentry <command> <org>/<project>", + options.usageHint ?? + asInvocationCommand("sentry <command> <org>/<project>"), [], ); } @@ -2096,7 +2098,9 @@ export async function guideOrgProjectFailure( > & Partial<Pick<ResolveOptions, "cwd">>, ): Promise<ResolvedTarget> { - const usageHint = options.usageHint ?? "sentry <command> <org>/<project>"; + const usageHint = + options.usageHint ?? + asInvocationCommand("sentry <command> <org>/<project>"); if (canPromptForTarget(options.interactive)) { const picked = await promptForOrgProject(); @@ -2449,7 +2453,8 @@ export async function resolveProjectBoundTarget( options: ResolveProjectBoundTargetOptions = {}, ): Promise<ResolvedProjectBoundTarget> { const usageHint = - options.usageHint ?? `sentry ${commandName} <org>/<project>`; + options.usageHint ?? + asInvocationCommand(`sentry ${commandName} <org>/<project>`); switch (parsed.type) { case "explicit": { @@ -2460,7 +2465,7 @@ export async function resolveProjectBoundTarget( case "org-all": throw new ContextError( "Project", - `sentry ${commandName} ${parsed.org}/<project>`, + asInvocationCommand(`sentry ${commandName} ${parsed.org}/<project>`), [], ); @@ -2473,7 +2478,9 @@ export async function resolveProjectBoundTarget( throw new ResolutionError( `'${parsed.projectSlug}'`, "is an organization, not a project", - `sentry ${commandName} ${parsed.projectSlug}/<project>`, + asInvocationCommand( + `sentry ${commandName} ${parsed.projectSlug}/<project>`, + ), [ `List projects: ${asInvocationCommand(`sentry project list ${parsed.projectSlug}/`)}`, ], @@ -2499,12 +2506,17 @@ export async function resolveProjectBoundTarget( const { projects } = resolution; if (projects.length > 1) { const projectOptions = projects - .map((m) => ` sentry ${commandName} ${m.orgSlug}/${m.slug}`) + .map( + (m) => + ` ${asInvocationCommand(`sentry ${commandName} ${m.orgSlug}/${m.slug}`)}`, + ) .join("\n"); throw new ResolutionError( `Project '${resolution.displaySlug}'`, "is ambiguous", - `sentry ${commandName} <org>/${parsed.projectSlug}`, + asInvocationCommand( + `sentry ${commandName} <org>/${parsed.projectSlug}`, + ), [ `Found in ${projects.length} organizations. Specify one:\n${projectOptions}`, ], @@ -2859,7 +2871,7 @@ export async function resolveOrgOptionalTarget( parsed: ParsedOrgProject, cwd: string, commandName: string, - usageHint = `sentry ${commandName} <target>`, + usageHint = asInvocationCommand(`sentry ${commandName} <target>`), ): Promise<ResolvedOrgOptionalTarget> { // org-all: resolve the org slug only if (parsed.type === "org-all") { @@ -2916,7 +2928,7 @@ export async function resolveOrgOnlyTarget( parsed: ParsedOrgProject, cwd: string, commandName: string, - usageHint = `sentry ${commandName} <target>`, + usageHint = asInvocationCommand(`sentry ${commandName} <target>`), ): Promise<string> { if (parsed.type === "auto-detect") { const resolved = await resolveOrg({ cwd }); diff --git a/packages/cli/src/lib/shell.ts b/packages/cli/src/lib/shell.ts index ba6e2f9f2..d6a582cf9 100644 --- a/packages/cli/src/lib/shell.ts +++ b/packages/cli/src/lib/shell.ts @@ -258,6 +258,7 @@ async function addToShellConfig( return { modified: true, configFile, + // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. message: `Added sentry ${label} in ${configFile}`, manualCommand: null, }; diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index fb59b4580..22b0eb347 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -112,7 +112,7 @@ export function orgProjectTarget<R>( requireExplicitTarget( parsed, "Project target", - `sentry ${commandNameOf(context)} <org>/<project>`, + asInvocationCommand(`sentry ${commandNameOf(context)} <org>/<project>`), ), ); } @@ -219,7 +219,7 @@ export const orgTarget = boundPositional( resolveOrgInput( input, context.cwd, - `sentry ${commandNameOf(context)} <org>`, + asInvocationCommand(`sentry ${commandNameOf(context)} <org>`), ), ); diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index ecc8303a3..c573f1b15 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -588,6 +588,17 @@ tester.run( code: "function hint(org) { return `Try: sentry project list ${org}/`; }", errors: 1, }, + // An interpolated subcommand or a <command> placeholder is still a command. + { + filename: commands, + code: "function hint(name) { return `Run sentry ${name} <org>/`; }", + errors: 1, + }, + { + filename: commands, + code: "function hint() { return ' sentry <command> <org>/<project>'; }", + errors: 1, + }, // Every command runs under npx or another name, not only sentry-mcp. { filename: commands, From df1b5a8c961679d0393defb4d50592e699e67f39 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:57:56 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat(cli):=20Build=20command=20hints=20with?= =?UTF-8?q?=20a=20sentry`=E2=80=A6`=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hints now write their command with the `sentry` template tag from lib/app-identity.ts, without the program name: sentry`issue list ${org}/`. The tag names the running program (sentry, npx -y sentry, sentry-mcp), and an interpolated `sentry …` command gets the same prefix, never twice. asInvocationCommand() stays only for commands held in variables (USAGE_HINT, commandPrefix, suggestions); asRunningAppCommand() is gone. no-hardcoded-sentry-command now flags `sentry <word>` only when the word is a route from src/command-manifest.ts, a flag, a placeholder or an interpolation, so prose such as "sentry binary" passes without disable comments. It also flags asInvocationCommand() on a literal and a tag that repeats `sentry`. check:doc-examples checks each tag's command and flags against the route tree, which caught the DSN hints that named a nonexistent `sentry config set`; they now name `sentry cli defaults`. Refs #2054 Refs #2050 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 2 +- packages/cli/lint-rules/cli-oxlint-plugin.js | 128 +++++++++++++----- packages/cli/script/check-doc-examples.ts | 57 +++++++- packages/cli/src/cli.ts | 4 +- .../src/commands/agent-conversation/list.ts | 10 +- .../src/commands/alert/issues/rule-resolve.ts | 6 +- .../cli/src/commands/alert/issues/view.ts | 6 +- .../cli/src/commands/alert/metrics/create.ts | 4 +- .../commands/alert/metrics/rule-resolve.ts | 6 +- packages/cli/src/commands/attribute/list.ts | 12 +- packages/cli/src/commands/auth/list.ts | 8 +- packages/cli/src/commands/auth/login.ts | 16 +-- packages/cli/src/commands/auth/logout.ts | 10 +- packages/cli/src/commands/auth/refresh.ts | 4 +- packages/cli/src/commands/auth/status.ts | 6 +- packages/cli/src/commands/auth/switch.ts | 10 +- packages/cli/src/commands/auth/whoami.ts | 12 +- packages/cli/src/commands/cli/defaults.ts | 8 +- packages/cli/src/commands/cli/fix.ts | 4 +- packages/cli/src/commands/cli/import.ts | 6 +- packages/cli/src/commands/cli/setup.ts | 17 +-- packages/cli/src/commands/cli/uninstall.ts | 4 - packages/cli/src/commands/cli/upgrade.ts | 17 ++- .../cli/src/commands/code-mappings/upload.ts | 6 +- packages/cli/src/commands/dashboard/create.ts | 6 +- packages/cli/src/commands/dashboard/list.ts | 10 +- .../cli/src/commands/dashboard/resolve.ts | 18 ++- .../cli/src/commands/dashboard/revisions.ts | 12 +- .../cli/src/commands/dashboard/widget/add.ts | 6 +- .../cli/src/commands/dashboard/widget/edit.ts | 4 +- .../commands/debug-files/bundle-sources.ts | 4 +- .../src/commands/debug-files/print-sources.ts | 4 +- packages/cli/src/commands/dsn/list.ts | 6 +- packages/cli/src/commands/event/list.ts | 14 +- packages/cli/src/commands/event/send.ts | 4 +- packages/cli/src/commands/event/view.ts | 37 ++--- packages/cli/src/commands/explore.ts | 16 +-- packages/cli/src/commands/feedback/list.ts | 8 +- .../cli/src/commands/feedback/positional.ts | 4 +- packages/cli/src/commands/feedback/utils.ts | 22 ++- packages/cli/src/commands/info.ts | 6 +- packages/cli/src/commands/issue/events.ts | 14 +- packages/cli/src/commands/issue/explain.ts | 4 +- packages/cli/src/commands/issue/list.ts | 29 ++-- packages/cli/src/commands/issue/merge.ts | 8 +- packages/cli/src/commands/issue/plan.ts | 4 +- packages/cli/src/commands/issue/positional.ts | 8 +- .../src/commands/issue/resolve-commit-spec.ts | 4 +- packages/cli/src/commands/issue/resolve.ts | 6 +- packages/cli/src/commands/issue/utils.ts | 17 +-- packages/cli/src/commands/issue/view.ts | 4 +- packages/cli/src/commands/local/run.ts | 6 +- packages/cli/src/commands/local/server.ts | 6 +- packages/cli/src/commands/local/ui.ts | 4 +- packages/cli/src/commands/log/list.ts | 8 +- packages/cli/src/commands/log/view.ts | 14 +- packages/cli/src/commands/org/list.ts | 18 +-- packages/cli/src/commands/proguard/uuid.ts | 4 +- packages/cli/src/commands/project/create.ts | 20 ++- packages/cli/src/commands/project/delete.ts | 4 +- packages/cli/src/commands/project/list.ts | 34 +++-- packages/cli/src/commands/project/view.ts | 6 +- packages/cli/src/commands/release/create.ts | 4 +- packages/cli/src/commands/release/list.ts | 6 +- .../cli/src/commands/release/set-commits.ts | 66 +++------ packages/cli/src/commands/replay/download.ts | 8 +- packages/cli/src/commands/replay/list.ts | 12 +- packages/cli/src/commands/replay/view.ts | 16 +-- packages/cli/src/commands/schema.ts | 49 ++----- packages/cli/src/commands/send-envelope.ts | 6 +- packages/cli/src/commands/sourcemap/inject.ts | 4 +- .../cli/src/commands/sourcemap/resolve.ts | 4 +- packages/cli/src/commands/span/list.ts | 24 +--- packages/cli/src/commands/span/view.ts | 14 +- packages/cli/src/commands/status/show.ts | 4 +- packages/cli/src/commands/token/delete.ts | 8 +- packages/cli/src/commands/token/list.ts | 4 +- packages/cli/src/commands/token/shared.ts | 4 +- packages/cli/src/commands/trace/list.ts | 14 +- packages/cli/src/commands/trace/logs.ts | 8 +- packages/cli/src/commands/trace/view.ts | 23 ++-- packages/cli/src/commands/trial/list.ts | 8 +- packages/cli/src/commands/trial/start.ts | 4 +- packages/cli/src/commands/wasm-split.ts | 4 +- packages/cli/src/lib/account-selection.ts | 8 +- packages/cli/src/lib/api/infrastructure.ts | 12 +- packages/cli/src/lib/api/issues.ts | 6 +- packages/cli/src/lib/api/releases.ts | 36 ++--- packages/cli/src/lib/app-identity.ts | 34 +++-- packages/cli/src/lib/arg-parsing.ts | 12 +- packages/cli/src/lib/command.ts | 6 +- packages/cli/src/lib/custom-ca.ts | 6 +- packages/cli/src/lib/dashboard-arguments.ts | 8 +- packages/cli/src/lib/db/backup.ts | 6 +- packages/cli/src/lib/db/resilient.ts | 6 +- packages/cli/src/lib/db/schema.ts | 6 +- packages/cli/src/lib/dsn/errors.ts | 24 ++-- packages/cli/src/lib/dsn/resolver.ts | 4 +- .../cli/src/lib/envelope/event-send-dsn.ts | 8 +- packages/cli/src/lib/envelope/transport.ts | 4 +- packages/cli/src/lib/errors.ts | 9 +- packages/cli/src/lib/event-attachments.ts | 4 +- packages/cli/src/lib/explore-errors.ts | 6 +- packages/cli/src/lib/formatters/accounts.ts | 6 +- packages/cli/src/lib/formatters/feedback.ts | 12 +- packages/cli/src/lib/formatters/human.ts | 14 +- packages/cli/src/lib/formatters/issue.ts | 4 +- packages/cli/src/lib/formatters/replay.ts | 10 +- packages/cli/src/lib/git.ts | 8 +- packages/cli/src/lib/help.ts | 20 +-- packages/cli/src/lib/hex-id-recovery.ts | 4 +- packages/cli/src/lib/hex-id.ts | 4 +- packages/cli/src/lib/id-arguments.ts | 4 +- packages/cli/src/lib/issue-arguments.ts | 4 +- packages/cli/src/lib/list-command.ts | 4 +- packages/cli/src/lib/metrics-transform.ts | 10 +- packages/cli/src/lib/org-list.ts | 4 +- packages/cli/src/lib/resolve-target.ts | 71 ++++------ packages/cli/src/lib/sentry-client.ts | 6 +- packages/cli/src/lib/sentryclirc-import.ts | 4 +- packages/cli/src/lib/setup-code.ts | 4 +- packages/cli/src/lib/target-positional.ts | 6 +- packages/cli/src/lib/trace-target.ts | 8 +- packages/cli/src/lib/upgrade.ts | 6 +- packages/cli/src/lib/version-check.ts | 6 +- packages/cli/src/lib/wrangler.ts | 4 +- packages/cli/test/lib/app-identity.test.ts | 38 +++++- packages/cli/test/lib/dsn/errors.test.ts | 4 +- scripts/cli-lint-rules.test.mjs | 41 +++++- 129 files changed, 760 insertions(+), 811 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index cbf7e83e0..d4024a6b7 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 build a hint's command with `asInvocationCommand("sentry …")` from `src/lib/app-identity.ts`, inside the function that builds the hint. 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`) enforces this. +- **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 <route>` (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 `[<org>/][<project>]` 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 016c89545..37ab5c620 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -359,19 +359,72 @@ function untaggedParser(value) { } /** - * A `sentry <command>` hint: the CLI binary name, then a subcommand or a - * `<placeholder>` for one. + * Top-level routes of the CLI, read from `src/command-manifest.ts` (one + * `name: route(…)` or `name: hidden(…)` entry each). Only `sentry <route>` + * is a command; `sentry binary` or `sentry v1.2` is prose. */ -const SENTRY_COMMAND_RE = /(?:^|[\s`'"(])sentry [a-z<]/; +const ROUTE_NAMES = new Set( + [ + ...readFileSync( + new URL("../src/command-manifest.ts", import.meta.url), + "utf-8", + ).matchAll(/^ {2}"?([a-z][a-z0-9-]*)"?: (?:route|hidden)\(/gm), + ].map(([, routeName]) => routeName), +); + +/** Stands in for an interpolation, which may be any word. */ +const INTERPOLATION = "\uFFFC"; + +/** + * `sentry` and the word after it: a route, a flag, a `<placeholder>`, or an + * interpolation. + */ +const SENTRY_WORD_RE = + /(?:^|[\s`'"(])sentry (\uFFFC|<|--?[a-z]|[a-z][a-z0-9-]*)/g; + +/** Whether `text` names a `sentry <command>`. */ +function namesSentryCommand(text) { + for (const [, word] of text.matchAll(SENTRY_WORD_RE)) { + if ( + word === INTERPOLATION || + word === "<" || + word.startsWith("-") || + ROUTE_NAMES.has(word) + ) + return true; + } + return false; +} /** A whole `sentry <command>`, such as a usage line or a command prefix. */ -const WHOLE_SENTRY_COMMAND_RE = /^sentry [a-z<]/; +function isWholeSentryCommand(text) { + return namesSentryCommand(text) && text.startsWith("sentry "); +} -/** Calls that turn a `sentry …` command into the running program's command. */ -const INVOCATION_HELPERS = new Set([ - "asInvocationCommand", - "asRunningAppCommand", -]); +/** The tag that builds a command hint: `` sentry`issue list` ``. */ +function isSentryTag(node) { + return ( + node.type === "TaggedTemplateExpression" && + node.tag.type === "Identifier" && + node.tag.name === "sentry" + ); +} + +/** 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 + .map((quasi) => quasi.value.cooked ?? "") + .join(INTERPOLATION); +} /** Properties whose text is command help, written for the `sentry` binary. */ const HELP_TEXT_KEYS = new Set([ @@ -389,15 +442,11 @@ function hasFunctionAncestor(node) { return false; } -/** Whether `node` sits in an invocation helper call or in command help text. */ +/** Whether `node` sits in a command hint helper or in command help text. */ function isInvocationSafe(node) { for (let current = node.parent; current; current = current.parent) { if (FUNCTION_TYPES.has(current.type)) return false; - if ( - current.type === "CallExpression" && - INVOCATION_HELPERS.has(current.callee.name) - ) - return true; + if (isSentryTag(current) || isInvocationCall(current)) return true; if ( current.type === "Property" && HELP_TEXT_KEYS.has(current.key?.name ?? current.key?.value) @@ -857,39 +906,56 @@ const rules = { }, ), "no-hardcoded-sentry-command": rule( - "Hints must name the program that is running (`npx sentry`, `sentry-mcp`, ...): wrap the `sentry …` command in asInvocationCommand() from lib/app-identity.js.", + "Hints must name the program that is running (`npx sentry`, `sentry-mcp`, ...): write the command with the sentry`…` tag from lib/app-identity.js, as sentry`issue list`.", (_context, file, report) => { if (!file.includes("/src/")) return {}; const check = (node, text) => { - if (!SENTRY_COMMAND_RE.test(text) || isInvocationSafe(node)) return; + 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 (WHOLE_SENTRY_COMMAND_RE.test(text) && !hasFunctionAncestor(node)) - return; + if (isWholeSentryCommand(text) && !hasFunctionAncestor(node)) return; report(node); }; + // The running program is known only once an entry point has started, + // so a hint built at module load would still name `sentry`. + const checkModuleLevel = (node) => { + 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.", + ); + }; return { - // The running program is known only once an entry point has started, - // so a call evaluated at module load would still name `sentry`. CallExpression(node) { - if ( - INVOCATION_HELPERS.has(node.callee.name) && - !hasFunctionAncestor(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, - "Call asInvocationCommand() where the hint is built, not at module load: the running program is not known yet.", + "Write a literal command with the sentry`…` tag; asInvocationCommand() is for commands held in variables.", + ); + }, + TaggedTemplateExpression(node) { + if (!isSentryTag(node)) return; + checkModuleLevel(node); + if (templateText(node.quasi).startsWith("sentry ")) + report( + node, + "The sentry`…` tag adds the program name; leave `sentry` out of the text.", ); }, Literal(node) { if (typeof node.value === "string") check(node, node.value); }, TemplateLiteral(node) { - // Each interpolation stands in for a word, so `sentry ${cmd}` counts. - check( - node, - node.quasis.map((quasi) => quasi.value.cooked ?? "").join("x"), - ); + if (!isSentryTag(node.parent)) check(node, templateText(node)); }, }; }, diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index e0d002e43..9b4d1c149 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -11,6 +11,9 @@ * matches a project first, so examples that mean the org must use the * trailing-slash form (`my-org/`) * + * Hints built with the `` sentry`…` `` tag (src/lib/app-identity.ts) get the + * same checks, with each interpolation read as a placeholder. + * * Library examples that pass only string literals to `sdk.run(…)` get the same * checks, since `run()` takes the same argv as the CLI. Those calls are found * by parsing TypeScript fences and `@example` JSDoc with the TypeScript @@ -30,6 +33,7 @@ import { readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import ts from "typescript"; import { exampleSteps } from "../src/lib/command-example.js"; import type { Command, @@ -69,6 +73,9 @@ const EXTRA_DOC_FILES = [ /** Source files whose JSDoc shows `sdk.run(…)` examples. */ const SDK_EXAMPLE_FILES = ["src/index.ts"]; +/** Source scanned for `` sentry`…` `` hints. */ +const SOURCE_DIR = "src"; + /** Flags Stricli handles before command dispatch. */ const BUILTIN_FLAGS = new Set(["help", "help-all", "version"]); const BUILTIN_ALIASES = new Set(["h", "H"]); @@ -257,6 +264,40 @@ function checkWords(words: string[]): string[] { return problems; } +/** + * Commands built with the `` sentry`…` `` tag in one source file, as + * `sentry …` lines with each interpolation as an `<arg>` placeholder. + */ +function sentryTagCommands(file: string, code: string): string[] { + const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest); + const commands: string[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isTaggedTemplateExpression(node) && + ts.isIdentifier(node.tag) && + node.tag.text === "sentry" + ) { + const { template } = node; + const text = ts.isNoSubstitutionTemplateLiteral(template) + ? template.text + : template.head.text + + template.templateSpans + .map((span) => `<arg>${span.literal.text}`) + .join(""); + commands.push(`sentry ${text}`); + } + ts.forEachChild(node, visit); + }; + visit(source); + return commands; +} + +function listSource(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf-8" }) + .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts")) + .map((f) => join(dir, f)); +} + function listMarkdown(dir: string): string[] { return readdirSync(dir, { recursive: true, encoding: "utf-8" }) .filter((f) => f.endsWith(".md") || f.endsWith(".mdx")) @@ -323,9 +364,23 @@ for (const file of [...files, ...SDK_EXAMPLE_FILES]) { } } +const sourceFiles = listSource(SOURCE_DIR); +for (const file of sourceFiles) { + const content = await readFile(file, "utf-8"); + if (!content.includes("sentry`")) { + continue; + } + for (const invocation of sentryTagCommands(file, content)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file, example: invocation, problem }); + } + } +} + if (findings.length === 0) { console.log( - `✓ ${checked} sentry examples in ${files.length + SDK_EXAMPLE_FILES.length} files match the CLI`, + `✓ ${checked} sentry examples in ${files.length + SDK_EXAMPLE_FILES.length + sourceFiles.length} files match the CLI`, ); process.exit(0); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index ffdd3389c..f2322217f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,7 +14,7 @@ * stream error handlers and calls `startCli()`. */ -import { asInvocationCommand } from "./lib/app-identity.js"; +import { sentry } from "./lib/app-identity.js"; import type { CliApplication } from "./lib/application.js"; import { redactCredentialText } from "./lib/credential-redaction.js"; import { CliError, formatError } from "./lib/errors.js"; @@ -158,7 +158,7 @@ async function recoverUnknownCommandHelp( const { warning } = await import("./lib/formatters/colors.js"); process.stderr.write( warning( - `Tip: use --help for help (e.g., ${asInvocationCommand(`sentry ${groupArgs.join(" ")} --help`)})\n`, + `Tip: use --help for help (e.g., ${sentry`${groupArgs.join(" ")} --help`})\n`, ), ); await executor(["help", ...groupArgs]); diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 86441bcad..3b7b15682 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { getProject, listConversations } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { parseText, textParser } from "../../lib/completion-target.js"; import { @@ -303,12 +303,8 @@ export const listCommand = buildListCommand("agent-conversation", { hint: paginationHint({ hasMore, hasPrev: !!hasPrev, - nextHint: asInvocationCommand( - `sentry agent-conversation list ${scope} -c next${flagSuffix}`, - ), - prevHint: asInvocationCommand( - `sentry agent-conversation list ${scope} -c prev${flagSuffix}`, - ), + nextHint: sentry`agent-conversation list ${scope} -c next${flagSuffix}`, + prevHint: sentry`agent-conversation list ${scope} -c prev${flagSuffix}`, }), }; }, diff --git a/packages/cli/src/commands/alert/issues/rule-resolve.ts b/packages/cli/src/commands/alert/issues/rule-resolve.ts index 184e4e9a9..eabfcdb20 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 } from "../../../lib/app-identity.js"; +import { asInvocationCommand, 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"; @@ -134,7 +134,7 @@ export async function resolveIssueAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule ID '${ref}' matched multiple projects.\n` + - `Use an explicit target: ${asInvocationCommand("sentry alert issues <command> <org>/<project>/<rule-id>")}`, + `Use an explicit target: ${sentry`alert issues <command> <org>/<project>/<rule-id>`}`, ); } throw new ResolutionError( @@ -168,7 +168,7 @@ export async function resolveIssueAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule name '${ref}' matched multiple projects.\n` + - `Use an explicit target: ${asInvocationCommand("sentry alert issues <command> <org>/<project>/<rule-id-or-name>")}`, + `Use an explicit target: ${sentry`alert issues <command> <org>/<project>/<rule-id-or-name>`}`, ); } diff --git a/packages/cli/src/commands/alert/issues/view.ts b/packages/cli/src/commands/alert/issues/view.ts index 9dd8205db..aafed67e6 100644 --- a/packages/cli/src/commands/alert/issues/view.ts +++ b/packages/cli/src/commands/alert/issues/view.ts @@ -1,5 +1,5 @@ import type { SentryContext } from "../../../context.js"; -import { asInvocationCommand } from "../../../lib/app-identity.js"; +import { sentry } from "../../../lib/app-identity.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; import { ContextError } from "../../../lib/errors.js"; @@ -107,9 +107,7 @@ export const viewCommand = buildCommand({ const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: asInvocationCommand( - "sentry alert issues view <org>/<project>/<rule-id-or-name>", - ), + usageHint: sentry`alert issues view <org>/<project>/<rule-id-or-name>`, }); if (targets.length === 0) { throw new ContextError("Organization and project", USAGE_HINT); diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index df7bfeebc..bd6cd4632 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../../context.js"; import { createMetricAlertRule } from "../../../lib/api-client.js"; -import { asInvocationCommand } from "../../../lib/app-identity.js"; +import { sentry } from "../../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { @@ -80,7 +80,7 @@ function resolveMetricCreateOrg( parsed, cwd, "alert metrics create", - asInvocationCommand("sentry alert metrics create <org>/"), + sentry`alert metrics create <org>/`, ); } diff --git a/packages/cli/src/commands/alert/metrics/rule-resolve.ts b/packages/cli/src/commands/alert/metrics/rule-resolve.ts index c4be06360..01026eceb 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 } from "../../../lib/app-identity.js"; +import { asInvocationCommand, 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"; @@ -130,7 +130,7 @@ export async function resolveMetricAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule ID '${ref}' matched multiple organizations.\n` + - `Use an explicit target: ${asInvocationCommand("sentry alert metrics <command> <org>/<rule-id>")}`, + `Use an explicit target: ${sentry`alert metrics <command> <org>/<rule-id>`}`, ); } throw new ResolutionError( @@ -164,7 +164,7 @@ export async function resolveMetricAlertRule( if (hits.length > 1) { throw new ValidationError( `Alert rule name '${ref}' matched multiple organizations.\n` + - `Use an explicit target: ${asInvocationCommand("sentry alert metrics <command> <org>/<rule-id-or-name>")}`, + `Use an explicit target: ${sentry`alert metrics <command> <org>/<rule-id-or-name>`}`, ); } diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index daee681f5..0454f7609 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -15,7 +15,7 @@ import type { TraceItemAttributeDataset, TraceItemAttributeDefinition, } from "../../lib/api/traces.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { ATTRIBUTE_TYPES, parseAttributeFilter, @@ -131,11 +131,11 @@ export function buildUsageTip( const { key } = example; if (dataset === "tracemetrics") { const target = project ? `${org}/${project}` : `${org}/`; - return `Tip: Group metrics with: ${asInvocationCommand(`sentry explore ${target} --dataset metrics -m <metric> -F ${shellQuote(key)}`)}`; + return `Tip: Group metrics with: ${sentry`explore ${target} --dataset metrics -m <metric> -F ${shellQuote(key)}`}`; } const target = project ? `${org}/${project}` : `${org}/<project>`; const command = dataset === "logs" ? "log list" : "span list"; - return `Tip: Filter or add to columns with: ${asInvocationCommand(`sentry ${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`)}`; + return `Tip: Filter or add to columns with: ${sentry`${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`}`; } /** @@ -175,11 +175,7 @@ function pageHint( filter: string | undefined, ): string { const filterArg = filter ? ` ${shellQuote(filter)}` : ""; - const parts = [ - asInvocationCommand( - `sentry attribute list ${scope}${filterArg} -c ${direction}`, - ), - ]; + const parts = [sentry`attribute list ${scope}${filterArg} -c ${direction}`]; if (flags.dataset !== DEFAULT_DATASET) { parts.push(`--dataset ${datasetDisplayName(flags.dataset)}`); } diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 3af83d120..742a5e3a2 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { listAccounts } from "../../lib/db/accounts.js"; import { @@ -66,16 +66,16 @@ export const listCommand = buildCommand({ } if (stored.length > 1) { return { - hint: `Switch accounts with '${asInvocationCommand("sentry auth switch <email>")}'.`, + hint: `Switch accounts with '${sentry`auth switch <email>`}'.`, }; } if (stored.length === 0) { return { - hint: `Log in with '${asInvocationCommand("sentry auth login")}'.`, + hint: `Log in with '${sentry`auth login`}'.`, }; } return { - hint: `Add another account with '${asInvocationCommand("sentry auth login")}'.`, + hint: `Add another account with '${sentry`auth login`}'.`, }; }, }); diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 46e578dda..6b548faea 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -3,7 +3,7 @@ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; import { pointDefaultUrlAtAccount } from "../../lib/account-selection.js"; import { getCurrentUser, getUserRegions } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { normalizeAuthToken } from "../../lib/auth-header.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { @@ -132,8 +132,8 @@ function resolveLoginScope(flags: LoginFlags): string | undefined { `${conflicting} cannot be used with --token — OAuth scope is fixed when the token is issued, so the CLI cannot narrow an existing token's permissions.`, "", "To restrict OAuth scopes, drop --token and use the device flow:", - ` ${asInvocationCommand("sentry auth login --read-only")}`, - ` ${asInvocationCommand("sentry auth login --scope project:read --scope org:read")}`, + ` ${sentry`auth login --read-only`}`, + ` ${sentry`auth login --scope project:read --scope org:read`}`, "", "Or create a scoped User Auth Token in Sentry (Account → Auth Tokens) and pass it without --read-only/--scope.", ].join("\n"), @@ -305,8 +305,8 @@ export function rcTokenHint( : ` --url ${effectiveHost}`; return ( `Found a token in .sentryclirc (${rcConfig.sources.token}). ` + - `To import it: ${asInvocationCommand("sentry cli import")} | ` + - `To pass it directly: ${asInvocationCommand(`sentry auth login --token <token>${urlHint}`)}` + `To import it: ${sentry`cli import`} | ` + + `To pass it directly: ${sentry`auth login --token <token>${urlHint}`}` ); } @@ -329,7 +329,7 @@ function persistLoginUrlAsDefault( pointDefaultUrlAtAccount(effectiveHost); } catch { log.debug( - `Could not persist default URL to DB; host is recorded on the stored token. Set SENTRY_HOST or run '${asInvocationCommand(`sentry cli defaults url ${effectiveHost}`)}' if subsequent commands route incorrectly.`, + `Could not persist default URL to DB; host is recorded on the stored token. Set SENTRY_HOST or run '${sentry`cli defaults url ${effectiveHost}`}' if subsequent commands route incorrectly.`, ); } } @@ -391,7 +391,7 @@ async function handleExistingAuth(force: boolean): Promise<boolean> { // Non-interactive (piped, CI): print message and block if (!isatty(0)) { log.info( - `You are already authenticated. Use '${asInvocationCommand("sentry auth login <email>")}' to add another account, ` + + `You are already authenticated. Use '${sentry`auth login <email>`}' to add another account, ` + "or '--force' to replace the current login.", ); return false; @@ -402,7 +402,7 @@ async function handleExistingAuth(force: boolean): Promise<boolean> { const identity = userInfo ? formatUserIdentity(userInfo) : "current user"; const confirmed = await prompt( `Already authenticated as ${identity}. Add another account? ` + - `(The current account stays saved; see '${asInvocationCommand("sentry auth list")}'. Use --force to replace it.)`, + `(The current account stays saved; see '${sentry`auth list`}'. Use --force to replace it.)`, { type: "confirm", initial: false }, ); diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 3cab5e77f..a710e3d4e 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -9,7 +9,7 @@ import { pointDefaultUrlAtAccount, resolveAccountSelector, } from "../../lib/account-selection.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { ACCOUNT_SERVER_TARGET, @@ -177,11 +177,7 @@ export const logoutCommand = buildCommand({ if (selectors.length > 0) { targets = uniqueAccounts( selectors.map((sel) => - resolveAccountSelector( - sel, - asInvocationCommand("sentry auth logout"), - { url: flags.url }, - ), + resolveAccountSelector(sel, sentry`auth logout`, { url: flags.url }), ), ); } else { @@ -191,7 +187,7 @@ export const logoutCommand = buildCommand({ if (targets.length === 0) { return yield new CommandOutput({ loggedOut: false, - message: `No account is active. Run '${asInvocationCommand("sentry auth list")}' and pass an account to log out of, or 'all'.`, + message: `No account is active. Run '${sentry`auth list`}' and pass an account to log out of, or 'all'.`, }); } diff --git a/packages/cli/src/commands/auth/refresh.ts b/packages/cli/src/commands/auth/refresh.ts index cff2fea04..5ab79e94b 100644 --- a/packages/cli/src/commands/auth/refresh.ts +++ b/packages/cli/src/commands/auth/refresh.ts @@ -11,7 +11,7 @@ */ import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceListParser } from "../../lib/completion-target.js"; import { @@ -194,7 +194,7 @@ session (similar to \`gh auth refresh -s <scope>\`). throw new AuthError( "invalid", "No refresh token available. You may be using a manual API token.\n" + - `Run '${asInvocationCommand("sentry auth login")}' to authenticate with OAuth and enable auto-refresh.`, + `Run '${sentry`auth login`}' to authenticate with OAuth and enable auto-refresh.`, ); } diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index b7e686c34..783614abd 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type AuthConfig, @@ -282,8 +282,8 @@ export const statusCommand = buildCommand({ classifySentryToken(effectiveToken) === "org-auth-token"; return { hint: isOrgToken - ? `Run \`${asInvocationCommand("sentry auth whoami")}\` to see which organization this token belongs to` - : `Run \`${asInvocationCommand("sentry auth whoami")}\` to see which user this token belongs to`, + ? `Run \`${sentry`auth whoami`}\` to see which organization this token belongs to` + : `Run \`${sentry`auth whoami`}\` to see which user this token belongs to`, }; } }, diff --git a/packages/cli/src/commands/auth/switch.ts b/packages/cli/src/commands/auth/switch.ts index 691f10fcb..3f52b2943 100644 --- a/packages/cli/src/commands/auth/switch.ts +++ b/packages/cli/src/commands/auth/switch.ts @@ -10,7 +10,7 @@ import { promptForAccount, resolveAccountSelector, } from "../../lib/account-selection.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { ACCOUNT_SERVER_TARGET, @@ -46,7 +46,7 @@ async function pickAccount( accounts: AccountSummary[], url: string | undefined, ): Promise<AccountSummary | undefined> { - const command = asInvocationCommand("sentry auth switch"); + const command = sentry`auth switch`; if (selector !== undefined) { return resolveAccountSelector(selector, command, { url, fuzzy: true }); } @@ -58,9 +58,7 @@ async function pickAccount( `Account on '${url}'`, "not found", `${command} [account] --url <server>`, - [ - `Run '${asInvocationCommand("sentry auth list")}' to see stored accounts`, - ], + [`Run '${sentry`auth list`}' to see stored accounts`], ); } const [only] = candidates; @@ -155,7 +153,7 @@ export const switchCommand = buildCommand({ if (!target.usable) { return { - hint: `This account's session has ended. Run '${asInvocationCommand("sentry auth login")}' to log in again.`, + hint: `This account's session has ended. Run '${sentry`auth login`}' to log in again.`, }; } if (getAuthConfig()?.source.startsWith("env:")) { diff --git a/packages/cli/src/commands/auth/whoami.ts b/packages/cli/src/commands/auth/whoami.ts index bc68cb680..baf37dddf 100644 --- a/packages/cli/src/commands/auth/whoami.ts +++ b/packages/cli/src/commands/auth/whoami.ts @@ -8,7 +8,7 @@ import type { SentryContext } from "../../context.js"; import { getCurrentUser } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type AccountSummary, listAccounts } from "../../lib/db/accounts.js"; import { getAuthConfig, getAuthToken } from "../../lib/db/auth.js"; @@ -84,10 +84,10 @@ export const whoamiCommand = buildCommand({ throw new ResolutionError( "Organization auth tokens (sntrys_...)", "are not tied to a user — `whoami` needs a user-scoped credential", - asInvocationCommand("sentry auth status"), + sentry`auth status`, [ - `Use an OAuth token from \`${asInvocationCommand("sentry auth login")}\` or a personal access token`, - `Run \`${asInvocationCommand("sentry org list")}\` to list organizations this token can access`, + `Use an OAuth token from \`${sentry`auth login`}\` or a personal access token`, + `Run \`${sentry`org list`}\` to list organizations this token can access`, ], ); } @@ -104,7 +104,7 @@ export const whoamiCommand = buildCommand({ }; yield new CommandOutput(data); return { - hint: `Run \`${asInvocationCommand("sentry auth login")}\` for user-scoped authentication`, + hint: `Run \`${sentry`auth login`}\` for user-scoped authentication`, }; } @@ -161,6 +161,6 @@ export function formatOtherIdentitiesHint(user: { const you = user.name || user.username || user.email || `user ${user.id}`; return ( `You are ${you}, but you can be whoever you want — as long as it's one of: ` + - `${others.map(formatAccountLabel).join(", ")}. Run '${asInvocationCommand("sentry auth switch")}'.` + `${others.map(formatAccountLabel).join(", ")}. Run '${sentry`auth switch`}'.` ); } diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index 5e6fea727..6bc22d7dd 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -13,7 +13,7 @@ import { resolve } from "node:path"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser } from "../../lib/completion-target.js"; import { normalizeUrl } from "../../lib/constants.js"; @@ -360,7 +360,7 @@ export const defaultsCommand = buildCommand({ // Too many arguments if (rest.length > 0) { throw new ValidationError( - `Too many arguments. Usage: ${asInvocationCommand("sentry cli defaults")} [<key> [<value>]]`, + `Too many arguments. Usage: ${sentry`cli defaults`} [<key> [<value>]]`, "args", ); } @@ -370,7 +370,7 @@ export const defaultsCommand = buildCommand({ rawKeyArg !== undefined && SHOW_ALL_VERBS.has(rawKeyArg.toLowerCase()); if (isShowVerb && (valueArg !== undefined || flags.clear)) { throw new ValidationError( - `'${rawKeyArg}' shows all defaults and takes no value or --clear. Use '${asInvocationCommand("sentry cli defaults")}' to show all, or '${asInvocationCommand("sentry cli defaults --clear")}' to clear all.`, + `'${rawKeyArg}' shows all defaults and takes no value or --clear. Use '${sentry`cli defaults`}' to show all, or '${sentry`cli defaults --clear`}' to clear all.`, "args", ); } @@ -427,7 +427,7 @@ export const defaultsCommand = buildCommand({ if (flags.clear) { if (valueArg !== undefined) { throw new ValidationError( - `Cannot use --clear with a value. Use either '${asInvocationCommand(`sentry cli defaults ${keyArg} --clear`)}' or '${asInvocationCommand(`sentry cli defaults ${keyArg} <value>`)}'.`, + `Cannot use --clear with a value. Use either '${sentry`cli defaults ${keyArg} --clear`}' or '${sentry`cli defaults ${keyArg} <value>`}'.`, "args", ); } diff --git a/packages/cli/src/commands/cli/fix.ts b/packages/cli/src/commands/cli/fix.ts index 34388d6b0..1b9873be2 100644 --- a/packages/cli/src/commands/cli/fix.ts +++ b/packages/cli/src/commands/cli/fix.ts @@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process"; import { chmod, chown, stat } from "node:fs/promises"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { getConfigDir, getDbPath, getRawDatabase } from "../../lib/db/index.js"; import { @@ -340,7 +340,7 @@ function ownershipInstructions( "To fix ownership, run one of:\n\n" + ` sudo chown -R ${username} "${configDir}"\n\n` + "Or fix it automatically:\n\n" + - ` sudo ${asInvocationCommand("sentry cli fix")}` + ` sudo ${sentry`cli fix`}` ); } diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 995d877b1..dabe83555 100644 --- a/packages/cli/src/commands/cli/import.ts +++ b/packages/cli/src/commands/cli/import.ts @@ -13,7 +13,7 @@ import { isatty } from "node:tty"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { textParser } from "../../lib/completion-target.js"; import { getDefaultUrl } from "../../lib/db/defaults.js"; @@ -223,7 +223,7 @@ function enforceTrustGate(plan: ImportPlan): void { throw new HostScopeError( `Token (from ${tokenSource}) and URL (from ${urlSource}) come from different files.\n` + "To confirm you trust this URL, pass it explicitly:\n" + - ` ${asInvocationCommand(`sentry cli import --url ${plan.effective.url ?? "<url>"}`)}`, + ` ${sentry`cli import --url ${plan.effective.url ?? "<url>"}`}`, ); } @@ -390,7 +390,7 @@ export const importCommand = buildCommand({ if (result.tokenValid === false) { return { - hint: `Token validation failed. Check your token and try: ${asInvocationCommand("sentry auth login")}`, + hint: `Token validation failed. Check your token and try: ${sentry`auth login`}`, }; } if (result.imported) { diff --git a/packages/cli/src/commands/cli/setup.ts b/packages/cli/src/commands/cli/setup.ts index df0b6eb10..ec4029df9 100644 --- a/packages/cli/src/commands/cli/setup.ts +++ b/packages/cli/src/commands/cli/setup.ts @@ -12,7 +12,7 @@ import { dirname, join } from "node:path"; import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; import { installAgentSkills } from "../../lib/agent-skills.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { assertAutoLoginHostTrusted } from "../../lib/auto-auth.js"; import { determineInstallDir, @@ -480,17 +480,12 @@ function printWelcomeMessage( binaryPath: string, ): void { emit(""); - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. emit(`Installed sentry v${version} to ${binaryPath}`); emit(""); emit("Get started:"); - emit(` ${asInvocationCommand("sentry auth")} Authenticate with Sentry`); - emit( - ` ${asInvocationCommand("sentry mcp")} Run the Sentry MCP server for your AI agent`, - ); - emit( - ` ${asInvocationCommand("sentry --help")} See all available commands`, - ); + emit(` ${sentry`auth`} Authenticate with Sentry`); + emit(` ${sentry`mcp`} Run the Sentry MCP server for your AI agent`); + emit(` ${sentry`--help`} See all available commands`); emit(""); emit("https://cli.sentry.dev"); } @@ -870,9 +865,7 @@ export const setupCommand = buildCommand({ await warmCachesAfterLogin(); } } else { - log.info( - `Run '${asInvocationCommand("sentry auth login")}' to authenticate later.`, - ); + log.info(`Run '${sentry`auth login`}' to authenticate later.`); } }, warn, diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index b965f6504..6fd354665 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -257,14 +257,12 @@ function gatherArtifacts(home: string): UninstallArtifact[] { const sentryConfigs = findSentryConfigFiles(home); for (const configFile of sentryConfigs) { artifacts.push({ - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. label: `sentry entries in ${configFile}`, path: configFile, exists: true, remove: async () => { const removed = await removeSentryLinesFromConfig(configFile); if (!removed) { - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. throw new Error(`Failed to remove sentry entries from ${configFile}`); } }, @@ -296,7 +294,6 @@ function gatherArtifacts(home: string): UninstallArtifact[] { // self-deletion and tell the user to remove it manually. if (process.platform === "win32") { artifacts.push({ - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. label: "sentry binary (manual removal needed on Windows)", path: binaryPath, exists: true, @@ -309,7 +306,6 @@ function gatherArtifacts(home: string): UninstallArtifact[] { }); } else { artifacts.push({ - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. label: "sentry binary", path: binaryPath, exists: true, diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index dbb03c1af..f2a4cf3a5 100644 --- a/packages/cli/src/commands/cli/upgrade.ts +++ b/packages/cli/src/commands/cli/upgrade.ts @@ -19,7 +19,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { setTimeout } from "node:timers/promises"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { compareVersions, determineInstallDir, @@ -98,7 +98,7 @@ function prepareLocalDatabaseForDowngrade(): void { } catch (error) { log.warn( `Could not prepare the local database for an older CLI: ${stringifyUnknown(error)}. ` + - `If the older CLI fails, delete ${getDbPath()} and run \`${asInvocationCommand("sentry auth login")}\`.`, + `If the older CLI fails, delete ${getDbPath()} and run \`${sentry`auth login`}\`.`, ); } } @@ -133,7 +133,7 @@ async function backupLocalDatabase( } catch (error) { log.warn( `Could not back up the local database: ${stringifyUnknown(error)}. ` + - `\`${asInvocationCommand("sentry cli upgrade undo")}\` won't be able to return to this version.`, + `\`${sentry`cli upgrade undo`}\` won't be able to return to this version.`, ); return; } @@ -357,7 +357,7 @@ async function resolveTargetWithFallback(opts: { throw new UpgradeError( "unsupported_operation", "Cannot switch channels in offline mode — the cached version belongs to the current channel. " + - `Run '${asInvocationCommand("sentry cli upgrade")}' with network access to switch channels.`, + `Run '${sentry`cli upgrade`}' with network access to switch channels.`, ); } // Read the cached version BEFORE persisting the channel — setReleaseChannel @@ -424,7 +424,7 @@ function validateMethod( throw new UpgradeError( "unsupported_operation", `Homebrew can't install an earlier version, so undo can't return to ${pinnedVersion} through it. ` + - `Run \`${asInvocationCommand("sentry cli upgrade undo --method curl")}\` to return to it as a standalone install; ` + + `Run \`${sentry`cli upgrade undo --method curl`}\` to return to it as a standalone install; ` + "the Homebrew one may still come first in PATH.", ); } @@ -582,8 +582,8 @@ function buildCheckResult(opts: { if (CLI_VERSION !== target) { const cmd = versionArg && !CHANNEL_VERSIONS.has(versionArg) - ? asInvocationCommand(`sentry cli upgrade ${target}`) - : asInvocationCommand("sentry cli upgrade"); + ? sentry`cli upgrade ${target}` + : sentry`cli upgrade`; result.warnings = [`Run '${cmd}' to update.`]; } @@ -693,7 +693,7 @@ async function spawnWithRetry( throw new UpgradeError( "execution_failed", `Downloaded binary not found at ${binaryPath}. ` + - `The download may have been interrupted — rerun \`${asInvocationCommand("sentry cli upgrade")}\`.`, + `The download may have been interrupted — rerun \`${sentry`cli upgrade`}\`.`, ); } if (!isEbusyError(error) || attempt === SPAWN_MAX_ATTEMPTS) { @@ -1040,7 +1040,6 @@ async function migrateToStandaloneForNightly(opts: { }; const warnings: string[] = []; warnings.push( - // oxlint-disable-next-line sentry-cli/no-hardcoded-sentry-command -- names the binary, not a command. `Your ${method}-installed sentry may still appear earlier in PATH.`, ); const hint = uninstallHints[method]; diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index eaf8238dc..9600394c3 100644 --- a/packages/cli/src/commands/code-mappings/upload.ts +++ b/packages/cli/src/commands/code-mappings/upload.ts @@ -22,7 +22,7 @@ import { CodeMappingSchema, uploadCodeMappings, } from "../../lib/api/code-mappings.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath, @@ -259,9 +259,7 @@ export const uploadCommand = buildCommand({ if (!repository) { throw new ContextError( "Repository name", - asInvocationCommand( - "sentry code-mappings upload <path> --repo <owner/repo>", - ), + sentry`code-mappings upload <path> --repo <owner/repo>`, [ "Could not auto-detect repository from git remotes", "Provide --repo explicitly", diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index 45d4c79a6..d80d27516 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { createDashboard, getProject } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -125,7 +125,7 @@ async function resolveDashboardTarget( case "project-search": { const found = await resolveProjectBoundSlug( parsed.projectSlug, - asInvocationCommand("sentry dashboard create <org>/<project> <title>"), + sentry`dashboard create <org>/<project> <title>`, undefined, parsed.originalSlug, ); @@ -142,7 +142,7 @@ async function resolveDashboardTarget( if (!resolved) { throw new ContextError( "Organization", - asInvocationCommand("sentry dashboard create <org>/ <title>"), + sentry`dashboard create <org>/ <title>`, ); } return { orgSlug: resolved.org, projectIds: [] }; diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index b62d0ecf4..bc34d4d74 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -13,7 +13,7 @@ import { API_MAX_PER_PAGE, listDashboardsPaginated, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { openInBrowser } from "../../lib/browser.js"; import { advancePaginationState, @@ -353,12 +353,8 @@ function buildHint( const navRaw = paginationHint({ hasPrev: !!result.hasPrev, hasMore: !!result.hasMore, - prevHint: asInvocationCommand( - `sentry dashboard list ${orgSlug}/${filterArg} -c prev`, - ), - nextHint: asInvocationCommand( - `sentry dashboard list ${orgSlug}/${filterArg} -c next`, - ), + prevHint: sentry`dashboard list ${orgSlug}/${filterArg} -c prev`, + nextHint: sentry`dashboard list ${orgSlug}/${filterArg} -c next`, }); const nav = navRaw ? ` ${navRaw}` : ""; const url = buildDashboardsListUrl(orgSlug); diff --git a/packages/cli/src/commands/dashboard/resolve.ts b/packages/cli/src/commands/dashboard/resolve.ts index aecf79cac..4f78a798a 100644 --- a/packages/cli/src/commands/dashboard/resolve.ts +++ b/packages/cli/src/commands/dashboard/resolve.ts @@ -12,7 +12,7 @@ import { getOrganization, listDashboardsPaginated, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; import { DATASET_FIELDS, @@ -465,21 +465,19 @@ async function build404Error( throw new ResolutionError( `Organization ${org}`, "not found or has no dashboards", - asInvocationCommand("sentry dashboard list <org>/"), + sentry`dashboard list <org>/`, [ - `Verify the organization slug with: ${asInvocationCommand("sentry org list")}`, + `Verify the organization slug with: ${sentry`org list`}`, "Check that you have access to the organization", ], ); } - const listHint = asInvocationCommand( - `sentry dashboard list ${ctx.orgSlug ?? "<org>"}/`, - ); + const listHint = sentry`dashboard list ${ctx.orgSlug ?? "<org>"}/`; if (ctx.dashboardId) { // Fetch available dashboards to suggest alternatives const alternatives = [ "The dashboard may have been deleted", - `Check the dashboard ID or title with: ${asInvocationCommand("sentry dashboard list")}`, + `Check the dashboard ID or title with: ${sentry`dashboard list`}`, ]; if (ctx.orgSlug) { try { @@ -507,7 +505,7 @@ async function build404Error( throw new ResolutionError( `Organization ${org}`, "not found", - asInvocationCommand("sentry org list"), + sentry`org list`, ["Verify the organization slug and your access"], ); } @@ -523,14 +521,14 @@ function build403Error( throw new ResolutionError( `Dashboard ${ctx.dashboardId} in ${org}`, "access denied", - asInvocationCommand(`sentry dashboard list ${ctx.orgSlug ?? "<org>"}/`), + sentry`dashboard list ${ctx.orgSlug ?? "<org>"}/`, [message, "Check your organization membership and role"], ); } throw new ResolutionError( `Dashboards in ${org}`, "access denied", - asInvocationCommand("sentry org list"), + sentry`org list`, [message, "Check your organization membership and role"], ); } diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 14a6ff2bb..006a567a3 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -8,7 +8,7 @@ import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { paginate } from "../../lib/api/infrastructure.js"; import { listDashboardRevisionsPaginated } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { advancePaginationState, @@ -211,12 +211,8 @@ export const revisionsCommand = buildCommand({ const nav = paginationHint({ hasPrev: !!hasPrev, hasMore, - prevHint: asInvocationCommand( - `sentry dashboard revisions ${orgSlug}/ ${dashboardId} -c prev`, - ), - nextHint: asInvocationCommand( - `sentry dashboard revisions ${orgSlug}/ ${dashboardId} -c next`, - ), + prevHint: sentry`dashboard revisions ${orgSlug}/ ${dashboardId} -c prev`, + nextHint: sentry`dashboard revisions ${orgSlug}/ ${dashboardId} -c next`, }); const navStr = nav ? ` ${nav}` : ""; @@ -227,7 +223,7 @@ export const revisionsCommand = buildCommand({ return { hint: `Showing ${revisions.length} revision(s) for dashboard ${dashboardId}.${navStr}\n` + - `Restore: ${asInvocationCommand(`sentry dashboard restore ${orgSlug}/ ${dashboardId} --revision <revision-id>`)}\n` + + `Restore: ${sentry`dashboard restore ${orgSlug}/ ${dashboardId} --revision <revision-id>`}\n` + `Dashboard: ${url}`, }; }, diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index 0b8f00b37..5dae0becf 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; -import { asInvocationCommand } from "../../../lib/app-identity.js"; +import { sentry } from "../../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { aggregateShorthandParser, @@ -90,14 +90,14 @@ function parseAddPositionalArgs(args: readonly string[]): { throw new ValidationError( "Widget title is required as a positional argument.\n\n" + "Example:\n" + - ` ${asInvocationCommand('sentry dashboard widget add <dashboard> "My Widget" --display line --query count')}`, + ` ${sentry`dashboard widget add <dashboard> "My Widget" --display line --query count`}`, "title", ); } if (args.length > 3) { throw new ValidationError( `Too many positional arguments (got ${args.length}, expected at most 3).\n\n` + - `Usage: ${asInvocationCommand("sentry dashboard widget add [<org/project>] <dashboard> <title>")}`, + `Usage: ${sentry`dashboard widget add [<org/project>] <dashboard> <title>`}`, "positional", ); } diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index 4501e7778..b4ce0535c 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; -import { asInvocationCommand } from "../../../lib/app-identity.js"; +import { sentry } from "../../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { aggregateShorthandParser, @@ -360,7 +360,7 @@ export const editCommand = buildCommand({ throw new ValidationError( "Specify --index or --title to identify the widget to edit.\n\n" + "Example:\n" + - ` ${asInvocationCommand("sentry dashboard widget edit <dashboard> --title 'My Widget' --display bar")}`, + ` ${sentry`dashboard widget edit <dashboard> --title 'My Widget' --display bar`}`, "index", ); } diff --git a/packages/cli/src/commands/debug-files/bundle-sources.ts b/packages/cli/src/commands/debug-files/bundle-sources.ts index 17d0fbc1e..7f136e680 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, 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"; @@ -183,7 +183,7 @@ export const bundleSourcesCommand = buildCommand({ }); return { - hint: `Created ${outputPath} with ${result.fileCount} source file(s). Upload with: ${asInvocationCommand("sentry debug-files upload")}`, + hint: `Created ${outputPath} with ${result.fileCount} source file(s). Upload with: ${sentry`debug-files upload`}`, }; }, }); diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index fab009212..1cde436a4 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { type DifSourcesInfo, @@ -212,7 +212,7 @@ export const printSourcesCommand = buildCommand({ }; } return { - hint: `Bundle these into a source bundle with: ${asInvocationCommand(`sentry debug-files bundle-sources ${path}`)}`, + hint: `Bundle these into a source bundle with: ${sentry`debug-files bundle-sources ${path}`}`, }; }, }); diff --git a/packages/cli/src/commands/dsn/list.ts b/packages/cli/src/commands/dsn/list.ts index 051a09f61..091e1304d 100644 --- a/packages/cli/src/commands/dsn/list.ts +++ b/packages/cli/src/commands/dsn/list.ts @@ -10,7 +10,7 @@ import { listOrganizationDsns, listProjectDsns, } from "../../lib/api/projects.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { @@ -87,9 +87,7 @@ async function listForOrganization( advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); const hasMore = !!nextCursor; - const command = asInvocationCommand( - `sentry dsn list ${target} --limit ${flags.limit}`, - ); + const command = sentry`dsn list ${target} --limit ${flags.limit}`; return { items: data.map((dsn) => ({ ...dsn, org })), diff --git a/packages/cli/src/commands/event/list.ts b/packages/cli/src/commands/event/list.ts index a7694ad93..371103f2d 100644 --- a/packages/cli/src/commands/event/list.ts +++ b/packages/cli/src/commands/event/list.ts @@ -9,7 +9,7 @@ import type { SentryContext } from "../../context.js"; import { listIssueEvents } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -59,10 +59,7 @@ function nextPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags( - asInvocationCommand(`sentry event list ${issueArg} -c next`), - flags, - ); + return appendEventsFlags(sentry`event list ${issueArg} -c next`, flags); } /** Build the CLI hint for fetching the previous page, preserving active flags. */ @@ -70,10 +67,7 @@ function prevPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags( - asInvocationCommand(`sentry event list ${issueArg} -c prev`), - flags, - ); + return appendEventsFlags(sentry`event list ${issueArg} -c prev`, flags); } // --------------------------------------------------------------------------- @@ -215,7 +209,7 @@ export const listCommand = buildListCommand("event", { const countText = `Showing ${events.length} event${events.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use '${asInvocationCommand("sentry event view <EVENT_ID>")}' to view full event details.`; + : `${countText} Use '${sentry`event view <EVENT_ID>`}' to view full event details.`; } yield new CommandOutput({ diff --git a/packages/cli/src/commands/event/send.ts b/packages/cli/src/commands/event/send.ts index 66616295e..8c26e216b 100644 --- a/packages/cli/src/commands/event/send.ts +++ b/packages/cli/src/commands/event/send.ts @@ -11,7 +11,7 @@ import type { DsnComponents, Event } from "@sentry/core"; import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { buildEventFromFlags, @@ -383,7 +383,7 @@ built entirely from the file contents. if (!flags.message?.length) { throw new ConfigError( "Provide a message via -m/--message or a JSON event file as a positional argument.", - asInvocationCommand("sentry event send -m 'My message'"), + sentry`event send -m 'My message'`, ); } const event = await buildEventFromFlags(flags); diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index a42ddf06a..485d7690f 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; import { type IssueSelector, issueSelectorHelp, @@ -187,7 +187,7 @@ export function jsonTransformEventView(data: EventViewData, fields?: string[]) { function replayHint(org: string, event: SentryEvent): string | undefined { const replayId = getReplayIdFromEvent(event); return replayId - ? `Related replay: ${asInvocationCommand(`sentry replay view ${org}/${replayId}`)}` + ? `Related replay: ${sentry`replay view ${org}/${replayId}`}` : undefined; } @@ -365,7 +365,7 @@ export function parseEventViewInput(args: readonly string[]): EventViewInput { // 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, [ - `'latest' resolves the newest event of an issue — pass an issue ID, e.g. '${asInvocationCommand("sentry event view 17370")}'`, + `'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}`, ]); } @@ -595,9 +595,7 @@ async function validateAndRecoverEventId( const result = await recoverHexId(rawEventId, "event", recoveryCtx); return handleRecoveryResult(result, err, { entityType: "event", - canonicalCommand: asInvocationCommand( - `sentry event view ${recoveryCtx.org || "<org>"}/${recoveryCtx.project ?? "<project>"}/<id>`, - ), + canonicalCommand: sentry`event view ${recoveryCtx.org || "<org>"}/${recoveryCtx.project ?? "<project>"}/<id>`, logTag: "event.view", }); } @@ -694,9 +692,7 @@ export async function resolveEventTarget( const resolved = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - asInvocationCommand( - `sentry event view <org>/${parsed.projectSlug} ${eventId}`, - ), + sentry`event view <org>/${parsed.projectSlug} ${eventId}`, parsed.originalSlug, ); target = { @@ -751,7 +747,7 @@ export async function resolveOrgAllTarget( throw new ResolutionError( `Event ${eventId} in organization "${org}"`, "not found", - asInvocationCommand(`sentry event view ${org}/<project> ${eventId}`), + sentry`event view ${org}/<project> ${eventId}`, ); } return { @@ -784,7 +780,7 @@ export async function resolveAutoDetectTarget( .withTag("event.view") .warn( `Found event in ${resolved.org}/${resolved.project}. ` + - `Use: ${asInvocationCommand(`sentry event view ${resolved.org}/${resolved.project} ${eventId}`)}`, + `Use: ${sentry`event view ${resolved.org}/${resolved.project} ${eventId}`}`, ); return { org: resolved.org, @@ -966,14 +962,14 @@ export async function fetchEventWithContext( // Nudge the user when the event ID looks like an issue short ID if (issueForm(eventId) === "short-id") { suggestions.unshift( - `This looks like an issue short ID. Try: ${asInvocationCommand(`sentry issue view ${eventId}`)}`, + `This looks like an issue short ID. Try: ${sentry`issue view ${eventId}`}`, ); } throw new ResolutionError( `Event '${eventId}'`, `not found in ${org}/${project}`, - asInvocationCommand(`sentry event view ${org}/<project> ${eventId}`), + sentry`event view ${org}/<project> ${eventId}`, suggestions, ); } @@ -1044,9 +1040,7 @@ async function resolveSelectorScope( return await resolveProjectBoundSlug( parsed.projectSlug, SELECTOR_HINT, - asInvocationCommand( - `sentry event view <org>/${parsed.projectSlug}/${selector}`, - ), + sentry`event view <org>/${parsed.projectSlug}/${selector}`, parsed.originalSlug, ); case ProjectSpecificationType.AutoDetect: { @@ -1134,10 +1128,7 @@ async function resolveIssueIdShortcut( : undefined; const resolved = await resolveOrg({ org: explicitOrg, cwd }); if (!resolved) { - throw new ContextError( - "Organization", - asInvocationCommand(`sentry event view ${issueId}`), - ); + throw new ContextError("Organization", sentry`event view ${issueId}`); } const org = resolved.org; log.info(`Fetching latest event for issue ${issueId}...`); @@ -1198,7 +1189,7 @@ async function resolveIssueShortcut( if (!resolved) { throw new ContextError( "Organization", - asInvocationCommand(`sentry issue view ${issueShortId}`), + sentry`issue view ${issueShortId}`, ); } @@ -1211,7 +1202,7 @@ async function resolveIssueShortcut( throw new ResolutionError( `Issue '${issueShortId}'`, "has no associated project", - asInvocationCommand(`sentry event view <org>/<project> ${eventId}`), + sentry`event view <org>/<project> ${eventId}`, ["Specify the project explicitly to view this event"], ); } @@ -1240,7 +1231,7 @@ async function resolveIssueShortcut( return { org: resolved.org, data, - hint: `Tip: Use '${asInvocationCommand(`sentry issue view ${issueShortId}`)}' to view the full issue`, + hint: `Tip: Use '${sentry`issue view ${issueShortId}`}' to view the full issue`, }; } diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index b55d1717f..9495c1bed 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -21,7 +21,7 @@ import { queryEventsTimeSeries, queryMetricsMeta, } from "../lib/api-client.js"; -import { asInvocationCommand } from "../lib/app-identity.js"; +import { sentry } from "../lib/app-identity.js"; import { validateLimit } from "../lib/arg-parsing.js"; import { choiceParser, @@ -433,8 +433,8 @@ function validateMetricsFields(fieldList: string[]): void { `Invalid metrics aggregate${badAggs.length > 1 ? "s" : ""}: ${badAggs.join(", ")}\n\n` + "The metrics dataset requires the format: aggregation(value,metric_name,metric_type,unit)\n\n" + "Examples:\n" + - ` ${asInvocationCommand('sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics')}\n` + - ` ${asInvocationCommand('sentry explore my-org/ -F gen_ai.request.model -F "avg(value,cache.hit_rate,distribution,none)" --dataset metrics')}\n\n` + + ` ${sentry`explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics`}\n` + + ` ${sentry`explore my-org/ -F gen_ai.request.model -F "avg(value,cache.hit_rate,distribution,none)" --dataset metrics`}\n\n` + "Parameters:\n" + ' - value: literal string "value"\n' + " - metric_name: the metric name emitted by the SDK (e.g., llm.token_usage)\n" + @@ -964,10 +964,10 @@ export const exploreCommand = buildListCommand("explore", { throw new ValidationError( "The metrics dataset requires --metric or explicit --field flags.\n\n" + "Auto mode (recommended):\n" + - ` ${asInvocationCommand("sentry explore my-org/ -m llm.token_usage --dataset metrics")}\n` + - ` ${asInvocationCommand("sentry explore my-org/ -m llm.token_usage --agg avg --dataset metrics")}\n\n` + + ` ${sentry`explore my-org/ -m llm.token_usage --dataset metrics`}\n` + + ` ${sentry`explore my-org/ -m llm.token_usage --agg avg --dataset metrics`}\n\n` + "Manual mode (tracemetrics format):\n" + - ` ${asInvocationCommand('sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics')}`, + ` ${sentry`explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics`}`, "field", ); } @@ -1074,11 +1074,11 @@ export const exploreCommand = buildListCommand("explore", { hasPrev, hasMore, prevHint: appendFlagHints( - asInvocationCommand(`sentry explore ${baseTarget} -c prev`), + sentry`explore ${baseTarget} -c prev`, hintFlags, ), nextHint: appendFlagHints( - asInvocationCommand(`sentry explore ${baseTarget} -c next`), + sentry`explore ${baseTarget} -c next`, hintFlags, ), }); diff --git a/packages/cli/src/commands/feedback/list.ts b/packages/cli/src/commands/feedback/list.ts index 539466878..f86335d5a 100644 --- a/packages/cli/src/commands/feedback/list.ts +++ b/packages/cli/src/commands/feedback/list.ts @@ -10,7 +10,7 @@ import { getProject, listFeedback, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { @@ -112,9 +112,7 @@ function pageHint( flags: Pick<ListFlags, "status" | "limit" | "query" | "period">, ): string { return appendFeedbackFlags( - asInvocationCommand( - `sentry feedback list ${formatScope(org, project)} -c ${direction}`, - ), + sentry`feedback list ${formatScope(org, project)} -c ${direction}`, flags, ); } @@ -289,7 +287,7 @@ export const listCommand = buildListCommand("feedback", { const count = `Showing ${feedback.length} feedback item${feedback.length === 1 ? "" : "s"}.`; const first = feedback[0]; const viewHint = first - ? `Use '${asInvocationCommand(`sentry feedback view ${resolved.org}/${first.shortId}`)}' for details.` + ? `Use '${sentry`feedback view ${resolved.org}/${first.shortId}`}' for details.` : undefined; hint = nav ? `${count} ${nav}` diff --git a/packages/cli/src/commands/feedback/positional.ts b/packages/cli/src/commands/feedback/positional.ts index 5c9d52d4a..b3f2d825e 100644 --- a/packages/cli/src/commands/feedback/positional.ts +++ b/packages/cli/src/commands/feedback/positional.ts @@ -3,7 +3,7 @@ * identifier, parsed before the command runs. */ -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -47,7 +47,7 @@ export function feedbackPositional(command: string) { if (!feedback) { throw new ContextError( "Feedback ID", - asInvocationCommand(`sentry feedback ${command} <feedback>`), + sentry`feedback ${command} <feedback>`, ); } return await resolveFeedback(feedback, context.cwd, command); diff --git a/packages/cli/src/commands/feedback/utils.ts b/packages/cli/src/commands/feedback/utils.ts index 794defbaf..e142860bf 100644 --- a/packages/cli/src/commands/feedback/utils.ts +++ b/packages/cli/src/commands/feedback/utils.ts @@ -3,7 +3,7 @@ */ import { listFeedback, updateIssueStatus } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import type { IssueSelector } from "../../lib/arg-parsing.js"; import { ApiError, @@ -49,7 +49,7 @@ async function resolveFeedbackSelector( if (!org) { throw new ContextError( "Organization", - asInvocationCommand(`sentry feedback ${command} <org>/@latest`), + sentry`feedback ${command} <org>/@latest`, ); } @@ -62,7 +62,7 @@ async function resolveFeedbackSelector( throw new ResolutionError( "Selector '@latest'", "found no unresolved User Feedback", - asInvocationCommand(`sentry feedback list ${org}/ --status all`), + sentry`feedback list ${org}/ --status all`, ["The @latest selector only matches unresolved Feedback."], ); } @@ -103,17 +103,15 @@ export async function resolveFeedback( issueArg: feedbackArg, cwd, command, - commandBase: asInvocationCommand("sentry feedback"), + commandBase: sentry`feedback`, }); } catch (error) { if (error instanceof ApiError && error.status === 404) { throw new ResolutionError( `Feedback '${text}'`, "not found", - buildCommandHint(command, text, asInvocationCommand("sentry feedback")), - [ - `List available Feedback: ${asInvocationCommand("sentry feedback list")}`, - ], + buildCommandHint(command, text, sentry`feedback`), + [`List available Feedback: ${sentry`feedback list`}`], ); } throw error; @@ -124,12 +122,8 @@ export async function resolveFeedback( throw new ResolutionError( `Issue '${issue.shortId}'`, "is not User Feedback", - asInvocationCommand( - `sentry issue view ${org ? `${org}/` : ""}${issue.shortId}`, - ), - [ - `Use a Feedback ID from: ${asInvocationCommand("sentry feedback list")}`, - ], + sentry`issue view ${org ? `${org}/` : ""}${issue.shortId}`, + [`Use a Feedback ID from: ${sentry`feedback list`}`], ); } diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 03a4903b5..65a405400 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -8,7 +8,7 @@ import type { SentryContext } from "../context.js"; import { getCurrentUser } from "../lib/api/users.js"; -import { asInvocationCommand } from "../lib/app-identity.js"; +import { sentry } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { DEFAULT_SENTRY_URL } from "../lib/constants.js"; import { isAuthenticated } from "../lib/db/auth.js"; @@ -180,8 +180,8 @@ export const infoCommand = buildCommand({ this.process.exitCode = 1; return { hint: failed - ? `Not authenticated. Run \`${asInvocationCommand("sentry auth login")}\`.` - : `No default organization/project. Set them with \`${asInvocationCommand("sentry cli defaults org <org>")}\` and \`${asInvocationCommand("sentry cli defaults project <project>")}\`.`, + ? `Not authenticated. Run \`${sentry`auth login`}\`.` + : `No default organization/project. Set them with \`${sentry`cli defaults org <org>`}\` and \`${sentry`cli defaults project <project>`}\`.`, }; } return { hint: "Configuration verified." }; diff --git a/packages/cli/src/commands/issue/events.ts b/packages/cli/src/commands/issue/events.ts index 07b5c9514..7fc35a948 100644 --- a/packages/cli/src/commands/issue/events.ts +++ b/packages/cli/src/commands/issue/events.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { listIssueEvents } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -53,10 +53,7 @@ function nextPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags( - asInvocationCommand(`sentry issue events ${issueArg} -c next`), - flags, - ); + return appendEventsFlags(sentry`issue events ${issueArg} -c next`, flags); } /** Build the CLI hint for fetching the previous page, preserving active flags. */ @@ -64,10 +61,7 @@ function prevPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags( - asInvocationCommand(`sentry issue events ${issueArg} -c prev`), - flags, - ); + return appendEventsFlags(sentry`issue events ${issueArg} -c prev`, flags); } // --------------------------------------------------------------------------- @@ -191,7 +185,7 @@ export const eventsCommand = buildListCommand("issue", { const countText = `Showing ${events.length} event${events.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use '${asInvocationCommand("sentry event view <EVENT_ID>")}' to view full event details.`; + : `${countText} Use '${sentry`event view <EVENT_ID>`}' to view full event details.`; } yield new CommandOutput({ diff --git a/packages/cli/src/commands/issue/explain.ts b/packages/cli/src/commands/issue/explain.ts index af20c9b30..8cd367883 100644 --- a/packages/cli/src/commands/issue/explain.ts +++ b/packages/cli/src/commands/issue/explain.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError, stringifyUnknown } from "../../lib/errors.js"; @@ -188,7 +188,7 @@ export const explainCommand = buildCommand({ return isBatch ? undefined : { - hint: `To create a plan, run: ${asInvocationCommand(`sentry issue plan ${primaryArg}`)}`, + hint: `To create a plan, run: ${sentry`issue plan ${primaryArg}`}`, }; }, }); diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 24da1dd59..f674bac31 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -14,7 +14,7 @@ import { listIssuesAllPages, } from "../../lib/api-client.js"; import { extractRequiredScopes } from "../../lib/api-scope.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { CURSOR_KEYWORDS, formatProjectTarget, @@ -385,11 +385,11 @@ function formatListHeader(title: string): string { function formatListFooter(mode: "single" | "multi" | "none"): string { switch (mode) { case "single": - return `\nTip: Use '${asInvocationCommand("sentry issue view <ID>")}' to view details (bold part works as shorthand).`; + return `\nTip: Use '${sentry`issue view <ID>`}' to view details (bold part works as shorthand).`; case "multi": - return `\nTip: Use '${asInvocationCommand("sentry issue view <ALIAS>")}' to view details (see ALIAS column).`; + return `\nTip: Use '${sentry`issue view <ALIAS>`}' to view details (see ALIAS column).`; default: - return `\nTip: Use '${asInvocationCommand("sentry issue view <SHORT_ID>")}' to view issue details.`; + return `\nTip: Use '${sentry`issue view <SHORT_ID>`}' to view issue details.`; } } @@ -722,7 +722,7 @@ function trimWithProjectGuarantee( /** Re-run the failed search with an explicit target, preserving search and output flags. */ function contextRecoveryCommand(target: string, flags: ListFlags): string { const parts = [ - asInvocationCommand(`sentry issue list ${shellQuote(target)}`), + sentry`issue list ${shellQuote(target)}`, `--limit ${flags.limit}`, `--sort ${flags.sort}`, `--period ${shellQuote(formatTimeRangeFlag(flags.period))}`, @@ -766,10 +766,7 @@ function pageHint( target: string, flags: ListFlags, ): string { - return appendIssueFlags( - asInvocationCommand(`sentry issue list ${target} -c ${direction}`), - flags, - ); + return appendIssueFlags(sentry`issue list ${target} -c ${direction}`, flags); } /** @@ -1067,7 +1064,7 @@ function build400Detail( } suggestions.push( - `Verify you have access to the target project: ${asInvocationCommand("sentry project list <org>/")}`, + `Verify you have access to the target project: ${sentry`project list <org>/`}`, ); return formatDetailWithSuggestions(lines, suggestions); @@ -1186,14 +1183,10 @@ function build403Detail(originalDetail: unknown): string { " • Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/", ); } else { - lines.push( - ` • Re-authenticate with: ${asInvocationCommand("sentry auth login")}`, - ); + lines.push(` • Re-authenticate with: ${sentry`auth login`}`); } - lines.push( - ` • Verify project membership: ${asInvocationCommand("sentry project list <org>/")}`, - ); + lines.push(` • Verify project membership: ${sentry`project list <org>/`}`); return lines.join("\n "); } @@ -1206,7 +1199,7 @@ function build403Detail(originalDetail: unknown): string { */ function appendProjectMembershipHint(detail: string | undefined): string { const base = detail ?? "You do not have permission to perform this action."; - return `${base}\n Verify project membership: ${asInvocationCommand("sentry project list <org>/")}`; + return `${base}\n Verify project membership: ${sentry`project list <org>/`}`; } /** @@ -1825,7 +1818,7 @@ export const listCommand = buildListCommand("issue", { compact: true, } satisfies IssueListResult); return { - hint: `Tip: Use '${asInvocationCommand(`sentry issue view ${shortId}`)}' for full details`, + hint: `Tip: Use '${sentry`issue view ${shortId}`}' for full details`, }; } diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index e9fcf6d24..1cadc34a8 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -22,7 +22,7 @@ import type { SentryContext } from "../../context.js"; import { type MergeIssuesResult, mergeIssues } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseIssueReference } from "../../lib/completion-target.js"; import { @@ -338,10 +338,10 @@ export const mergeCommand = buildCommand({ if (effectiveArgs.length < 2) { const hint = args.length === 1 - ? `Tip: you can also write: ${asInvocationCommand("sentry issue merge CLI-K9 --into CLI-15H")}` - : `Example: ${asInvocationCommand("sentry issue merge CLI-K9 CLI-15H CLI-15N")}`; + ? `Tip: you can also write: ${sentry`issue merge CLI-K9 --into CLI-15H`}` + : `Example: ${sentry`issue merge CLI-K9 CLI-15H CLI-15N`}`; throw new ValidationError( - `'${asInvocationCommand(`sentry ${COMMAND_PATH}`)}' needs at least 2 issue IDs (got ${args.length}).\n\n` + + `'${sentry`${COMMAND_PATH}`}' needs at least 2 issue IDs (got ${args.length}).\n\n` + hint, ); } diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 096e76ca6..2594520ae 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -8,7 +8,7 @@ import { isatty } from "node:tty"; import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ApiError, CliError } from "../../lib/errors.js"; @@ -319,7 +319,7 @@ export const planCommand = buildCommand({ "Plan creation timed out after 6 minutes. Try again or check the issue in Sentry web UI.", timeoutHint: "The plan may still be generated in the background.\n" + - ` Or retry: ${asInvocationCommand(`sentry issue plan ${issueArg.text}`)}`, + ` Or retry: ${sentry`issue plan ${issueArg.text}`}`, }); if (finalState.status === "ERROR") { diff --git a/packages/cli/src/commands/issue/positional.ts b/packages/cli/src/commands/issue/positional.ts index 16eae8ea7..e776dc866 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 { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { ISSUE_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -67,8 +67,7 @@ export function requireIssueArgument( if (issue) { return issue; } - const { command, commandBase = asInvocationCommand("sentry issue") } = - issueCommand; + const { command, commandBase = sentry`issue` } = issueCommand; throw new ContextError("Issue ID", `${commandBase} ${command} <issue>`); } @@ -80,8 +79,7 @@ export function requireIssueArgument( function issueTarget( issueCommand: IssueCommand, ): (this: IssueTargetContext, input?: string) => IssueTarget { - const { command, commandBase = asInvocationCommand("sentry issue") } = - issueCommand; + const { command, commandBase = sentry`issue` } = issueCommand; return boundPositional( ISSUE_TARGET, parseIssueTargetInput, diff --git a/packages/cli/src/commands/issue/resolve-commit-spec.ts b/packages/cli/src/commands/issue/resolve-commit-spec.ts index 0dcd53771..8da5e645d 100644 --- a/packages/cli/src/commands/issue/resolve-commit-spec.ts +++ b/packages/cli/src/commands/issue/resolve-commit-spec.ts @@ -19,7 +19,7 @@ import { execFileSync } from "node:child_process"; import type { ResolveCommitSpec } from "../../lib/api/issues.js"; import { listRepositoriesCached } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { ValidationError } from "../../lib/errors.js"; import { getHeadCommit, @@ -89,7 +89,7 @@ function formatAvailableRepos(repos: SentryRepository[]): string { .join("\n"); const more = repos.length > MAX - ? `\n ... and ${repos.length - MAX} more (${asInvocationCommand("sentry repo list <org>/")})` + ? `\n ... and ${repos.length - MAX} more (${sentry`repo list <org>/`})` : ""; return `Available repositories in this organization:\n${names}${more}`; } diff --git a/packages/cli/src/commands/issue/resolve.ts b/packages/cli/src/commands/issue/resolve.ts index d54ae5f89..38b732fb3 100644 --- a/packages/cli/src/commands/issue/resolve.ts +++ b/packages/cli/src/commands/issue/resolve.ts @@ -24,7 +24,7 @@ import { type ResolveStatusDetails, updateIssueStatus, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; @@ -185,9 +185,7 @@ export const resolveCommand = buildCommand({ // issue's org — we can't resolve the commit without knowing the org. throw new ContextError( "Organization", - asInvocationCommand( - "sentry issue resolve <org>/<issue> --in @commit", - ), + sentry`issue resolve <org>/<issue> --in @commit`, [], "--in @commit needs an organization context to look up the Sentry repo registry.", ); diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 1b388b0f3..bfcd05b1f 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -21,7 +21,7 @@ import { triggerRootCauseAnalysis, tryGetIssueByShortId, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import type { IssueSelector } from "../../lib/arg-parsing.js"; import { clearCachedIssueOrg, @@ -131,7 +131,7 @@ export async function mapIssueArgsConcurrently<T>( export function buildCommandHint( command: string, issueId: string, - base = asInvocationCommand("sentry issue"), + base = sentry`issue`, trailingArgs: readonly string[] = [], ): string { return appendCommandArguments( @@ -626,9 +626,7 @@ export async function findSelectorIssue( throw new ResolutionError( `Selector '${selector}'`, "no unresolved issues found", - asInvocationCommand( - `sentry issue list ${org}/${project ?? ""} -q "is:resolved"`, - ), + sentry`issue list ${org}/${project ?? ""} -q "is:resolved"`, [ `The ${SELECTOR_LABELS[selector]} issue selector only matches unresolved issues.`, ], @@ -673,9 +671,7 @@ async function resolveIssueSearch( return { org, issue }; } - const listCommand = asInvocationCommand( - `sentry issue list ${org}/ --query ${shellQuote(query)}`, - ); + const listCommand = sentry`issue list ${org}/ --query ${shellQuote(query)}`; if (!issue) { throw new ResolutionError( `Search ${shellQuote(query)}`, @@ -930,8 +926,7 @@ export async function resolveIssue( options: ResolveIssueOptions, ): Promise<ResolvedIssueResult> { const { cwd, command, commandBase, trailingArgs = [] } = options; - const effectiveCommandBase = - commandBase ?? asInvocationCommand("sentry issue"); + const effectiveCommandBase = commandBase ?? sentry`issue`; const { text, parsed, baseUrl } = issueArgumentOf(options.issueArg); if (baseUrl) { applySentryUrlContext(baseUrl); @@ -1222,7 +1217,7 @@ export async function pollAutofixState( timeoutHint ?? "The analysis may still complete in the background.\n" + ` View in Sentry: ${issueUrl}\n` + - ` Or retry: ${asInvocationCommand(`sentry issue explain ${issueId}`)}`; + ` Or retry: ${sentry`issue explain ${issueId}`}`; return await poll<AutofixState>({ fetchState: () => getAutofixState(orgSlug, issueId), diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index 2159eceae..b0923cb87 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { getLatestEvent, listReplayIdsForIssue } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -444,7 +444,7 @@ export const viewCommand = buildCommand({ requestedCount: issueArgs.length, }); return { - hint: `Tip: Use '${asInvocationCommand(`sentry issue explain ${primaryArg}`)}' for AI root cause analysis`, + hint: `Tip: Use '${sentry`issue explain ${primaryArg}`}' for AI root cause analysis`, }; }, }); diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 1c3fd20be..939c99cd0 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -21,7 +21,7 @@ import { import type { Server } from "node:http"; import { basename, resolve } from "node:path"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceParser, @@ -337,7 +337,7 @@ function augmentPathForNode( function autoDetectErrorMessage(): string { return [ "No command provided and could not auto-detect a dev script.", - `Usage: ${asInvocationCommand("sentry local run -- <command>")}`, + `Usage: ${sentry`local run -- <command>`}`, "", "Supported auto-detection:", " - package.json (scripts: dev, develop, serve, start)", @@ -597,7 +597,7 @@ export const runCommand = buildCommand({ logger.info(`Starting: ${bold(wrangler.args.join(" "))}`); logger.info( daemonSessionId - ? `SENTRY_SPOTLIGHT configured for session ${daemonSessionId}. Use ${asInvocationCommand(`sentry local session view ${daemonSessionId} --json`)} for its ingest URL.` + ? `SENTRY_SPOTLIGHT configured for session ${daemonSessionId}. Use ${sentry`local session view ${daemonSessionId} --json`} for its ingest URL.` : `SENTRY_SPOTLIGHT=${spotlightUrl}`, ); diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index cab18991e..d9f40cd04 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1,4 +1,4 @@ -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { choiceParser, parseBindHost, @@ -1137,7 +1137,7 @@ export const serverCommand = buildCommand({ printLocalViewerHint(session.streamUrl, flags.format === "json"); logger.info(` Session: ${bold(session.id)} (${session.label})`); logger.info( - ` Capabilities: ${asInvocationCommand(`sentry local session view ${session.id} --json`)}`, + ` Capabilities: ${sentry`local session view ${session.id} --json`}`, ); logger.info("Press Ctrl-C to stop."); await openLocalUiIfRequested( @@ -1248,7 +1248,7 @@ export const serverCommand = buildCommand({ ` Set ${bold("SENTRY_SPOTLIGHT")}=${listenUrl}/stream in your app`, ); logger.info( - ` Or run: ${bold(asInvocationCommand(`sentry local run -p ${boundPort} -- <your-command>`))}`, + ` Or run: ${bold(sentry`local run -p ${boundPort} -- <your-command>`)}`, ); if (activeFilters.size > 0) { logger.info(` Filtering: ${[...activeFilters].join(", ")}`); diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts index e86f1c802..9b6c5b964 100644 --- a/packages/cli/src/commands/local/ui.ts +++ b/packages/cli/src/commands/local/ui.ts @@ -1,4 +1,4 @@ -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { isLoopbackHost, normalizeLocalHostname } from "./loopback.js"; import { openBrowser } from "../../lib/browser.js"; import { getCliEnvironment } from "../../lib/constants.js"; @@ -118,7 +118,7 @@ export async function openLocalUi( const streamUrl = new URL(receiverUrl); if (streamUrl.searchParams.has("cap") || directoryUrl) { logger.warn( - `Could not open browser. Use ${asInvocationCommand("sentry local session view <session> --json")} to retrieve its stream URL.`, + `Could not open browser. Use ${sentry`local session view <session> --json`} to retrieve its stream URL.`, ); return; } diff --git a/packages/cli/src/commands/log/list.ts b/packages/cli/src/commands/log/list.ts index e3fe68a9a..c9973fb8d 100644 --- a/packages/cli/src/commands/log/list.ts +++ b/packages/cli/src/commands/log/list.ts @@ -12,7 +12,7 @@ import { listLogs, listTraceLogs, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildProjectQuery, type ParsedOrgProject, @@ -582,7 +582,7 @@ async function executeTraceSingleFetch( result: { logs: [], traceId, hasMore: false }, hint: `No logs found for trace ${traceId} ${periodLabel}.\n\n` + - `Try '${asInvocationCommand("sentry trace logs")}' for more options (e.g., --period 30d).`, + `Try '${sentry`trace logs`}' for more options (e.g., --period 30d).`, }; } @@ -593,9 +593,9 @@ async function executeTraceSingleFetch( // Build hint with real values for easy copy-paste let hint = `${countText}${tip}`; if (projectFilter) { - hint += `\nFiltered to project '${projectFilter}'. Full trace logs: ${asInvocationCommand(`sentry log list ${org}/${traceId}`)}`; + hint += `\nFiltered to project '${projectFilter}'. Full trace logs: ${sentry`log list ${org}/${traceId}`}`; } else { - hint += `\nFilter by project: ${asInvocationCommand(`sentry log list ${org}/<project>/${traceId}`)}`; + hint += `\nFilter by project: ${sentry`log list ${org}/<project>/${traceId}`}`; } return { diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 869229682..15f4a2753 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -10,7 +10,7 @@ import pLimit from "p-limit"; import type { SentryContext } from "../../context.js"; import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { looksLikeIssueShortId, type OrgProjectInput, @@ -112,7 +112,7 @@ export function parsePositionalArgs(args: readonly string[]): { ...(args.length > 1 && first && looksLikeIssueShortId(first) && { - suggestion: `Did you mean: ${asInvocationCommand(`sentry issue view ${first}`)}`, + suggestion: `Did you mean: ${sentry`issue view ${first}`}`, }), }; } @@ -140,9 +140,7 @@ async function validateAndRecoverLogId( }); return handleRecoveryResult(result, err, { entityType: "log", - canonicalCommand: asInvocationCommand( - `sentry log view ${target.org}/${target.project}/<id>`, - ), + canonicalCommand: sentry`log view ${target.org}/${target.project}/<id>`, logTag: "log.view", }); } @@ -181,9 +179,7 @@ async function resolveTarget( const result = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - asInvocationCommand( - `sentry log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, - ), + sentry`log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, parsed.originalSlug, ); if ( @@ -384,7 +380,7 @@ function throwNotFoundError( throw new ResolutionError( `Log '${id}'`, `not found in ${org}/${project}`, - asInvocationCommand(`sentry log view ${org}/${project}/${id}`), + sentry`log view ${org}/${project}/${id}`, suggestions, ); } diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 25ddb4508..6ba8fcc2d 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -10,7 +10,7 @@ import type { InferOutput } from "valibot"; import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseAccountSelector, @@ -321,13 +321,11 @@ function mergeAccountOrgs(results: AccountOrgs[]): { */ function reloginCommand(account: AccountSummary): string { if (account.email) { - return asInvocationCommand( - `sentry auth login ${accountSelectorOf(account)}`, - ); + return sentry`auth login ${accountSelectorOf(account)}`; } return account.host - ? asInvocationCommand(`sentry auth login --url ${account.host}`) - : asInvocationCommand("sentry auth login"); + ? sentry`auth login --url ${account.host}` + : sentry`auth login`; } /** @@ -354,7 +352,7 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { } const account = resolveAccountSelector( selector, - asInvocationCommand("sentry org list --account"), + sentry`org list --account`, { fuzzy: true, }, @@ -513,12 +511,10 @@ export const listCommand = buildCommand({ hints.push(`Showing ${flags.limit} of ${orgs.length} organizations`); } if (entries.length > 0) { - hints.push( - `Tip: Use '${asInvocationCommand("sentry org view <slug>")}' for details`, - ); + hints.push(`Tip: Use '${sentry`org view <slug>`}' for details`); } else if (filter && merged.orgs.length > 0) { hints.push( - `None of your ${merged.orgs.length} organizations match '${filter}'. Run '${asInvocationCommand("sentry org list")}' to see them all.`, + `None of your ${merged.orgs.length} organizations match '${filter}'. Run '${sentry`org list`}' to see them all.`, ); } diff --git a/packages/cli/src/commands/proguard/uuid.ts b/packages/cli/src/commands/proguard/uuid.ts index bb5f438bf..c359faa3c 100644 --- a/packages/cli/src/commands/proguard/uuid.ts +++ b/packages/cli/src/commands/proguard/uuid.ts @@ -8,7 +8,7 @@ import { readFile } from "node:fs/promises"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -71,7 +71,7 @@ export const uuidCommand = buildCommand({ if (!path?.trim()) { throw new ContextError( "Mapping file path", - asInvocationCommand("sentry proguard uuid <path>"), + sentry`proguard uuid <path>`, [], ); } diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 104800401..c46c2052e 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, 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"; @@ -135,10 +135,10 @@ function buildPlatformError(nameArg: string, platform?: string): string { `${heading}\n` + didYouMean + "\nUsage:\n" + - ` ${asInvocationCommand(`sentry project create ${nameArg}:<platform>`)}\n\n` + + ` ${sentry`project create ${nameArg}:<platform>`}\n\n` + `Common platforms:\n\n${platformTable}\n` + - `Run '${asInvocationCommand("sentry platform list")}' to see all valid platform identifiers.\n` + - `Run '${asInvocationCommand("sentry project create <name>:<platform>")}' with any valid Sentry platform identifier.` + `Run '${sentry`platform list`}' to see all valid platform identifiers.\n` + + `Run '${sentry`project create <name>:<platform>`}' with any valid Sentry platform identifier.` ); } @@ -181,9 +181,7 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Team '${teamSlug}'`, `not found in ${orgSlug}`, - asInvocationCommand( - `sentry project create ${orgSlug}/${name}:${platform} --team <team-slug>`, - ), + sentry`project create ${orgSlug}/${name}:${platform} --team <team-slug>`, [`Available teams: ${teams.map((t) => t.slug).join(", ")}`], ); } @@ -203,12 +201,10 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Project '${name}' in ${orgSlug}`, "could not be created", - asInvocationCommand( - `sentry project create ${orgSlug}/${name}:${platform} --team <team-slug>`, - ), + sentry`project create ${orgSlug}/${name}:${platform} --team <team-slug>`, [ "The organization or team may not exist, or you may lack access", - `List teams: ${asInvocationCommand(`sentry team list ${orgSlug}/`)}`, + `List teams: ${sentry`team list ${orgSlug}/`}`, ], ); } @@ -325,7 +321,7 @@ function projectExistsError(orgSlug: string, name: string): CliError { const slug = slugify(name); return new CliError( `A project named '${name}' already exists in ${orgSlug}.\n\n` + - `View it: ${asInvocationCommand(`sentry project view ${orgSlug}/${slug}`)}`, + `View it: ${sentry`project view ${orgSlug}/${slug}`}`, ); } diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 6bc150ba4..7b4093482 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -25,7 +25,7 @@ import { getOrganization, getProject, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { getCachedOrgRole } from "../../lib/db/regions.js"; import { ApiError } from "../../lib/errors.js"; import { @@ -102,7 +102,7 @@ async function buildPermissionError( return new ApiError( `Permission denied: cannot delete ${label}.\n\n` + `This requires a ${rolesWithAccess} role, or a token with the 'project:admin' scope.\n` + - ` Check your role: ${asInvocationCommand(`sentry org view ${orgSlug}`)}`, + ` Check your role: ${sentry`org view ${orgSlug}`}`, 403, ); } diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 192e379bb..d4f2d3662 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -21,7 +21,7 @@ import { type PaginatedResponse, searchProjects, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { explicitProjectSlugs, formatProjectTarget, @@ -376,9 +376,7 @@ async function fetchAutoDetectProjects( /** Build a pagination hint for auto-detect JSON output. */ function autoDetectPaginationHint(orgs: string[], filter?: string): string { const org = orgs.length === 1 ? (orgs[0] as string) : "<org>"; - return asInvocationCommand( - `sentry project list ${orgFilterTarget(org, filter, shellQuote)} --json`, - ); + return sentry`project list ${orgFilterTarget(org, filter, shellQuote)} --json`; } /** Build the truncation header for auto-detect mode. */ @@ -395,7 +393,7 @@ function autoDetectHeader( const org = orgs[0] as string; return ( `Showing ${count} projects (more available). ` + - `Use '${asInvocationCommand(`sentry project list ${org}/`)}' for paginated results.` + `Use '${sentry`project list ${org}/`}' for paginated results.` ); } return `Showing ${count} projects (more available). Use --limit to show more.`; @@ -410,7 +408,7 @@ function selfHostedWarning( } return ( `Note: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - `Specify the organization explicitly: ${asInvocationCommand("sentry project list <org>/")}` + `Specify the organization explicitly: ${sentry`project list <org>/`}` ); } @@ -461,7 +459,7 @@ export async function handleAutoDetect( hintParts.push(footer); } hintParts.push( - `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`, + `Tip: Use '${sentry`project view <org>/<project>`}' for details`, ); } @@ -512,7 +510,7 @@ export async function handleExplicit( items: [], hint: `No project '${projectSlug}' in '${org}', and no projects matching '${projectSlug}' on platform '${flags.platform}'.\n` + - `Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, + `Tip: Use '${sentry`project list ${org}/`}' to see all projects`, }; } } @@ -520,7 +518,7 @@ export async function handleExplicit( items: [], hint: `No project '${projectSlug}' found in organization '${org}'.\n` + - `Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, + `Tip: Use '${sentry`project list ${org}/`}' to see all projects`, }; } const project: ProjectWithOrg = { ...projectResult.value, orgSlug: org }; @@ -536,7 +534,7 @@ export async function handleExplicit( return { items: filtered, - hint: `Tip: Use '${asInvocationCommand(`sentry project view ${org}/${projectSlug}`)}' for details`, + hint: `Tip: Use '${sentry`project view ${org}/${projectSlug}`}' for details`, }; } @@ -576,14 +574,14 @@ async function handleExplicitProjects( items: [], hint: `No projects found among: ${slugs.map((slug) => `'${slug}'`).join(", ")}.\n` + - `Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, + `Tip: Use '${sentry`project list ${org}/`}' to see all projects`, }; } const hintParts: string[] = []; if (missing.length > 0) { hintParts.push( - `Missing: ${missing.join(", ")}. Tip: Use '${asInvocationCommand(`sentry project list ${org}/`)}' to see all projects`, + `Missing: ${missing.join(", ")}. Tip: Use '${sentry`project list ${org}/`}' to see all projects`, ); } if (nameFiltered.length > 0) { @@ -598,7 +596,7 @@ async function handleExplicitProjects( } if (hintParts.length === 0) { hintParts.push( - `Tip: Use '${asInvocationCommand(`sentry project view ${org}/<project>`)}' for details`, + `Tip: Use '${sentry`project view ${org}/<project>`}' for details`, ); } @@ -663,7 +661,7 @@ export async function handleOrgAll( let hint: string | undefined; let header: string | undefined; - const base = asInvocationCommand(`sentry project list ${org}/`); + const base = sentry`project list ${org}/`; const suffix = flags.platform ? ` --platform ${flags.platform}` : ""; const navHint = paginationHint({ hasPrev, @@ -691,7 +689,7 @@ export async function handleOrgAll( } else { header = `Showing ${filtered.length} projects`; } - const tip = `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`; + const tip = `Tip: Use '${sentry`project view <org>/<project>`}' for details`; hint = tip; } @@ -738,7 +736,7 @@ async function handleOrgAllFiltered( matches.length > items.length ? `Showing ${items.length} of ${matches.length} matches. Use --limit to show more.` : `Showing ${items.length} projects`, - hint: `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`, + hint: `Tip: Use '${sentry`project view <org>/<project>`}' for details`, }; } @@ -824,7 +822,7 @@ export async function handleProjectSearch( return { items: limited, header, - hint: `Tip: Use '${asInvocationCommand("sentry project view <org>/<project>")}' for details`, + hint: `Tip: Use '${sentry`project view <org>/<project>`}' for details`, }; } @@ -839,7 +837,7 @@ function handleProjectSearchNotFound( throw new ResolutionError( `Project '${resolution.displaySlug}'`, "not found", - asInvocationCommand(`sentry project list <org>/${projectSlug}`), + sentry`project list <org>/${projectSlug}`, projectSearchNotFoundSuggestions(resolution), ); } diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index bfa0657b9..ac3728605 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -297,7 +297,7 @@ async function resolveViewTargets( const resolved = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - asInvocationCommand(`sentry project view <org>/${parsed.projectSlug}`), + sentry`project view <org>/${parsed.projectSlug}`, parsed.originalSlug, ); return { @@ -316,7 +316,7 @@ async function resolveViewTargets( case ProjectSpecificationType.OrgAll: throw new ContextError( "Specific project", - asInvocationCommand(`sentry project view ${parsed.org}/<project>`), + sentry`project view ${parsed.org}/<project>`, ["Specify the full org/project target, not just the organization"], ); diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index d438c905a..d49b03ece 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -7,7 +7,7 @@ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseProjectList, @@ -207,7 +207,7 @@ export const createCommand = buildCommand({ const hint = flags.finalize ? "Release created and finalized." - : `Release created. Finalize with: ${asInvocationCommand(`sentry release finalize ${version}`)}`; + : `Release created. Finalize with: ${sentry`release finalize ${version}`}`; return { hint }; }, }); diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index 0261184e7..7212a200b 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -14,7 +14,7 @@ import { listReleasesPaginated, type ReleaseSortValue, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; import { choiceParser, @@ -261,7 +261,7 @@ function buildReleaseListConfig( return { paginationKey: PAGINATION_KEY, entityPlural: "releases", - commandPrefix: asInvocationCommand("sentry release list"), + commandPrefix: sentry`release list`, listForOrg: async (org) => { const { data } = await listReleasesPaginated(org, { perPage: 100, @@ -504,7 +504,7 @@ async function handleAutoDetectWithProject( if (unique.length === 0) { return { items: [], - hint: `No project detected. Specify a target: ${asInvocationCommand("sentry release list <org>/<project>")}`, + hint: `No project detected. Specify a target: ${sentry`release list <org>/<project>`}`, }; } diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index 54f50432c..eebe71908 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -14,7 +14,7 @@ import { setCommitsLocal, setCommitsWithRefs, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { parseFilePath, @@ -71,12 +71,8 @@ function setCommitsFromLocal( throw validationError( `Cannot read local commits: ${cwd} is not a git checkout.`, [ - asInvocationCommand( - `sentry release set-commits ${org}/${version} --commit owner/repo@<commit-sha>`, - ), - asInvocationCommand( - `sentry release set-commits ${org}/${version} --auto`, - ), + sentry`release set-commits ${org}/${version} --commit owner/repo@<commit-sha>`, + sentry`release set-commits ${org}/${version} --auto`, ], "local", "--local reads git history, so it needs the .git directory. Builds that exclude it (Docker, Vercel) can pass the commit with --commit.", @@ -248,12 +244,8 @@ function parseCommitRefs( throw validationError( `Invalid commit format '${trimmed}'. Expected REPO@SHA or REPO@PREV..SHA.`, [ - asInvocationCommand( - "sentry release set-commits 1.0.0 --commit owner/repo@abc123", - ), - asInvocationCommand( - "sentry release set-commits 1.0.0 --commit owner/repo@prev..abc123", - ), + sentry`release set-commits 1.0.0 --commit owner/repo@abc123`, + sentry`release set-commits 1.0.0 --commit owner/repo@prev..abc123`, ], "commit", ); @@ -303,11 +295,7 @@ function parseLocalScope(flags: { if (flags.path !== undefined && paths.length === 0) { throw validationError( "--path requires at least one non-empty path.", - [ - asInvocationCommand( - "sentry release set-commits 1.0.0 --path apps/mobile,packages/shared", - ), - ], + [sentry`release set-commits 1.0.0 --path apps/mobile,packages/shared`], "path", ); } @@ -316,20 +304,12 @@ function parseLocalScope(flags: { "--path cannot be combined with --auto or --commit. Those modes expand commit ranges on the server; --path filters local git history only.", flags.commit ? [ - asInvocationCommand( - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - ), - asInvocationCommand( - "sentry release set-commits 1.0.0 --local --path apps/mobile", - ), + sentry`release set-commits 1.0.0 --from v0.9.0 --path apps/mobile`, + sentry`release set-commits 1.0.0 --local --path apps/mobile`, ] : [ - asInvocationCommand( - "sentry release set-commits 1.0.0 --local --path apps/mobile", - ), - asInvocationCommand( - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - ), + sentry`release set-commits 1.0.0 --local --path apps/mobile`, + sentry`release set-commits 1.0.0 --from v0.9.0 --path apps/mobile`, ], "path", ); @@ -339,7 +319,7 @@ function parseLocalScope(flags: { if (flags.from !== undefined && !from) { throw validationError( "--from requires a non-empty git ref (tag, branch, or commit).", - [asInvocationCommand("sentry release set-commits 1.0.0 --from v0.9.0")], + [sentry`release set-commits 1.0.0 --from v0.9.0`], "from", FROM_RANGE_NOTE, ); @@ -351,10 +331,8 @@ function parseLocalScope(flags: { throw validationError( `--from must be a git ref, not a CLI flag (received '${from}').`, [ - asInvocationCommand("sentry release set-commits 1.0.0 --from v0.9.0"), - asInvocationCommand( - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - ), + sentry`release set-commits 1.0.0 --from v0.9.0`, + sentry`release set-commits 1.0.0 --from v0.9.0 --path apps/mobile`, ], "from", FROM_DASHED_REF_NOTE, @@ -364,12 +342,8 @@ function parseLocalScope(flags: { throw validationError( "--from cannot be combined with --auto or --commit. Those modes expand commit ranges on the server; --from reads local git history.", [ - asInvocationCommand( - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - ), - asInvocationCommand( - "sentry release set-commits 1.0.0 --local --initial-depth 50", - ), + sentry`release set-commits 1.0.0 --from v0.9.0 --path apps/mobile`, + sentry`release set-commits 1.0.0 --local --initial-depth 50`, ], "from", FROM_RANGE_NOTE, @@ -535,13 +509,9 @@ export const setCommitsCommand = buildCommand({ throw validationError( "Only one of --auto, --local, or --commit can be used at a time.", [ - asInvocationCommand("sentry release set-commits 1.0.0 --auto"), - asInvocationCommand( - "sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile", - ), - asInvocationCommand( - "sentry release set-commits 1.0.0 --commit owner/repo@abc123..def456", - ), + sentry`release set-commits 1.0.0 --auto`, + sentry`release set-commits 1.0.0 --from v0.9.0 --path apps/mobile`, + sentry`release set-commits 1.0.0 --commit owner/repo@abc123..def456`, ], "commit", ); diff --git a/packages/cli/src/commands/replay/download.ts b/packages/cli/src/commands/replay/download.ts index 2259ca657..6f1e61289 100644 --- a/packages/cli/src/commands/replay/download.ts +++ b/packages/cli/src/commands/replay/download.ts @@ -12,7 +12,7 @@ import { getReplayRecordingSegments, resolveReplay, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath } from "../../lib/completion-target.js"; import { ApiError, ResolutionError } from "../../lib/errors.js"; @@ -119,9 +119,7 @@ export const downloadCommand = buildCommand({ throw new ResolutionError( `Replay '${replayId}'`, "not found", - asInvocationCommand( - `sentry replay download ${resolved.org}/${replayId}`, - ), + sentry`replay download ${resolved.org}/${replayId}`, [ "Check that you are querying the right organization", "The replay may be past your retention window", @@ -182,7 +180,7 @@ function noRecordingError(org: string, replayId: string): ResolutionError { return new ResolutionError( `Replay '${replayId}'`, "has no recording to download", - asInvocationCommand(`sentry replay view ${org}/${replayId}`), + sentry`replay view ${org}/${replayId}`, ["The replay may be archived or past your retention window"], ); } diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 2f6b79f76..db0cd2388 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -11,7 +11,7 @@ import { listReplays, type ReplaySortValue, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, @@ -201,9 +201,7 @@ function nextPageHint( flags: Pick<ListFlags, "environment" | "query" | "sort" | "period">, ): string { return appendReplayFlags( - asInvocationCommand( - `sentry replay list ${formatScope(org, project)} -c next`, - ), + sentry`replay list ${formatScope(org, project)} -c next`, flags, ); } @@ -214,9 +212,7 @@ function prevPageHint( flags: Pick<ListFlags, "environment" | "query" | "sort" | "period">, ): string { return appendReplayFlags( - asInvocationCommand( - `sentry replay list ${formatScope(org, project)} -c prev`, - ), + sentry`replay list ${formatScope(org, project)} -c prev`, flags, ); } @@ -431,7 +427,7 @@ export const listCommand = buildListCommand("replay", { const countText = `Showing ${replays.length} replay${replays.length === 1 ? "" : "s"}.`; const firstReplay = replays[0]; const replayHint = firstReplay - ? `Use '${asInvocationCommand(`sentry replay view ${resolved.org}/${firstReplay.id}`)}' for details.` + ? `Use '${sentry`replay view ${resolved.org}/${firstReplay.id}`}' for details.` : undefined; hint = nav ? `${countText} ${nav}` diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index bc686ec10..c7d063ee5 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 { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -183,11 +183,9 @@ export async function validateReplayProjectScope( throw new ResolutionError( `Replay '${replayId}'`, "has no project association", - asInvocationCommand( - `sentry replay ${command} ${org}/${project}/${replayId}`, - ), + sentry`replay ${command} ${org}/${project}/${replayId}`, [ - `Use the org-scoped replay instead: ${asInvocationCommand(`sentry replay ${command} ${org}/${replayId}`)}`, + `Use the org-scoped replay instead: ${sentry`replay ${command} ${org}/${replayId}`}`, ], ); } @@ -197,11 +195,9 @@ export async function validateReplayProjectScope( throw new ResolutionError( `Replay '${replayId}'`, `is not in project '${project}'`, - asInvocationCommand( - `sentry replay ${command} ${org}/${project}/${replayId}`, - ), + sentry`replay ${command} ${org}/${project}/${replayId}`, [ - `Use the org-scoped replay instead: ${asInvocationCommand(`sentry replay ${command} ${org}/${replayId}`)}`, + `Use the org-scoped replay instead: ${sentry`replay ${command} ${org}/${replayId}`}`, ], ); } @@ -395,7 +391,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Replay '${replayId}'`, "not found", - asInvocationCommand(`sentry replay view ${resolved.org}/${replayId}`), + sentry`replay view ${resolved.org}/${replayId}`, [ "Check that you are querying the right organization", "The replay may be past your retention window", diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts index 589f53522..b723f7c0e 100644 --- a/packages/cli/src/commands/schema.ts +++ b/packages/cli/src/commands/schema.ts @@ -20,7 +20,7 @@ import { type SchemaEndpoint, searchEndpoints, } from "../lib/api-schema.js"; -import { asInvocationCommand } from "../lib/app-identity.js"; +import { sentry } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { choiceParser, parseText } from "../lib/completion-target.js"; import { OutputError, ResolutionError } from "../lib/errors.js"; @@ -232,22 +232,13 @@ function jsonTransformSchema(data: SchemaResult, fields?: string[]): unknown { function noResourceMatchError(resource: string): ResolutionError { const names = getResourceSummaries().map((r) => r.name); const [closest] = fuzzyMatch(resource, names, { maxResults: 1 }); - const browseHint = asInvocationCommand("sentry schema"); - const searchHint = asInvocationCommand( - `sentry schema --search ${resource} Search endpoints by keyword`, - ); + const browseHint = sentry`schema`; + const searchHint = sentry`schema --search ${resource} Search endpoints by keyword`; // Lead with the closest resource name when a typo has an obvious fix // (e.g. "committers" → "commits"); otherwise point at the full browse. - const primaryHint = closest - ? asInvocationCommand(`sentry schema ${closest}`) - : browseHint; + const primaryHint = closest ? sentry`schema ${closest}` : browseHint; const suggestions = closest - ? [ - asInvocationCommand( - "sentry schema Browse all resources", - ), - searchHint, - ] + ? [sentry`schema Browse all resources`, searchHint] : [searchHint]; return new ResolutionError( `Resource "${resource}"`, @@ -300,10 +291,8 @@ function pathNotFoundError( `Endpoint '${label}'`, "does not exist in the schema", example - ? asInvocationCommand( - `sentry schema "${example.method} ${example.path}"`, - ) - : asInvocationCommand("sentry schema"), + ? sentry`schema "${example.method} ${example.path}"` + : sentry`schema`, [`Available methods at this path: ${methods}`], ); } @@ -311,22 +300,16 @@ function pathNotFoundError( const segment = lastConcreteSegment(query.path); const resourceExists = segment !== undefined && getEndpointsByResource(segment).length > 0; - let hint = asInvocationCommand("sentry schema"); + let hint = sentry`schema`; if (resourceExists) { - hint = asInvocationCommand(`sentry schema ${segment}`); + hint = sentry`schema ${segment}`; } else if (segment) { - hint = asInvocationCommand(`sentry schema --search ${segment}`); + hint = sentry`schema --search ${segment}`; } - const suggestions = [ - asInvocationCommand( - "sentry schema Browse all resources", - ), - ]; + const suggestions = [sentry`schema Browse all resources`]; if (resourceExists && segment) { suggestions.unshift( - asInvocationCommand( - `sentry schema --search ${segment} Search endpoints by keyword`, - ), + sentry`schema --search ${segment} Search endpoints by keyword`, ); } throw new ResolutionError( @@ -382,12 +365,8 @@ function resolveGlobQuery(resource: string): SchemaResult { throw new ResolutionError( `Pattern "${resource}"`, "matched no resources", - asInvocationCommand("sentry schema"), - [ - asInvocationCommand( - "sentry schema --all List every endpoint in a flat table", - ), - ], + sentry`schema`, + [sentry`schema --all List every endpoint in a flat table`], ); } const endpoints = matched.flatMap((r) => getEndpointsByResource(r.name)); diff --git a/packages/cli/src/commands/send-envelope.ts b/packages/cli/src/commands/send-envelope.ts index 1b4d60da8..fe0d25dd0 100644 --- a/packages/cli/src/commands/send-envelope.ts +++ b/packages/cli/src/commands/send-envelope.ts @@ -6,7 +6,7 @@ */ import type { SentryContext } from "../context.js"; -import { asInvocationCommand } from "../lib/app-identity.js"; +import { sentry } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { parseFilePath, parseText } from "../lib/completion-target.js"; import { CliError, EXIT } from "../lib/errors.js"; @@ -55,8 +55,8 @@ export const sendEnvelopeCommand = buildCommand({ ) { const fileArgs = files.length > 0 ? ` ${files.join(" ")}` : " <file>"; throw new CliError( - `\`${asInvocationCommand("sentry send-envelope")}\` has been removed.\n` + - `Use: ${asInvocationCommand(`sentry event send --raw${fileArgs}`)}`, + `\`${sentry`send-envelope`}\` has been removed.\n` + + `Use: ${sentry`event send --raw${fileArgs}`}`, EXIT.GENERAL, ); }, diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index 989bf310e..60c7af868 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -6,7 +6,7 @@ */ import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceListParser, @@ -197,7 +197,7 @@ export const injectCommand = buildCommand({ if (modified > 0) { return { - hint: `Run \`${asInvocationCommand("sentry sourcemap upload")}\` to upload the injected files to Sentry`, + hint: `Run \`${sentry`sourcemap upload`}\` to upload the injected files to Sentry`, }; } return {}; diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index 9765248fb..43219be0d 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -10,7 +10,7 @@ import { tableCell } from "@sentry/toolkit-core/markdown"; import { relative, resolve as resolvePath } from "node:path"; import type { SentryContext } from "../../context.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { choiceListParser, @@ -237,7 +237,7 @@ export const resolveCommand = buildCommand({ } if (withDebugId < files.length) { return { - hint: `Run \`${asInvocationCommand("sentry sourcemap inject")}\` to add debug IDs to files missing them.`, + hint: `Run \`${sentry`sourcemap inject`}\` to add debug IDs to files missing them.`, }; } return {}; diff --git a/packages/cli/src/commands/span/list.ts b/packages/cli/src/commands/span/list.ts index 4f6d57b6c..9c1a396a7 100644 --- a/packages/cli/src/commands/span/list.ts +++ b/packages/cli/src/commands/span/list.ts @@ -14,7 +14,7 @@ import type { SentryContext } from "../../context.js"; import type { SpanSortValue } from "../../lib/api/traces.js"; import { listSpans } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -294,9 +294,7 @@ function traceNextPageHint( flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { return appendFlagHints( - asInvocationCommand( - `sentry span list ${org}/${project}/${traceId} -c next`, - ), + sentry`span list ${org}/${project}/${traceId} -c next`, flags, ); } @@ -309,9 +307,7 @@ function tracePrevPageHint( flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { return appendFlagHints( - asInvocationCommand( - `sentry span list ${org}/${project}/${traceId} -c prev`, - ), + sentry`span list ${org}/${project}/${traceId} -c prev`, flags, ); } @@ -322,10 +318,7 @@ function projectNextPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendFlagHints( - asInvocationCommand(`sentry span list ${org}/${project} -c next`), - flags, - ); + return appendFlagHints(sentry`span list ${org}/${project} -c next`, flags); } /** Build the CLI hint for fetching the previous page in project mode. */ @@ -334,10 +327,7 @@ function projectPrevPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendFlagHints( - asInvocationCommand(`sentry span list ${org}/${project} -c prev`), - flags, - ); + return appendFlagHints(sentry`span list ${org}/${project} -c prev`, flags); } // --------------------------------------------------------------------------- @@ -504,7 +494,7 @@ async function handleTraceMode( const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use '${asInvocationCommand(`sentry span view ${traceId} <span-id>`)}' to view span details.`; + : `${countText} Use '${sentry`span view ${traceId} <span-id>`}' to view span details.`; } return { @@ -591,7 +581,7 @@ async function handleProjectMode( const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use '${asInvocationCommand("sentry span view <trace-id> <span-id>")}' to view span details.`; + : `${countText} Use '${sentry`span view <trace-id> <span-id>`}' to view span details.`; } return { diff --git a/packages/cli/src/commands/span/view.ts b/packages/cli/src/commands/span/view.ts index 73005bf87..a0940b864 100644 --- a/packages/cli/src/commands/span/view.ts +++ b/packages/cli/src/commands/span/view.ts @@ -11,7 +11,7 @@ import { getDetailedTrace, type TraceItemDetail, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { buildCommand } from "../../lib/command.js"; import { ORG_PROJECT_TARGET } from "../../lib/completion-target.js"; @@ -117,8 +117,8 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { first && ID_KINDS.span.isId(first) ? [ `'${first}' looks like a span ID (16 characters), not a trace ID`, - `Provide the trace ID first: ${asInvocationCommand(`sentry span view <trace-id> ${validateSpanId(first)}`)}`, - `Use '${asInvocationCommand("sentry trace list")}' to find trace IDs`, + `Provide the trace ID first: ${sentry`span view <trace-id> ${validateSpanId(first)}`}`, + `Use '${sentry`trace list`}' to find trace IDs`, ] : [], ); @@ -126,7 +126,7 @@ export function parseSpanViewInput(rawArgs: readonly string[]): SpanViewInput { if (first === undefined) { throw new ContextError("Span ID", USAGE_HINT, [ - `Use '${asInvocationCommand(`sentry span list ${target}`)}' to find span IDs within this trace`, + `Use '${sentry`span list ${target}`}' to find span IDs within this trace`, ]); } @@ -151,7 +151,7 @@ function parseTrace(traceArg: string, spanId: string): TraceArgumentsInput { } throw new ContextError("Trace ID", USAGE_HINT, [ `'${traceArg}' is missing a trace ID before the span ID '${spanId}'`, - `Provide the trace ID: ${asInvocationCommand(`sentry span view ${traceArg}/<trace-id> ${spanId}`)}`, + `Provide the trace ID: ${sentry`span view ${traceArg}/<trace-id> ${spanId}`}`, ]); } } @@ -200,9 +200,7 @@ async function validateAndRecoverSpanId( const result = await recoverHexId(rawSpanId, "span", ctx); return handleRecoveryResult(result, err, { entityType: "span", - canonicalCommand: asInvocationCommand( - `sentry span view ${ctx.org}/${ctx.project}/${ctx.traceId} <id>`, - ), + canonicalCommand: sentry`span view ${ctx.org}/${ctx.project}/${ctx.traceId} <id>`, logTag: "span.view", }); } diff --git a/packages/cli/src/commands/status/show.ts b/packages/cli/src/commands/status/show.ts index cd1e53d8d..00a766ba6 100644 --- a/packages/cli/src/commands/status/show.ts +++ b/packages/cli/src/commands/status/show.ts @@ -11,7 +11,7 @@ import { fetchSentryStatus, SENTRY_STATUS_PAGE_URL, } from "../../lib/api/status-page.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseUrl } from "../../lib/completion-target.js"; import { formatSentryStatus } from "../../lib/formatters/human.js"; @@ -67,7 +67,7 @@ export const showCommand = buildCommand({ if (status.indicator !== "none") { return { - hint: `Run \`${asInvocationCommand("sentry status")}\` again to refresh, or open ${status.url}`, + hint: `Run \`${sentry`status`}\` again to refresh, or open ${status.url}`, }; } }, diff --git a/packages/cli/src/commands/token/delete.ts b/packages/cli/src/commands/token/delete.ts index 343d4fc65..9b83867e7 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, 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"; @@ -109,7 +109,7 @@ export function parseTokenArguments(args: readonly string[]): TokenInput { if (args.some((arg) => TOKEN_VALUE_PATTERN.test(arg))) { throw new ValidationError( "That looks like a token value, not a token ID or name. Use the ID " + - `or name from '${asInvocationCommand("sentry token list")}'.`, + `or name from '${sentry`token list`}'.`, "token-id", ); } @@ -168,7 +168,7 @@ async function findToken(org: string, ref: TokenRef): Promise<OrgAuthToken> { throw new ResolutionError( `Org auth token ${label}`, `not found in ${org}`, - asInvocationCommand(`sentry token list ${org}`), + sentry`token list ${org}`, ["It may already be revoked."], ); } @@ -177,7 +177,7 @@ async function findToken(org: string, ref: TokenRef): Promise<OrgAuthToken> { const ids = matches.map((t) => t.id).join(", "); throw new ValidationError( `${matches.length} org auth tokens in ${org} are named '${ref.name}' ` + - `(IDs ${ids}). Pass the ID instead: ${asInvocationCommand(`sentry token delete ${org}/<token-id>`)}`, + `(IDs ${ids}). Pass the ID instead: ${sentry`token delete ${org}/<token-id>`}`, "token-id", ); } diff --git a/packages/cli/src/commands/token/list.ts b/packages/cli/src/commands/token/list.ts index 83f4debf6..edbf56036 100644 --- a/packages/cli/src/commands/token/list.ts +++ b/packages/cli/src/commands/token/list.ts @@ -8,7 +8,7 @@ import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { OrgAuthTokenSchema } from "../../generated/output-schemas/token.js"; import { listOrgAuthTokens } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { selectAccountForOrg } from "../../lib/db/auth.js"; import { colorTag } from "../../lib/formatters/markdown.js"; @@ -108,7 +108,7 @@ export const listCommand = buildCommand({ yield new CommandOutput(tokens); if (tokens.length === 0) { return { - hint: `Create one with: ${asInvocationCommand(`sentry token create ${resolved.org}/<name>`)}`, + hint: `Create one with: ${sentry`token create ${resolved.org}/<name>`}`, }; } }, diff --git a/packages/cli/src/commands/token/shared.ts b/packages/cli/src/commands/token/shared.ts index bcae98d9f..9ba61513a 100644 --- a/packages/cli/src/commands/token/shared.ts +++ b/packages/cli/src/commands/token/shared.ts @@ -7,7 +7,7 @@ * for it (plus whatever the endpoint needs) on first use. */ -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { ApiError, AuthError, ContextError } from "../../lib/errors.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; import { ensureScopesForAction } from "../../lib/scope-recovery.js"; @@ -95,7 +95,7 @@ export function explainTokenForbidden( const detail = scopeMissing ? `Your saved login doesn't include ${scopes}. Re-authorization needs an interactive terminal and is skipped with --yes, --json, or --dry-run.\n ` + "Run this command once in a terminal without --yes, --json, or --dry-run to re-authorize." - : `Org auth tokens can only be managed with a CLI login from '${asInvocationCommand("sentry auth login")}'. ` + + : `Org auth tokens can only be managed with a CLI login from '${sentry`auth login`}'. ` + "Personal tokens, org auth tokens, and SENTRY_AUTH_TOKEN can't manage them.\n " + `The login also needs ${scopes}, and your org role must allow it.`; return new ApiError( diff --git a/packages/cli/src/commands/trace/list.ts b/packages/cli/src/commands/trace/list.ts index 72efe7ddc..26cd31725 100644 --- a/packages/cli/src/commands/trace/list.ts +++ b/packages/cli/src/commands/trace/list.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { listTransactions } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, textParser } from "../../lib/completion-target.js"; import { @@ -111,10 +111,7 @@ function nextPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendTraceFlags( - asInvocationCommand(`sentry trace list ${org}/${project} -c next`), - flags, - ); + return appendTraceFlags(sentry`trace list ${org}/${project} -c next`, flags); } /** Build the CLI hint for fetching the previous page, preserving active flags. */ @@ -123,10 +120,7 @@ function prevPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendTraceFlags( - asInvocationCommand(`sentry trace list ${org}/${project} -c prev`), - flags, - ); + return appendTraceFlags(sentry`trace list ${org}/${project} -c prev`, flags); } /** @@ -342,7 +336,7 @@ export const listCommand = buildListCommand("trace", { const countText = `Showing ${traces.length} trace${traces.length === 1 ? "" : "s"}.`; hint = nav ? `${countText} ${nav}` - : `${countText} Use '${asInvocationCommand("sentry trace view <TRACE_ID>")}' to view the full span tree.`; + : `${countText} Use '${sentry`trace view <TRACE_ID>`}' to view the full span tree.`; } yield new CommandOutput({ diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index 05c0b92db..6b3fdb6f3 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -6,7 +6,7 @@ import type { SentryContext } from "../../context.js"; import { type LogSortDirection, listTraceLogs } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildProjectQuery, parseLogSort, @@ -283,7 +283,7 @@ export const logsCommand = buildCommand({ const emptyMessage = `No logs found for trace ${traceId} in the last ${formatTimeRangeFlag(flags.period)}.\n\n` + - `Try a longer period: ${asInvocationCommand(`sentry trace logs --period 30d ${traceId}`)}`; + `Try a longer period: ${sentry`trace logs --period 30d ${traceId}`}`; yield new CommandOutput({ logs, @@ -295,11 +295,11 @@ export const logsCommand = buildCommand({ // Build hint with real values for easy copy-paste if (projectFilter) { return { - hint: `Filtered to project '${projectFilter}'. Full trace logs: ${asInvocationCommand(`sentry trace logs ${org}/${traceId}`)}`, + hint: `Filtered to project '${projectFilter}'. Full trace logs: ${sentry`trace logs ${org}/${traceId}`}`, }; } return { - hint: `Filter by project: ${asInvocationCommand(`sentry trace logs ${org}/<project>/${traceId}`)}`, + hint: `Filter by project: ${sentry`trace logs ${org}/<project>/${traceId}`}`, }; }, }); diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 3091e87f5..b5294b276 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -15,7 +15,7 @@ import { getProject, type TraceItemDetail, } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { looksLikeIssueShortId, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; @@ -80,14 +80,14 @@ function buildViewHint( summary: ReturnType<typeof computeTraceSummary>, ): string { if (projectFilter) { - return `Filtered to project '${projectFilter}'. Full trace: ${asInvocationCommand(`sentry trace view ${org}/${traceId}`)}`; + return `Filtered to project '${projectFilter}'. Full trace: ${sentry`trace view ${org}/${traceId}`}`; } if (summary.projects.length > 1) { const projectList = summary.projects.join(", "); const firstProject = summary.projects[0]; - return `This trace spans ${summary.projects.length} projects (${projectList}). Filter: ${asInvocationCommand(`sentry trace view ${org}/${firstProject}/${traceId}`)}`; + return `This trace spans ${summary.projects.length} projects (${projectList}). Filter: ${sentry`trace view ${org}/${firstProject}/${traceId}`}`; } - return `Tip: Open in browser with '${asInvocationCommand(`sentry trace view --web ${traceId}`)}'`; + return `Tip: Open in browser with '${sentry`trace view --web ${traceId}`}'`; } /** @@ -122,7 +122,7 @@ export function parseTraceViewInput(args: readonly string[]): TraceViewInput { return { trace: parseTraceArgumentsInput(args, USAGE_HINT), ...(shortId && { - suggestion: `Did you mean: ${asInvocationCommand(`sentry issue view ${first}`)}`, + suggestion: `Did you mean: ${sentry`issue view ${first}`}`, }), }; } @@ -407,10 +407,7 @@ async function resolveTraceFromIssue( const resolved = await resolveOrg({ cwd }); if (!resolved) { - throw new ContextError( - "Organization", - asInvocationCommand(`sentry issue view ${issueShortId}`), - ); + throw new ContextError("Organization", sentry`issue view ${issueShortId}`); } const org = resolved.org; @@ -426,7 +423,7 @@ async function resolveTraceFromIssue( if (!traceId) { throw new ValidationError( `Could not find a trace for issue '${issueShortId}'. The latest event has no trace context.\n\n` + - `Try: ${asInvocationCommand(`sentry issue view ${issueShortId}`)}`, + `Try: ${sentry`issue view ${issueShortId}`}`, ); } @@ -455,7 +452,7 @@ function fetchTraceSpanDetails( if (totalCount > LARGE_TRACE_WARN_THRESHOLD) { log.warn( `Trace has ${totalCount} spans \u2014 this may take a moment. ` + - `Use '${asInvocationCommand("sentry span view")}' for specific spans.`, + `Use '${sentry`span view`}' for specific spans.`, ); } @@ -594,9 +591,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Trace '${traceId}'`, "not found", - asInvocationCommand( - `sentry trace view ${org}/${project ?? "<project>"}/${traceId}`, - ), + sentry`trace view ${org}/${project ?? "<project>"}/${traceId}`, [ "Check that you are querying the right org/project", "The trace may be past your plan's retention window", diff --git a/packages/cli/src/commands/trial/list.ts b/packages/cli/src/commands/trial/list.ts index ad68818f8..10b1e6b65 100644 --- a/packages/cli/src/commands/trial/list.ts +++ b/packages/cli/src/commands/trial/list.ts @@ -8,7 +8,7 @@ import type { SentryContext } from "../../context.js"; import { getCustomerTrialInfo } from "../../lib/api-client.js"; -import { asInvocationCommand } from "../../lib/app-identity.js"; +import { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -246,13 +246,11 @@ export const listCommand = buildCommand({ (e) => e.status === "available" && e.category !== "plan", ); if (hasAvailableProduct) { - hints.push( - `Tip: Use '${asInvocationCommand("sentry trial start <name>")}' to start a trial`, - ); + hints.push(`Tip: Use '${sentry`trial start <name>`}' to start a trial`); } if (planEntry?.status === "available") { hints.push( - `Tip: Use '${asInvocationCommand("sentry trial start plan")}' to start a Business plan trial`, + `Tip: Use '${sentry`trial start plan`}' to start a Business plan trial`, ); } diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index d62ff043e..77ca93c21 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 } from "../../lib/app-identity.js"; +import { asInvocationCommand, 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"; @@ -194,7 +194,7 @@ export const startCommand = buildCommand({ const { org } = await resolveOrgInput( input.org, context.cwd, - asInvocationCommand("sentry trial start <name> <org>"), + sentry`trial start <name> <org>`, ); return { name: input.name, org }; }, diff --git a/packages/cli/src/commands/wasm-split.ts b/packages/cli/src/commands/wasm-split.ts index 860849d08..7fe272aa9 100644 --- a/packages/cli/src/commands/wasm-split.ts +++ b/packages/cli/src/commands/wasm-split.ts @@ -18,7 +18,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { basename } from "node:path"; import type { SentryContext } from "../context.js"; -import { asInvocationCommand } from "../lib/app-identity.js"; +import { sentry } from "../lib/app-identity.js"; import { buildCommand } from "../lib/command.js"; import { parseFilePath, @@ -170,7 +170,7 @@ export const wasmSplitCommand = buildCommand({ async *func(this: SentryContext, flags: WasmSplitFlags, input: string) { if (!input?.trim()) { throw new ValidationError( - `Wasm file path is required: ${asInvocationCommand("sentry wasm-split <input>")}`, + `Wasm file path is required: ${sentry`wasm-split <input>`}`, "input", ); } diff --git a/packages/cli/src/lib/account-selection.ts b/packages/cli/src/lib/account-selection.ts index 0db3dd920..1dba4c807 100644 --- a/packages/cli/src/lib/account-selection.ts +++ b/packages/cli/src/lib/account-selection.ts @@ -5,7 +5,7 @@ */ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { type AccountSummary, findAccounts, @@ -93,11 +93,9 @@ export function resolveAccountSelector( similar.length > 0 ? [ ...similar.map((a) => `Did you mean ${describeAccount(a)}?`), - `Run '${asInvocationCommand("sentry auth list")}' to see stored accounts`, + `Run '${sentry`auth list`}' to see stored accounts`, ] - : [ - `Run '${asInvocationCommand("sentry auth list")}' to see stored accounts`, - ], + : [`Run '${sentry`auth list`}' to see stored accounts`], ); } diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index fb85038af..8c2f27426 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -29,7 +29,7 @@ import * as Sentry from "@sentry/node"; import { type GenericSchema, safeParse } from "valibot"; import { extractRequiredScopes } from "../api-scope.js"; -import { asInvocationCommand, getUrlLoginCommand } from "../app-identity.js"; +import { getUrlLoginCommand, sentry } from "../app-identity.js"; import { type CredentialContext, getActiveEnvVarName, @@ -102,12 +102,12 @@ function enrich403Detail(rawDetail: string | undefined): string { const scopeArgs = scopes.map((s) => `--scope ${s}`).join(" "); lines.push( `Your token is missing the required scope(s) '${scopes.join("', '")}'.`, - `Re-authenticate with: ${asInvocationCommand(`sentry auth refresh ${scopeArgs}`)}`, + `Re-authenticate with: ${sentry`auth refresh ${scopeArgs}`}`, ); } else { lines.push( "You may not have access to this resource.", - `Re-authenticate with: ${asInvocationCommand("sentry auth login")}`, + `Re-authenticate with: ${sentry`auth login`}`, ); } return lines.join("\n "); @@ -141,7 +141,7 @@ function enrich401Detail(rawDetail: string | undefined): string { "Your account is disabled in this organization because it is over its member limit.", "This is a billing/seat-limit issue, not an auth problem — re-authenticating won't help.", "Ask an org owner to upgrade the plan or free up a seat, then retry.", - `Or target a different org, e.g.: ${asInvocationCommand("sentry issue list my-other-org/")}`, + `Or target a different org, e.g.: ${sentry`issue list my-other-org/`}`, ].join("\n "); } @@ -158,7 +158,7 @@ function enrich401Detail(rawDetail: string | undefined): string { } else { lines.push( "Not authenticated or your session has expired.", - `Re-authenticate with: ${asInvocationCommand("sentry auth login")}`, + `Re-authenticate with: ${sentry`auth login`}`, ); } return lines.join("\n "); @@ -338,7 +338,7 @@ function buildNetworkErrorDetail(error: unknown): string { const steps = [causeHint ?? "Check your network connection and try again."]; steps.push( isSentrySaasUrl(baseUrl) - ? `Check Sentry's status with: ${asInvocationCommand("sentry status")}` + ? `Check Sentry's status with: ${sentry`status`}` : describeUrlSource(), ); return [`Unable to reach Sentry at ${baseUrl}: ${cause}`, "", ...steps].join( diff --git a/packages/cli/src/lib/api/issues.ts b/packages/cli/src/lib/api/issues.ts index ce1326cd2..90e420841 100644 --- a/packages/cli/src/lib/api/issues.ts +++ b/packages/cli/src/lib/api/issues.ts @@ -9,7 +9,7 @@ import { listOrganizationIssues } from "@sentry/api"; import type { SentryIssue } from "../../types/index.js"; import type { IssueSubstatus } from "../../types/sentry.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { buildTlsErrorDetail, customFetch, @@ -366,8 +366,8 @@ export async function getIssueByShortId( 404, [ "The issue may have been deleted or merged", - `Verify the short ID and org: ${asInvocationCommand(`sentry issue view ${orgSlug}/${normalizedShortId}`)}`, - `List issues in this org: ${asInvocationCommand(`sentry issue list ${orgSlug}/`)}`, + `Verify the short ID and org: ${sentry`issue view ${orgSlug}/${normalizedShortId}`}`, + `List issues in this org: ${sentry`issue list ${orgSlug}/`}`, ].join("\n "), ); } diff --git a/packages/cli/src/lib/api/releases.ts b/packages/cli/src/lib/api/releases.ts index bb92d7d17..5ec5fa879 100644 --- a/packages/cli/src/lib/api/releases.ts +++ b/packages/cli/src/lib/api/releases.ts @@ -21,7 +21,7 @@ import type { SentryRelease, SentryRepository, } from "../../types/index.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getCiCheckout } from "../ci.js"; import { getEnv } from "../env.js"; import { @@ -501,13 +501,11 @@ function resolveAutoCheckout( throw validationError( "Could not determine the repository: there is no 'origin' git remote and no CI repository variables (GITHUB_REPOSITORY, CI_PROJECT_PATH, VERCEL_GIT_REPO_SLUG, ...). --auto needs one of them to match a Sentry repository.", [ - asInvocationCommand( - `sentry release set-commits ${target} --commit owner/repo@<commit-sha>`, - ), + sentry`release set-commits ${target} --commit owner/repo@<commit-sha>`, ...(inGitCheckout ? [ "git remote add origin <url>", - asInvocationCommand(`sentry release set-commits ${target} --local`), + sentry`release set-commits ${target} --local`, ] : []), ], @@ -604,13 +602,12 @@ function noMatchingRepositoryError( const commit = checkout.commit ?? readHeadCommit(cwd) ?? "<commit-sha>"; const target = `${orgSlug}/${version}`; const examples = [ - ...suggestions.map((name) => - asInvocationCommand( - `sentry release set-commits ${target} --commit '${name}@${commit}'`, - ), + ...suggestions.map( + (name) => + sentry`release set-commits ${target} --commit '${name}@${commit}'`, ), ...(isInsideGitWorkTree(cwd) - ? [asInvocationCommand(`sentry release set-commits ${target} --local`)] + ? [sentry`release set-commits ${target} --local`] : []), ]; const didYouMean = @@ -650,13 +647,9 @@ function noRepoIntegrationError( cwd?: string, ): ApiError { const detail = buildValidationMessage(connectIntegrationHint(orgSlug), [ - asInvocationCommand(`sentry integration list ${orgSlug}/`), + sentry`integration list ${orgSlug}/`, ...(isInsideGitWorkTree(cwd) - ? [ - asInvocationCommand( - `sentry release set-commits ${orgSlug}/${version} --local`, - ), - ] + ? [sentry`release set-commits ${orgSlug}/${version} --local`] : []), ]); return new ApiError( @@ -888,9 +881,7 @@ export async function explainSetCommitsError( : (ref?.commit ?? "<commit-sha>"); const target = `${orgSlug}/${version}`; const commitExample = (name: string) => - asInvocationCommand( - `sentry release set-commits ${target} --commit '${name}@${commit}'`, - ); + sentry`release set-commits ${target} --commit '${name}@${commit}'`; const headline = `Sentry organization '${orgSlug}' has no repository named '${repository}'.`; let match: SentryRepository | undefined; @@ -919,7 +910,7 @@ export async function explainSetCommitsError( if (hasRepositories === false) { return validationError( `${headline} It has no repositories yet, so it cannot associate commits.`, - [asInvocationCommand(`sentry integration list ${orgSlug}/`)], + [sentry`integration list ${orgSlug}/`], COMMIT_REPOSITORY_FIELD, connectIntegrationHint(orgSlug), ); @@ -934,10 +925,7 @@ export async function explainSetCommitsError( : ""; return validationError( `${headline}${didYouMean}`, - [ - ...suggestions.map(commitExample), - asInvocationCommand(`sentry repo list ${orgSlug}/`), - ], + [...suggestions.map(commitExample), sentry`repo list ${orgSlug}/`], COMMIT_REPOSITORY_FIELD, `Add the repository to Sentry through your source code integration: ${buildRepositorySettingsUrl(orgSlug)}`, ); diff --git a/packages/cli/src/lib/app-identity.ts b/packages/cli/src/lib/app-identity.ts index 7997e931e..e22240591 100644 --- a/packages/cli/src/lib/app-identity.ts +++ b/packages/cli/src/lib/app-identity.ts @@ -27,16 +27,6 @@ export function getAppIdentity(): AppIdentity { return identity; } -/** - * `command` as the running program spells it: a leading `sentry ` (how - * command docs write their examples) becomes this program's name. - */ -export function asRunningAppCommand(command: string): string { - return command.startsWith("sentry ") - ? `${identity.name}${command.slice("sentry".length)}` - : command; -} - /** * Whether npm's `npx` (`npm exec`) launched this process. The binary is then * not on PATH, so hints must go through `npx` too. @@ -60,7 +50,9 @@ export function getInvocationCommand(): string { /** * `command` as a hint that runs this program again: a leading `sentry ` - * becomes {@link getInvocationCommand}. + * 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 ") @@ -68,6 +60,26 @@ export function asInvocationCommand(command: string): string { : 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. + */ +export function sentry( + strings: TemplateStringsArray, + ...values: unknown[] +): string { + let text = strings[0] ?? ""; + values.forEach((value, i) => { + text += asInvocationCommand(String(value)) + (strings[i + 1] ?? ""); + }); + return `${getInvocationCommand()} ${text}`; +} + /** Command that signs in to the active Sentry host. */ export function getLoginCommand(): string { return `${getInvocationCommand()} auth login`; diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts index ea885df0c..280272800 100644 --- a/packages/cli/src/lib/arg-parsing.ts +++ b/packages/cli/src/lib/arg-parsing.ts @@ -8,7 +8,7 @@ import { parse as parseTarget } from "../generated/org-project-target-grammar.js"; import type { LogSortDirection } from "./api/logs.js"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { ContextError, ValidationError, validationError } from "./errors.js"; import { validateResourceId } from "./input-validation.js"; import { isGlobFilter } from "./list-filter.js"; @@ -224,10 +224,10 @@ export function rejectIssueCommandTokenWithNumericSuffix( `"${arg}" looks like a command token plus a suffix, not an issue short ID.\n` + ` Parsed as project '${normalizedProject}' + suffix '${suffix}', but '${normalizedProject}' is an issue command name.`, [ - asInvocationCommand("sentry issue view PROJECT-1"), - asInvocationCommand("sentry issue explain PROJECT-1"), - asInvocationCommand("sentry issue explain 123456789"), - asInvocationCommand("sentry issue explain my-org/project/PROJECT-G"), + sentry`issue view PROJECT-1`, + sentry`issue explain PROJECT-1`, + sentry`issue explain 123456789`, + sentry`issue explain my-org/project/PROJECT-G`, ], "issue", ); @@ -678,7 +678,7 @@ function rejectAtSelector(value: string, label: string): void { const article = "aeiouAEIOU".includes(label.charAt(0)) ? "an" : "a"; throw new ValidationError( `'${value}' is an issue selector, not ${article} ${label}.\n` + - ` Use: ${asInvocationCommand(`sentry issue view ${value}`)}`, + ` Use: ${sentry`issue view ${value}`}`, label, ); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index cef2999ef..d059233c6 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, asRunningAppCommand } from "./app-identity.js"; +import { asInvocationCommand, sentry } from "./app-identity.js"; import { appendCacheHint } from "./cache-hint.js"; import { type CommandExample, exampleSteps } from "./command-example.js"; import { @@ -473,7 +473,7 @@ function prepareNativeDocs( } const rendered = examples .map((example) => { - const steps = exampleSteps(example).map(asRunningAppCommand); + const steps = exampleSteps(example).map(asInvocationCommand); return steps.length === 1 ? ` ${steps[0]} # ${example.description}` : [` # ${example.description}`, ...steps.map((s) => ` ${s}`)].join( @@ -744,7 +744,7 @@ export function buildCommand< } ctx.stderr.write( warning( - `Tip: use --help for help (e.g., ${asInvocationCommand(`sentry ${pathSegments.join(" ")} --help`)})\n\n`, + `Tip: use --help for help (e.g., ${sentry`${pathSegments.join(" ")} --help`})\n\n`, ), ); stdout.write(`${formatHelpHuman(result)}\n`); diff --git a/packages/cli/src/lib/custom-ca.ts b/packages/cli/src/lib/custom-ca.ts index 3b2f918c3..ae4d38a9a 100644 --- a/packages/cli/src/lib/custom-ca.ts +++ b/packages/cli/src/lib/custom-ca.ts @@ -21,7 +21,7 @@ import { createRequire } from "node:module"; import { rootCertificates } from "node:tls"; const _require = createRequire(import.meta.url); -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { getDefaultCaCert } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { stringifyUnknown } from "./errors.js"; @@ -201,7 +201,7 @@ export function warnIfSaasWithEnvCa(targetUrl: string): void { log.warn( `Using custom CA certificates from ${resolvedLabel} for sentry.io connections.\n` + " If you intended this (e.g. corporate proxy), silence this warning:\n" + - ` ${asInvocationCommand("sentry cli defaults ca-cert /path/to/cert.pem")}`, + ` ${sentry`cli defaults ca-cert /path/to/cert.pem`}`, ); } @@ -280,7 +280,7 @@ export function buildTlsErrorDetail(error: Error): string { " This usually means your network uses a TLS-intercepting proxy\n" + " (corporate firewall, VPN) with a private certificate authority.\n\n" + " To fix this, point the CLI to your CA certificate bundle:\n" + - ` ${asInvocationCommand("sentry cli defaults ca-cert /path/to/corporate-ca.pem")}\n\n` + + ` ${sentry`cli defaults ca-cert /path/to/corporate-ca.pem`}\n\n` + " Or set the NODE_EXTRA_CA_CERTS environment variable:\n" + " export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem" ); diff --git a/packages/cli/src/lib/dashboard-arguments.ts b/packages/cli/src/lib/dashboard-arguments.ts index a65b24569..2fcd43363 100644 --- a/packages/cli/src/lib/dashboard-arguments.ts +++ b/packages/cli/src/lib/dashboard-arguments.ts @@ -17,7 +17,7 @@ * apply. */ -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { ValidationError } from "./errors.js"; import { ID_KINDS, parseIdArguments } from "./id-arguments.js"; import { type ParsedSentryUrl, parseSentryUrl } from "./sentry-url-parser.js"; @@ -64,7 +64,7 @@ export function parseDashboardArgs( throw new ValidationError( "Dashboard ID or title is required.\n\n" + "The URL provided contains an org but no dashboard ID.\n" + - `Try: ${asInvocationCommand(`sentry dashboard <command> ${url.org}/ <id-or-title>`)}`, + `Try: ${sentry`dashboard <command> ${url.org}/ <id-or-title>`}`, "dashboard", ); } @@ -136,9 +136,9 @@ export function parseDashboardListArgs( const orgNote = url.org ? ` in '${url.org}'` : ""; throw new ValidationError( "This looks like a dashboard URL. To view a specific dashboard:\n\n" + - ` ${asInvocationCommand(`sentry dashboard view ${orgPrefix}${url.dashboardId}`)}\n\n` + + ` ${sentry`dashboard view ${orgPrefix}${url.dashboardId}`}\n\n` + `To list dashboards${orgNote}:\n\n` + - ` ${asInvocationCommand(`sentry dashboard list${orgSuffix}`)}`, + ` ${sentry`dashboard list${orgSuffix}`}`, "dashboard", ); } diff --git a/packages/cli/src/lib/db/backup.ts b/packages/cli/src/lib/db/backup.ts index e3f418323..0c296fa99 100644 --- a/packages/cli/src/lib/db/backup.ts +++ b/packages/cli/src/lib/db/backup.ts @@ -23,7 +23,7 @@ import { stat, } from "node:fs/promises"; import { join } from "node:path"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { stringifyUnknown, UpgradeError } from "../errors.js"; import { logger } from "../logger.js"; import { @@ -189,7 +189,7 @@ export async function getUndoBackup( if (!last) { throw new UpgradeError( "unsupported_operation", - `Nothing to undo: no upgrade made with \`${asInvocationCommand("sentry cli upgrade")}\` was recorded.`, + `Nothing to undo: no upgrade made with \`${sentry`cli upgrade`}\` was recorded.`, ); } if (last.toVersion !== currentVersion) { @@ -276,7 +276,7 @@ function carryOverAccounts( if (!current) { return; } - const loginHint = `Your logins are as they were in ${backup.version}; run \`${asInvocationCommand("sentry auth login")}\` if you need another.`; + const loginHint = `Your logins are as they were in ${backup.version}; run \`${sentry`auth login`}\` if you need another.`; if (!tableExists(restored, "accounts")) { if (current.rows.length > 0) { return `${backup.version} stores logins in an older format, so your current logins could not be kept. ${loginHint}`; diff --git a/packages/cli/src/lib/db/resilient.ts b/packages/cli/src/lib/db/resilient.ts index e2450aa3c..6db7354a5 100644 --- a/packages/cli/src/lib/db/resilient.ts +++ b/packages/cli/src/lib/db/resilient.ts @@ -14,7 +14,7 @@ import { chmodSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname } from "node:path"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getRealUsername } from "../utils.js"; import { isReadonlyError, tryRepairAndRetry } from "./schema.js"; import type { Database } from "./sqlite.js"; @@ -54,7 +54,7 @@ function warnReadonlyDatabase(): void { process.stderr.write( `\nWarning: Sentry CLI local database is read-only. Caching and preferences won't persist.\n` + ` Path: ${dbPath}\n` + - ` Fix: ${asInvocationCommand("sentry cli fix")}\n\n`, + ` Fix: ${sentry`cli fix`}\n\n`, ); } @@ -150,7 +150,7 @@ function tryRepairReadonly(): boolean { "\nWarning: Sentry CLI config directory is owned by root.\n" + ` Path: ${configDir}\n` + ` Fix: sudo chown -R ${username} "${configDir}"\n` + - ` Or: sudo ${asInvocationCommand("sentry cli fix")}\n\n`, + ` Or: sudo ${sentry`cli fix`}\n\n`, ); return false; } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index 63a48623c..773f1d822 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -12,7 +12,7 @@ */ import { uuidv7 } from "uuidv7"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getEnv } from "../env.js"; import { stringifyUnknown } from "../errors.js"; import { logger } from "../logger.js"; @@ -1002,14 +1002,14 @@ function adaptNewerSchema(db: Database, storedVersion: number): void { const versions = `schema ${storedVersion}; this CLI uses ${CURRENT_SCHEMA_VERSION}`; if (getEnv()[NO_AUTO_REPAIR_ENV] === "1") { logger.warn( - `The local database was written by a newer Sentry CLI (${versions}). Run \`${asInvocationCommand("sentry cli fix")}\` to adapt it.`, + `The local database was written by a newer Sentry CLI (${versions}). Run \`${sentry`cli fix`}\` to adapt it.`, ); return; } const { failed } = repairSchema(db); if (failed.length > 0) { logger.warn( - `Could not adapt the local database written by a newer Sentry CLI (${versions}): ${failed.join("; ")}. Run \`${asInvocationCommand("sentry cli fix")}\`.`, + `Could not adapt the local database written by a newer Sentry CLI (${versions}): ${failed.join("; ")}. Run \`${sentry`cli fix`}\`.`, ); return; } diff --git a/packages/cli/src/lib/dsn/errors.ts b/packages/cli/src/lib/dsn/errors.ts index cdb0d0274..8e294f0f7 100644 --- a/packages/cli/src/lib/dsn/errors.ts +++ b/packages/cli/src/lib/dsn/errors.ts @@ -5,7 +5,7 @@ * Provides helpful context and suggestions for resolving issues. */ -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getDsnSourceDescription } from "./detector.js"; import { SENTRY_DSN_ENV } from "./env.js"; import { getAccessibleProjects } from "./resolver.js"; @@ -32,15 +32,11 @@ export function formatConflictError(result: DsnDetectionResult): string { }); lines.push("To resolve, specify which project to use:"); - lines.push(` ${asInvocationCommand("sentry <command> <org>/<project>")}`); + lines.push(` ${sentry`<command> <org>/<project>`}`); lines.push(""); lines.push("Or set a default project:"); - lines.push( - ` ${asInvocationCommand("sentry config set defaults.org <org>")}`, - ); - lines.push( - ` ${asInvocationCommand("sentry config set defaults.project <project>")}`, - ); + lines.push(` ${sentry`cli defaults org <org>`}`); + lines.push(` ${sentry`cli defaults project <project>`}`); return lines.join("\n"); } @@ -97,15 +93,11 @@ export async function formatNoDsnError( ); lines.push(""); lines.push("3. Specify project explicitly:"); - lines.push(` ${asInvocationCommand("sentry <command> <org>/<project>")}`); + lines.push(` ${sentry`<command> <org>/<project>`}`); lines.push(""); lines.push("4. Set default project:"); - lines.push( - ` ${asInvocationCommand("sentry config set defaults.org <org>")}`, - ); - lines.push( - ` ${asInvocationCommand("sentry config set defaults.project <project>")}`, - ); + lines.push(` ${sentry`cli defaults org <org>`}`); + lines.push(` ${sentry`cli defaults project <project>`}`); return lines.join("\n"); } @@ -129,7 +121,7 @@ export function formatResolutionError(error: Error, dsnRaw: string): string { " - The DSN is invalid or expired", "", "Try specifying the project explicitly:", - ` ${asInvocationCommand("sentry <command> <org>/<project>")}`, + ` ${sentry`<command> <org>/<project>`}`, ]; return lines.join("\n"); diff --git a/packages/cli/src/lib/dsn/resolver.ts b/packages/cli/src/lib/dsn/resolver.ts index 0e30fc2d0..9d16e5b5d 100644 --- a/packages/cli/src/lib/dsn/resolver.ts +++ b/packages/cli/src/lib/dsn/resolver.ts @@ -11,7 +11,7 @@ import { listProjects, resolveOrgDisplayName, } from "../api-client.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getCachedDsn, updateCachedResolution } from "../db/dsn-cache.js"; import { getDsnSourceDescription } from "./detector.js"; import type { @@ -61,7 +61,7 @@ export async function resolveProject( if (!project?.organization) { throw new Error( "Cannot resolve project: DSN could not be matched to any accessible project. " + - `You may not have access, or specify the target explicitly: ${asInvocationCommand("sentry <command> <org>/<project>")}`, + `You may not have access, or specify the target explicitly: ${sentry`<command> <org>/<project>`}`, ); } diff --git a/packages/cli/src/lib/envelope/event-send-dsn.ts b/packages/cli/src/lib/envelope/event-send-dsn.ts index dc5b2a0f3..3b196eb5e 100644 --- a/packages/cli/src/lib/envelope/event-send-dsn.ts +++ b/packages/cli/src/lib/envelope/event-send-dsn.ts @@ -8,7 +8,7 @@ import { isAbsolute } from "node:path"; import { getProjectKeys } from "../api/projects.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getAuthConfig } from "../db/auth.js"; import { ConfigError } from "../errors.js"; import { logger } from "../logger.js"; @@ -21,7 +21,7 @@ const DSN_PREFIX_RE = /^https?:\/\//i; /** Usage example shown on missing-DSN errors. */ function eventSendDsnHint(): string { - return asInvocationCommand("sentry event send <dsn> -m 'My message'"); + return sentry`event send <dsn> -m 'My message'`; } /** User-facing missing-DSN message covering every resolution source. */ @@ -125,8 +125,8 @@ async function dsnFromProjectTarget( ): Promise<string> { if (!getAuthConfig()) { throw new ConfigError( - `Cannot resolve project '${target}' without a logged-in session. Pass the DSN as the first argument or run ${asInvocationCommand("sentry auth login")}.`, - asInvocationCommand("sentry auth login"), + `Cannot resolve project '${target}' without a logged-in session. Pass the DSN as the first argument or run ${sentry`auth login`}.`, + sentry`auth login`, ); } const { org, project } = await resolveProjectBoundFromArg( diff --git a/packages/cli/src/lib/envelope/transport.ts b/packages/cli/src/lib/envelope/transport.ts index d182a13a9..21ee0184d 100644 --- a/packages/cli/src/lib/envelope/transport.ts +++ b/packages/cli/src/lib/envelope/transport.ts @@ -12,7 +12,7 @@ */ import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn } from "@sentry/core"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { detectDsn } from "../dsn/index.js"; import { ApiError, ConfigError, ValidationError } from "../errors.js"; import { logger } from "../logger.js"; @@ -108,7 +108,7 @@ export async function resolveIngestDsn( */ export function requireDsn( flags: DsnFlags, - usageHint = asInvocationCommand("sentry bash-hook --dsn <your-dsn>"), + usageHint = sentry`bash-hook --dsn <your-dsn>`, ): string { const dsn = resolveDsn(flags); if (dsn) { diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 15666acb8..076506192 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -34,6 +34,7 @@ import { asInvocationCommand, getLoginCommand, getUrlLoginCommand, + sentry, } from "./app-identity.js"; // --------------------------------------------------------------------------- @@ -379,8 +380,8 @@ function defaultContextAlternatives(): string[] { return [ "Run from a directory with a Sentry DSN in source code or .env files", "Set SENTRY_ORG and SENTRY_PROJECT (or SENTRY_DSN) environment variables", - `Run '${asInvocationCommand("sentry org list")}' to find your organization slug`, - `Run '${asInvocationCommand("sentry project list <org>/")}' to find project slugs`, + `Run '${sentry`org list`}' to find your organization slug`, + `Run '${sentry`project list <org>/`}' to find project slugs`, ]; } @@ -745,8 +746,8 @@ export class SeerError extends CliError { // where the prompt can't fire — hence the direct command suggestion // (CLI-1D/BW/98, 237 users combined). const trialHint = this.orgSlug - ? `\n\nStart a free trial:\n ${asInvocationCommand(`sentry trial start seer ${this.orgSlug}`)}` - : `\n\nYou may be eligible for a free trial:\n ${asInvocationCommand("sentry trial list")}`; + ? `\n\nStart a free trial:\n ${sentry`trial start seer ${this.orgSlug}`}` + : `\n\nYou may be eligible for a free trial:\n ${sentry`trial list`}`; // When org slug is known, provide direct URLs to settings if (this.orgSlug) { diff --git a/packages/cli/src/lib/event-attachments.ts b/packages/cli/src/lib/event-attachments.ts index c8842e1d7..90b920f7d 100644 --- a/packages/cli/src/lib/event-attachments.ts +++ b/packages/cli/src/lib/event-attachments.ts @@ -9,7 +9,7 @@ import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { EventAttachmentDetailsResponse } from "@sentry/api"; import type { EventViewAttachment, SentryEvent } from "../types/index.js"; import { listEventAttachments } from "./api-client.js"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { renderMarkdown } from "./formatters/markdown.js"; import { formatBytes } from "./formatters/numbers.js"; import { logger } from "./logger.js"; @@ -169,5 +169,5 @@ export function attachmentDownloadHint( const name = first.name || "attachment"; const extra = attachments.length > 1 ? ` (${attachments.length - 1} more)` : ""; - return `Download attachment: ${asInvocationCommand(`sentry api ${shellQuote(first.download)} > ${shellQuote(name)}${extra}`)}`; + return `Download attachment: ${sentry`api ${shellQuote(first.download)} > ${shellQuote(name)}${extra}`}`; } diff --git a/packages/cli/src/lib/explore-errors.ts b/packages/cli/src/lib/explore-errors.ts index 8a79cfd39..cfe4777f8 100644 --- a/packages/cli/src/lib/explore-errors.ts +++ b/packages/cli/src/lib/explore-errors.ts @@ -6,7 +6,7 @@ import { parseFunction } from "@sentry/toolkit-core/events-fields"; import { getSortField } from "@sentry/toolkit-core/events-sort"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { ApiError, type FunctionArgument, validationError } from "./errors.js"; /** User-authored expressions and the request they contributed to. */ @@ -64,7 +64,7 @@ function recoveryHint( } if (detail === `Unknown function ${parsed.name}`) { - return `Aggregate functions depend on the dataset. Check the function name and selected dataset; ${asInvocationCommand("sentry explore --help")} includes usage examples.`; + return `Aggregate functions depend on the dataset. Check the function name and selected dataset; ${sentry`explore --help`} includes usage examples.`; } if ( @@ -80,7 +80,7 @@ function recoveryHint( const { argument } = rejected; const problem = `${parsed.name}() does not accept ${argument} on dataset "${dataset}": it may not exist there, or may not have the type ${parsed.name}() needs.`; return ATTRIBUTE_DATASETS.has(dataset) - ? `${problem} Reference a numeric custom attribute with its type, e.g. tags[${argument},number]; to check the key and its type, run: ${asInvocationCommand(`sentry attribute list <org>/ ${argument} --dataset ${dataset}`)}` + ? `${problem} Reference a numeric custom attribute with its type, e.g. tags[${argument},number]; to check the key and its type, run: ${sentry`attribute list <org>/ ${argument} --dataset ${dataset}`}` : `${problem} Check the column name and its type.`; } diff --git a/packages/cli/src/lib/formatters/accounts.ts b/packages/cli/src/lib/formatters/accounts.ts index 10987e391..f8ca8678c 100644 --- a/packages/cli/src/lib/formatters/accounts.ts +++ b/packages/cli/src/lib/formatters/accounts.ts @@ -4,7 +4,7 @@ */ import { text } from "@sentry/toolkit-core/markdown"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { type AccountSummary, hostnameOf } from "../db/accounts.js"; import { colorTag, renderMarkdown } from "./markdown.js"; import { formatTable } from "./table.js"; @@ -56,9 +56,7 @@ export type AccountListResult = { /** Human output for `sentry auth list`. */ export function formatAccountList(data: AccountListResult): string { if (data.accounts.length === 0) { - return renderMarkdown( - `No stored accounts. Run \`${asInvocationCommand("sentry auth login")}\`.`, - ); + return renderMarkdown(`No stored accounts. Run \`${sentry`auth login`}\`.`); } return formatTable(data.accounts, [ { header: "", value: (a) => (a.active ? "*" : ""), minWidth: 1 }, diff --git a/packages/cli/src/lib/formatters/feedback.ts b/packages/cli/src/lib/formatters/feedback.ts index f35220322..9d1a7d090 100644 --- a/packages/cli/src/lib/formatters/feedback.ts +++ b/packages/cli/src/lib/formatters/feedback.ts @@ -14,7 +14,7 @@ import type { SentryEvent, SentryFeedback, } from "../../types/index.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails } from "./human.js"; import { colorTag, mdKvTable, renderMarkdown } from "./markdown.js"; @@ -317,10 +317,8 @@ function formatFeedbackOverview(data: FeedbackViewResult): string { const project = feedback.project?.slug; const command = data.org && project - ? asInvocationCommand( - `sentry event view ${data.org}/${project}/${associatedEventId}`, - ) - : asInvocationCommand(`sentry event view ${associatedEventId}`); + ? sentry`event view ${data.org}/${project}/${associatedEventId}` + : sentry`event view ${associatedEventId}`; rows.push(["Linked error", feedbackCodeSpan(command)]); } @@ -366,8 +364,8 @@ function formatRelatedReplays(data: FeedbackViewResult): string { const lines = ["### Additional Replays", ""]; for (const replayId of additionalIds.slice(0, MAX_REPLAY_IDS_SHOWN)) { const command = data.org - ? asInvocationCommand(`sentry replay view ${data.org}/${replayId}`) - : asInvocationCommand(`sentry replay view ${replayId}`); + ? sentry`replay view ${data.org}/${replayId}` + : sentry`replay view ${replayId}`; lines.push( `- ${feedbackCodeSpan(replayId)} (${feedbackCodeSpan(command)})`, ); diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index f33e2f751..13f27bd0c 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -31,7 +31,7 @@ import type { Writer, } from "../../types/index.js"; import { resolveOrgDisplayName } from "../api-client.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { stringifyUnknown } from "../errors.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { withSerializeSpan } from "../telemetry.js"; @@ -1828,7 +1828,7 @@ function buildAuthDetailRows(data: AuthStatusData): [string, string][] { if (data.accounts !== undefined && data.accounts > 1) { rows.push([ "Accounts", - `${data.accounts} stored (${inlineCode(asInvocationCommand("sentry auth list"))})`, + `${data.accounts} stored (${inlineCode(sentry`auth list`)})`, ]); } if (data.token) { @@ -2046,8 +2046,8 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { } else if (result.teamSource === "auto-selected") { lines.push( dry - ? `> **Note:** Would use team '${text(result.teamSlug)}'. See all teams: \`${asInvocationCommand("sentry team list")}\`` - : `> **Note:** Using team '${text(result.teamSlug)}'. See all teams: \`${asInvocationCommand("sentry team list")}\``, + ? `> **Note:** Would use team '${text(result.teamSlug)}'. See all teams: \`${sentry`team list`}\`` + : `> **Note:** Using team '${text(result.teamSlug)}'. See all teams: \`${sentry`team list`}\``, ); lines.push(""); } @@ -2072,7 +2072,7 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { if (!dry && !tipsSuppressed()) { lines.push(""); lines.push( - `*Tip: Use \`${asInvocationCommand(`sentry project view ${result.orgSlug}/${result.project.slug}`)}\` for details*`, + `*Tip: Use \`${sentry`project view ${result.orgSlug}/${result.project.slug}`}\` for details*`, ); } @@ -2240,9 +2240,7 @@ export function formatFixResult(data: FixResult): string { `${colorTag("green", "✓")} No issues found. Database schema and permissions are correct.`, ); } else if (data.dryRun && data.issues.length > 0 && !data.repairFailed) { - lines.push( - `Run \`${asInvocationCommand("sentry cli fix")}\` to apply fixes.`, - ); + lines.push(`Run \`${sentry`cli fix`}\` to apply fixes.`); } else if (!data.repairFailed) { lines.push(`${colorTag("green", "✓")} All issues repaired successfully.`); } diff --git a/packages/cli/src/lib/formatters/issue.ts b/packages/cli/src/lib/formatters/issue.ts index 3e6f413fe..d105dbe5f 100644 --- a/packages/cli/src/lib/formatters/issue.ts +++ b/packages/cli/src/lib/formatters/issue.ts @@ -4,7 +4,7 @@ import type { InferOutput } from "valibot"; import type { SentryIssue } from "../../types/index.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails, formatIssueDetails } from "./human.js"; import { filterFieldsIfSet } from "./json.js"; @@ -45,7 +45,7 @@ function formatReplaySection(org: string | null, replayIds: string[]): string { for (const replayId of visibleReplayIds) { if (org) { lines.push( - `- \`${replayId}\` (view: \`${asInvocationCommand(`sentry replay view ${org}/${replayId}`)}\`)`, + `- \`${replayId}\` (view: \`${sentry`replay view ${org}/${replayId}`}\`)`, ); } else { lines.push(`- \`${replayId}\``); diff --git a/packages/cli/src/lib/formatters/replay.ts b/packages/cli/src/lib/formatters/replay.ts index 2b8d7cbbf..773b08511 100644 --- a/packages/cli/src/lib/formatters/replay.ts +++ b/packages/cli/src/lib/formatters/replay.ts @@ -14,7 +14,7 @@ import type { ReplayRelatedIssue, ReplayRelatedTrace, } from "../../types/index.js"; -import { asInvocationCommand } from "../app-identity.js"; +import { sentry } from "../app-identity.js"; import { getReplayUserLabel } from "../replay-search.js"; import { buildReplayUrl } from "../sentry-web-urls.js"; import { mdKvTable, renderMarkdown } from "./markdown.js"; @@ -515,7 +515,7 @@ function formatRelatedIssueLine( return `- Event \`${issue.eventId}\``; } - return `- \`${issue.shortId}\`: ${text(issue.title)} (view: \`${asInvocationCommand(`sentry issue view ${org}/${issue.shortId}`)}\`)`; + return `- \`${issue.shortId}\`: ${text(issue.title)} (view: \`${sentry`issue view ${org}/${issue.shortId}`}\`)`; } function buildRelatedTraceStats(trace: ReplayRelatedTrace): string[] { @@ -542,7 +542,7 @@ function formatRelatedTraceLine( ): string { const stats = buildRelatedTraceStats(trace); const suffix = stats.length > 0 ? ` (${stats.join(", ")})` : ""; - return `- Trace \`${trace.traceId}\`${suffix} (view: \`${asInvocationCommand(`sentry trace view ${org}/${trace.traceId}`)}\`)`; + return `- Trace \`${trace.traceId}\`${suffix} (view: \`${sentry`trace view ${org}/${trace.traceId}`}\`)`; } function pushRelatedSection( @@ -595,12 +595,12 @@ export function formatReplayDetails(data: ReplayViewData): string { export function replayHint(data: ReplayViewData): string | undefined { const traceId = data.replay.trace_ids?.[0]; if (traceId) { - return `Related trace: ${asInvocationCommand(`sentry trace view ${data.org}/${traceId}`)}`; + return `Related trace: ${sentry`trace view ${data.org}/${traceId}`}`; } const issue = data.relatedIssues[0]; if (issue?.shortId) { - return `Related issue: ${asInvocationCommand(`sentry issue view ${data.org}/${issue.shortId}`)}`; + return `Related issue: ${sentry`issue view ${data.org}/${issue.shortId}`}`; } return; diff --git a/packages/cli/src/lib/git.ts b/packages/cli/src/lib/git.ts index c853545d5..addb73135 100644 --- a/packages/cli/src/lib/git.ts +++ b/packages/cli/src/lib/git.ts @@ -13,7 +13,7 @@ import { execFileSync } from "node:child_process"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { ValidationError, validationError } from "./errors.js"; /** `execFileSync` failure shape when git exits non-zero. */ @@ -186,7 +186,7 @@ export function getCommitLog( if (from?.startsWith("-")) { throw validationError( `--from must be a git ref, not a CLI flag (received '${from}').`, - [asInvocationCommand("sentry release set-commits 1.0.0 --from v0.9.0")], + [sentry`release set-commits 1.0.0 --from v0.9.0`], "from", "Git refs cannot start with '-'. Tokens like '--format=x' are CLI flags, not refs — use a tag or commit (e.g. v0.9.0).", ); @@ -222,9 +222,7 @@ export function getCommitLog( `Unknown git ref '${from}': not found in this repository.`, [ `git rev-parse ${from}`, - asInvocationCommand( - "sentry release set-commits <org>/<version> --from v0.9.0", - ), + sentry`release set-commits <org>/<version> --from v0.9.0`, ], "from", "Range is always <ref>..HEAD — checkout the current release tag first.", diff --git a/packages/cli/src/lib/help.ts b/packages/cli/src/lib/help.ts index 4c4733725..ac4997ec0 100644 --- a/packages/cli/src/lib/help.ts +++ b/packages/cli/src/lib/help.ts @@ -6,7 +6,7 @@ * Commands are auto-generated from Stricli's route structure. */ -import { asInvocationCommand, asRunningAppCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { formatBanner } from "./banner.js"; import { isAuthenticated } from "./db/auth.js"; import { detectAgent } from "./detect-agent.js"; @@ -137,9 +137,7 @@ function toHelpCommand( .filter((sub: RouteMapEntry) => !sub.hidden) .map((sub: RouteMapEntry) => sub.name.original); return { - usage: asRunningAppCommand( - `sentry ${routeName} ${pickSubcommands(routeName, subNames).join(" | ")}`, - ), + usage: sentry`${routeName} ${pickSubcommands(routeName, subNames).join(" | ")}`, description: brief, }; } @@ -151,13 +149,13 @@ function toHelpCommand( getPositionalString(entry.target.parameters.positional); const usageSuffix = placeholder ? ` ${placeholder}` : ""; return { - usage: asRunningAppCommand(`sentry ${routeName}${usageSuffix}`), + usage: sentry`${routeName}${usageSuffix}`, description: brief, }; } return { - usage: asRunningAppCommand(`sentry ${routeName}`), + usage: sentry`${routeName}`, description: brief, }; } @@ -319,9 +317,7 @@ type CustomHelpOptions = { */ export function printCustomHelp(options: CustomHelpOptions = {}): string { const loggedIn = isAuthenticated(); - const example = asInvocationCommand( - loggedIn ? "sentry issue list" : "sentry auth", - ); + const example = loggedIn ? sentry`issue list` : sentry`auth`; const overview = Boolean(options.overview) && !detectAgent(); const header: string[] = []; @@ -371,9 +367,7 @@ export function printCustomHelp(options: CustomHelpOptions = {}): string { // Example lines.push(` ${muted("try:")} ${magenta(example)}`); if (overview) { - lines.push( - ` ${muted("all commands:")} ${magenta(asInvocationCommand("sentry help"))}`, - ); + lines.push(` ${muted("all commands:")} ${magenta(sentry`help`)}`); } lines.push(""); @@ -862,7 +856,7 @@ function formatCommandHuman(cmd: CommandInfo): string { */ function formatGroupHuman(group: RouteInfo): string { const lines: string[] = []; - lines.push(asRunningAppCommand(`sentry ${group.name}`)); + lines.push(sentry`${group.name}`); lines.push(""); lines.push(` ${group.brief}`); lines.push(""); diff --git a/packages/cli/src/lib/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index 118d00f22..964bbbbd6 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 } from "./app-identity.js"; +import { asInvocationCommand, sentry } from "./app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -629,7 +629,7 @@ function buildNoMatchHint( return ( `No event matched this prefix in the last ${window}. ` + "Sentry cannot search by a partial event ID — pass the full " + - `32-character ID printed by \`${asInvocationCommand("sentry event list")}\`.` + `32-character ID printed by \`${sentry`event list`}\`.` ); } return ( diff --git a/packages/cli/src/lib/hex-id.ts b/packages/cli/src/lib/hex-id.ts index a95e7f00d..80854cbd8 100644 --- a/packages/cli/src/lib/hex-id.ts +++ b/packages/cli/src/lib/hex-id.ts @@ -8,7 +8,7 @@ * rather than rolling their own patterns. */ -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { ValidationError } from "./errors.js"; /** @@ -166,7 +166,7 @@ export function validateHexId(value: string, label: string): string { // 16-char hex looks like a span ID message += "\n\nThis looks like a span ID (16 characters). " + - `If you have the trace ID, try: ${asInvocationCommand(`sentry span view <trace-id> ${display}`)}`; + `If you have the trace ID, try: ${sentry`span view <trace-id> ${display}`}`; } else if (NON_HEX_RE.test(normalized)) { // Contains non-hex characters — likely a slug, name, or truncated input. // The hex-id-recovery module at the command layer provides more specific diff --git a/packages/cli/src/lib/id-arguments.ts b/packages/cli/src/lib/id-arguments.ts index e3d439fd0..9188d67fa 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 } from "./app-identity.js"; +import { asInvocationCommand, sentry } from "./app-identity.js"; import { looksLikeIssueShortId } from "./arg-parsing.js"; import { ContextError, ValidationError } from "./errors.js"; import { @@ -268,7 +268,7 @@ export const ID_KINDS = { validate: (value) => { if (!isAllDigits(value)) { throw new ValidationError( - `Invalid integration ID '${value}'. Use the numeric ID from '${asInvocationCommand("sentry integration list")}'.`, + `Invalid integration ID '${value}'. Use the numeric ID from '${sentry`integration list`}'.`, "integration-id", ); } diff --git a/packages/cli/src/lib/issue-arguments.ts b/packages/cli/src/lib/issue-arguments.ts index 440b9deb9..fa6633542 100644 --- a/packages/cli/src/lib/issue-arguments.ts +++ b/packages/cli/src/lib/issue-arguments.ts @@ -10,7 +10,7 @@ * names: each word is an issue. */ -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { firstNonBlankLine, splitNewlineArg } from "./arg-parsing.js"; import { ValidationError } from "./errors.js"; import { ID_KINDS, parseIdArguments } from "./id-arguments.js"; @@ -62,7 +62,7 @@ export function parseIssueArgument(arg: string): IssueArgument { // One token: an identifier (validated below, for its own messages) or a // name const { ids, name } = parseIdArguments([input], ID_KINDS.issue, { - usageHint: asInvocationCommand("sentry issue <command> <issue>"), + usageHint: sentry`issue <command> <issue>`, validate: false, }); if (name !== undefined) { diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts index a419522b8..30101e255 100644 --- a/packages/cli/src/lib/list-command.ts +++ b/packages/cli/src/lib/list-command.ts @@ -16,7 +16,7 @@ import type { Aliases, Command, CommandContext } from "@stricli/core"; import type { GenericSchema } from "valibot"; import type { SentryContext } from "../context.js"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { type ListTargetOptions, parseCursorFlag } from "./arg-parsing.js"; import { buildCommand, @@ -475,7 +475,7 @@ export function interceptSubcommand( } stderr.write( warning( - `Tip: "${target.trim()}" is a subcommand. Running: ${asInvocationCommand(`sentry ${routeName} ${target.trim()}`)}\n`, + `Tip: "${target.trim()}" is a subcommand. Running: ${sentry`${routeName} ${target.trim()}`}\n`, ), ); return; diff --git a/packages/cli/src/lib/metrics-transform.ts b/packages/cli/src/lib/metrics-transform.ts index 5639539fb..606b0d834 100644 --- a/packages/cli/src/lib/metrics-transform.ts +++ b/packages/cli/src/lib/metrics-transform.ts @@ -7,7 +7,7 @@ */ import type { MetricMeta } from "./api/explore.js"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { ResolutionError } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; @@ -51,9 +51,7 @@ export function resolveMetricField( throw new ResolutionError( `Aggregation '${agg}'`, `not recognized. Valid aggregations: ${[...VALID_AGGS].join(", ")}`, - asInvocationCommand( - `sentry explore my-org/ -m ${metricName} --agg sum --dataset metrics`, - ), + sentry`explore my-org/ -m ${metricName} --agg sum --dataset metrics`, ); } @@ -69,9 +67,7 @@ export function resolveMetricField( throw new ResolutionError( `Metric '${metricName}'`, "not found in this project", - asInvocationCommand( - `sentry explore my-org/ -m ${metricName} --dataset metrics --period 7d`, - ), + sentry`explore my-org/ -m ${metricName} --dataset metrics --period 7d`, suggestions.length > 0 ? [`Similar metrics: ${suggestions.join(", ")}`] : ["Use a wider --period to search for older metrics"], diff --git a/packages/cli/src/lib/org-list.ts b/packages/cli/src/lib/org-list.ts index c7c11a6b6..d5a431623 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 } from "./app-identity.js"; +import { asInvocationCommand, sentry } from "./app-identity.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, @@ -1056,7 +1056,7 @@ export async function handleProjectSearch<TEntity, TWithOrg>( "is an organization, not a project", `${asInvocationCommand(config.commandPrefix)} ${projectSlug}/`, [ - `List projects: ${asInvocationCommand(`sentry project list ${projectSlug}/`)}`, + `List projects: ${sentry`project list ${projectSlug}/`}`, `Specify a project: ${asInvocationCommand(config.commandPrefix)} ${projectSlug}/<project>`, ], ); diff --git a/packages/cli/src/lib/resolve-target.ts b/packages/cli/src/lib/resolve-target.ts index 9b38b23e3..7a0e04057 100644 --- a/packages/cli/src/lib/resolve-target.ts +++ b/packages/cli/src/lib/resolve-target.ts @@ -33,7 +33,7 @@ import { type ProjectWithOrg, resolveOrgDisplayName, } from "./api-client.js"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { looksLikeIssueShortId, type ParsedOrgProject, @@ -187,7 +187,7 @@ export async function resolveTargetSlugs( throw new ResolutionError( "Organization", "could not be resolved to a slug", - asInvocationCommand("sentry org list"), + sentry`org list`, ); } cacheResolvedProject(info, org, project); @@ -1031,7 +1031,7 @@ export function projectSearchNotFoundSuggestions( if (resolution.scopedOrg) { return [ `No project with this name found in organization '${resolution.scopedOrg}'`, - `Check the organization slug or try: ${asInvocationCommand(`sentry project list ${resolution.scopedOrg}/`)}`, + `Check the organization slug or try: ${sentry`project list ${resolution.scopedOrg}/`}`, ]; } return ["No project with this slug found in any accessible organization"]; @@ -1113,7 +1113,7 @@ export async function fetchProjectId( throw new ResolutionError( `Project '${project}'`, `not found in organization '${org}'`, - asInvocationCommand(`sentry project list ${org}/`), + sentry`project list ${org}/`, buildProjectNotFoundSuggestions(org, project, similar), ); } @@ -1221,7 +1221,7 @@ function combineProjectNotFoundErrors( return new ResolutionError( `Projects ${misses.map(({ slug }) => `'${slug}'`).join(", ")}`, `not found in organization '${org}'`, - asInvocationCommand(`sentry project list ${org}/`), + sentry`project list ${org}/`, [...specific, ...shared], ); } @@ -1282,7 +1282,7 @@ export async function resolveProjectIdsInOrg( throw new ResolutionError( `Project '${slug}'`, `has no numeric ID in organization '${org}'`, - asInvocationCommand(`sentry project list ${org}/`), + sentry`project list ${org}/`, ); } return id; @@ -1447,8 +1447,7 @@ export async function resolveAllTargets( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? - asInvocationCommand("sentry <command> <org>/<project>"), + options.usageHint ?? sentry`<command> <org>/<project>`, [], ); } @@ -1667,8 +1666,7 @@ export async function resolveOrgAndProject( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? - asInvocationCommand("sentry <command> <org>/<project>"), + options.usageHint ?? sentry`<command> <org>/<project>`, [], ); } @@ -1919,7 +1917,7 @@ async function promptForOrgProject(): Promise<ResolvedTarget | null> { throw new ResolutionError( `Organization '${orgSlug}'`, "has no accessible projects", - asInvocationCommand(`sentry project list ${orgSlug}/`), + sentry`project list ${orgSlug}/`, ); } @@ -1941,7 +1939,7 @@ async function promptForOrgProject(): Promise<ResolvedTarget | null> { setDefaultOrganization(orgSlug); setDefaultProject(projectSlug); log.info( - `Saved ${orgSlug}/${projectSlug} as your default. Change it with: ${asInvocationCommand("sentry cli defaults org <slug>")}`, + `Saved ${orgSlug}/${projectSlug} as your default. Change it with: ${sentry`cli defaults org <slug>`}`, ); } catch (error) { log.debug("Failed to persist selected org/project as default", error); @@ -2052,16 +2050,14 @@ async function buildAccountContextError( ); } alternatives.push( - `List projects example: ${asInvocationCommand(`sentry project list ${shellQuote(`${exampleOrg}/`)}`)}`, + `List projects example: ${sentry`project list ${shellQuote(`${exampleOrg}/`)}`}`, ); const notes = candidates.flatMap((candidate) => candidate.note ? [candidate.note] : [], ); if (shownOrgs.length < orgs.length) { notes.push(`Showing ${shownOrgs.length} of ${orgs.length} organizations.`); - alternatives.push( - `List all organizations: ${asInvocationCommand("sentry org list")}`, - ); + alternatives.push(`List all organizations: ${sentry`org list`}`); } return new ContextError( "Organization and project", @@ -2098,9 +2094,7 @@ export async function guideOrgProjectFailure( > & Partial<Pick<ResolveOptions, "cwd">>, ): Promise<ResolvedTarget> { - const usageHint = - options.usageHint ?? - asInvocationCommand("sentry <command> <org>/<project>"); + const usageHint = options.usageHint ?? sentry`<command> <org>/<project>`; if (canPromptForTarget(options.interactive)) { const picked = await promptForOrgProject(); @@ -2270,7 +2264,7 @@ export async function resolveProjectBoundSlug( "is an organization, not a project", usageHint.replace("<org>/<project>", `${projectSlug}/<project>`), [ - `List projects: ${asInvocationCommand(`sentry project list ${projectSlug}/`)}`, + `List projects: ${sentry`project list ${projectSlug}/`}`, `Specify a project: ${projectSlug}/<project>`, ], ); @@ -2452,9 +2446,7 @@ export async function resolveProjectBoundTarget( commandName: string, options: ResolveProjectBoundTargetOptions = {}, ): Promise<ResolvedProjectBoundTarget> { - const usageHint = - options.usageHint ?? - asInvocationCommand(`sentry ${commandName} <org>/<project>`); + const usageHint = options.usageHint ?? sentry`${commandName} <org>/<project>`; switch (parsed.type) { case "explicit": { @@ -2465,7 +2457,7 @@ export async function resolveProjectBoundTarget( case "org-all": throw new ContextError( "Project", - asInvocationCommand(`sentry ${commandName} ${parsed.org}/<project>`), + sentry`${commandName} ${parsed.org}/<project>`, [], ); @@ -2478,12 +2470,8 @@ export async function resolveProjectBoundTarget( throw new ResolutionError( `'${parsed.projectSlug}'`, "is an organization, not a project", - asInvocationCommand( - `sentry ${commandName} ${parsed.projectSlug}/<project>`, - ), - [ - `List projects: ${asInvocationCommand(`sentry project list ${parsed.projectSlug}/`)}`, - ], + sentry`${commandName} ${parsed.projectSlug}/<project>`, + [`List projects: ${sentry`project list ${parsed.projectSlug}/`}`], ); } @@ -2506,17 +2494,12 @@ export async function resolveProjectBoundTarget( const { projects } = resolution; if (projects.length > 1) { const projectOptions = projects - .map( - (m) => - ` ${asInvocationCommand(`sentry ${commandName} ${m.orgSlug}/${m.slug}`)}`, - ) + .map((m) => ` ${sentry`${commandName} ${m.orgSlug}/${m.slug}`}`) .join("\n"); throw new ResolutionError( `Project '${resolution.displaySlug}'`, "is ambiguous", - asInvocationCommand( - `sentry ${commandName} <org>/${parsed.projectSlug}`, - ), + sentry`${commandName} <org>/${parsed.projectSlug}`, [ `Found in ${projects.length} organizations. Specify one:\n${projectOptions}`, ], @@ -2733,7 +2716,7 @@ export async function resolveProjectBoundTargets( throw new ResolutionError( `Organization '${org}'`, "has no accessible projects", - asInvocationCommand(`sentry project list ${org}/`), + sentry`project list ${org}/`, ["Check that you have access to projects in this organization"], ); } @@ -2757,9 +2740,9 @@ export async function resolveProjectBoundTargets( throw new ResolutionError( `'${displaySlug}'`, "looks like an issue short ID, not a project slug", - asInvocationCommand(`sentry issue view ${displaySlug}`), + sentry`issue view ${displaySlug}`, [ - `To list issues in a project: ${asInvocationCommand("sentry issue list <org>/<project>")}`, + `To list issues in a project: ${sentry`issue list <org>/<project>`}`, ], ); } @@ -2774,7 +2757,7 @@ export async function resolveProjectBoundTargets( "is an organization, not a project", `${prefix} ${parsed.projectSlug}/`, [ - `List projects: ${asInvocationCommand(`sentry project list ${parsed.projectSlug}/`)}`, + `List projects: ${sentry`project list ${parsed.projectSlug}/`}`, `Specify a project: ${prefix} ${parsed.projectSlug}/<project>`, ], ); @@ -2871,7 +2854,7 @@ export async function resolveOrgOptionalTarget( parsed: ParsedOrgProject, cwd: string, commandName: string, - usageHint = asInvocationCommand(`sentry ${commandName} <target>`), + usageHint = sentry`${commandName} <target>`, ): Promise<ResolvedOrgOptionalTarget> { // org-all: resolve the org slug only if (parsed.type === "org-all") { @@ -2885,7 +2868,7 @@ export async function resolveOrgOptionalTarget( if (!resolved) { throw new ContextError("Organization", usageHint, [ "SENTRY_ORG environment variable", - asInvocationCommand("sentry cli defaults"), + sentry`cli defaults`, ]); } return withTelemetryContext({ org: resolved.org }); @@ -2928,7 +2911,7 @@ export async function resolveOrgOnlyTarget( parsed: ParsedOrgProject, cwd: string, commandName: string, - usageHint = asInvocationCommand(`sentry ${commandName} <target>`), + usageHint = sentry`${commandName} <target>`, ): Promise<string> { if (parsed.type === "auto-detect") { const resolved = await resolveOrg({ cwd }); diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 7aa0104ce..a7f8659aa 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -22,7 +22,7 @@ import { type RetryPolicy, type TransportCache, } from "@sentry/api/client"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { formatAuthHeader } from "./auth-header.js"; import { seedCompletions } from "./api/completion-seed.js"; import { maybeWarnEnvTokenIgnored } from "./auth-hint.js"; @@ -250,8 +250,8 @@ function buildTimeoutHint(idempotent: boolean): string { ? "Try again." : "The change may still have been applied; check before trying again."; const check = isSentrySaasUrl(baseUrl) - ? `Check Sentry's status with: ${asInvocationCommand("sentry status")}` - : `Check the server's health with: ${asInvocationCommand(`sentry status --url ${baseUrl}`)}`; + ? `Check Sentry's status with: ${sentry`status`}` + : `Check the server's health with: ${sentry`status --url ${baseUrl}`}`; return `The Sentry API at ${baseUrl} did not respond in time. ${retry}\n ${check}`; } diff --git a/packages/cli/src/lib/sentryclirc-import.ts b/packages/cli/src/lib/sentryclirc-import.ts index 55259c846..3cccddb97 100644 --- a/packages/cli/src/lib/sentryclirc-import.ts +++ b/packages/cli/src/lib/sentryclirc-import.ts @@ -31,7 +31,7 @@ import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { getActiveAccountId, @@ -536,7 +536,7 @@ async function validateAndFetchUser( // Transient network error — keep the token, warn the user log.debug("Token validation failed with transient error", error); result.warnings.push( - `Could not validate token (network error). Token was stored — run '${asInvocationCommand("sentry auth status")}' to verify.`, + `Could not validate token (network error). Token was stored — run '${sentry`auth status`}' to verify.`, ); return true; } diff --git a/packages/cli/src/lib/setup-code.ts b/packages/cli/src/lib/setup-code.ts index 4175874fa..856f8bf8f 100644 --- a/packages/cli/src/lib/setup-code.ts +++ b/packages/cli/src/lib/setup-code.ts @@ -3,7 +3,7 @@ import { looseObject, safeParse, string } from "valibot"; import type { BootstrapResult } from "../output-schemas/auth.js"; import { pointDefaultUrlAtAccount } from "./account-selection.js"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { normalizeAuthToken } from "./auth-header.js"; import { getAccountIdToReplace, @@ -99,7 +99,7 @@ export async function bootstrapAuthentication( !options.force ) { throw new ValidationError( - `A stored login already exists. To replace it, run:\nSENTRY_SETUP_CODE='<setup-code>' ${asInvocationCommand("sentry auth bootstrap --force")}`, + `A stored login already exists. To replace it, run:\nSENTRY_SETUP_CODE='<setup-code>' ${sentry`auth bootstrap --force`}`, "force", ); } diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index 22b0eb347..cbb0d23bc 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 } from "./app-identity.js"; +import { asInvocationCommand, sentry } from "./app-identity.js"; import { firstNonBlankLine, type ListTarget, @@ -112,7 +112,7 @@ export function orgProjectTarget<R>( requireExplicitTarget( parsed, "Project target", - asInvocationCommand(`sentry ${commandNameOf(context)} <org>/<project>`), + sentry`${commandNameOf(context)} <org>/<project>`, ), ); } @@ -219,7 +219,7 @@ export const orgTarget = boundPositional( resolveOrgInput( input, context.cwd, - asInvocationCommand(`sentry ${commandNameOf(context)} <org>`), + sentry`${commandNameOf(context)} <org>`, ), ); diff --git a/packages/cli/src/lib/trace-target.ts b/packages/cli/src/lib/trace-target.ts index a088f560b..3747f6514 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 } from "./app-identity.js"; +import { asInvocationCommand, sentry } from "./app-identity.js"; import { type ParsedOrgProject, parseOrgProjectArg, @@ -302,9 +302,7 @@ async function recoverTraceId( const result = await recoverHexId(rawTraceId, "trace", ctx); return handleRecoveryResult(result, failure, { entityType: "trace", - canonicalCommand: asInvocationCommand( - `sentry trace view ${ctx.org || "<org>"}/${ctx.project ?? "<project>"}/<id>`, - ), + canonicalCommand: sentry`trace view ${ctx.org || "<org>"}/${ctx.project ?? "<project>"}/<id>`, logTag: "trace-target", }); } @@ -424,7 +422,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}`))}`, - `List projects: ${asInvocationCommand(`sentry project list ${parsed.org}/`)}`, + `List projects: ${sentry`project list ${parsed.org}/`}`, ]); case "auto-detect": { diff --git a/packages/cli/src/lib/upgrade.ts b/packages/cli/src/lib/upgrade.ts index 2507997a1..2f110912c 100644 --- a/packages/cli/src/lib/upgrade.ts +++ b/packages/cli/src/lib/upgrade.ts @@ -21,7 +21,7 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, sep } from "node:path"; import { setTimeout } from "node:timers/promises"; import { prerelease as semverPrerelease, valid as semverValid } from "semver"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { acquireLock, cleanupOldBinary, @@ -1181,7 +1181,7 @@ async function waitForBinaryVisible(path: string): Promise<number> { throw new UpgradeError( "execution_failed", `Downloaded binary is missing or empty at ${path}. ` + - `This is usually transient — rerun \`${asInvocationCommand("sentry cli upgrade")}\` to retry.`, + `This is usually transient — rerun \`${sentry`cli upgrade`}\` to retry.`, ); } @@ -1248,7 +1248,7 @@ export async function downloadBinaryToTemp( "offline_cache_miss", offline === "explicit" ? `Cannot upgrade to ${version} in offline mode — no pre-downloaded update is available. ` + - `Run \`${asInvocationCommand("sentry cli upgrade")}\` without \`--offline\` to download the update directly.` + `Run \`${sentry`cli upgrade`}\` without \`--offline\` to download the update directly.` : `Cannot upgrade to ${version} — the network is unavailable and no pre-downloaded update was found. ` + "Check your internet connection and try again.", ); diff --git a/packages/cli/src/lib/version-check.ts b/packages/cli/src/lib/version-check.ts index ebd6c1b9b..e2920a13f 100644 --- a/packages/cli/src/lib/version-check.ts +++ b/packages/cli/src/lib/version-check.ts @@ -10,7 +10,7 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node"; import { compare as semverCompare } from "semver"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import type { UpgradeSource } from "./binary.js"; import { CLI_VERSION } from "./constants.js"; import { getReleaseChannel } from "./db/release-channel.js"; @@ -428,14 +428,14 @@ function formatStandardUpdateNotification(latestVersion: string): string { const label = channel === "nightly" ? "New nightly available:" : "Update available:"; - return `\n${muted(label)} ${cyan(CLI_VERSION)} -> ${cyan(latestVersion)} Run ${cyan(`"${asInvocationCommand("sentry cli upgrade")}"`)} to update.\n`; + return `\n${muted(label)} ${cyan(CLI_VERSION)} -> ${cyan(latestVersion)} Run ${cyan(`"${sentry`cli upgrade`}"`)} to update.\n`; } function formatContextualUpdateNotification(latestVersion: string): string { return ( `\n${muted("A new version of sentry-cli is available")} (${cyan(latestVersion)})${muted(".")} ` + `${muted("Upgrading may resolve this — we fix a lot of bugs in every release.")} ` + - `${muted("Run")} ${cyan(`"${asInvocationCommand("sentry cli upgrade")}"`)} ${muted("to update.")}\n` + `${muted("Run")} ${cyan(`"${sentry`cli upgrade`}"`)} ${muted("to update.")}\n` ); } diff --git a/packages/cli/src/lib/wrangler.ts b/packages/cli/src/lib/wrangler.ts index 449b2ce6a..59c022f97 100644 --- a/packages/cli/src/lib/wrangler.ts +++ b/packages/cli/src/lib/wrangler.ts @@ -9,7 +9,7 @@ import { access, readFile } from "node:fs/promises"; import { basename, join } from "node:path"; -import { asInvocationCommand } from "./app-identity.js"; +import { sentry } from "./app-identity.js"; import { logger } from "./logger.js"; /** Wrangler project configuration filenames, in resolution order. */ @@ -421,7 +421,7 @@ function injectIntoPackageScript( } if (script.slice(packageWrangler.end).trim()) { logger.warn( - `Could not inject SENTRY_SPOTLIGHT into compound package script "${invocation.name}"; run ${asInvocationCommand("sentry local run")} without an explicit command to use auto-detection`, + `Could not inject SENTRY_SPOTLIGHT into compound package script "${invocation.name}"; run ${sentry`local run`} without an explicit command to use auto-detection`, ); return unchanged; } diff --git a/packages/cli/test/lib/app-identity.test.ts b/packages/cli/test/lib/app-identity.test.ts index 04dce3ce3..6bccbbce5 100644 --- a/packages/cli/test/lib/app-identity.test.ts +++ b/packages/cli/test/lib/app-identity.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, test } from "vitest"; import { asInvocationCommand, - asRunningAppCommand, getLoginCommand, getUrlLoginCommand, + sentry, setAppIdentity, } from "../../src/lib/app-identity.js"; import { @@ -48,13 +48,41 @@ describe("app identity", () => { expect(getLoginCommand()).toBe("npx -y @sentry/mcp-server auth login"); }); - test("rewrites only a leading `sentry ` in example commands", () => { + test("rewrites only a leading `sentry ` in runtime commands", () => { setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); - expect(asRunningAppCommand("sentry auth login --url x")).toBe( + expect(asInvocationCommand("sentry auth login --url x")).toBe( "sentry-mcp auth login --url x", ); - expect(asRunningAppCommand("npx sentry auth")).toBe("npx sentry auth"); - expect(asRunningAppCommand("sentry-cli login")).toBe("sentry-cli login"); + 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/"); + + setAppIdentity({ name: "sentry-mcp", packageName: "@sentry/mcp-server" }); + expect(sentry`issue list ${org}/`).toBe("sentry-mcp issue list acme/"); + + process.env.npm_command = "exec"; + expect(sentry`issue list ${org}/`).toBe( + "npx -y @sentry/mcp-server issue list acme/", + ); + }); + + test("the sentry tag rewrites interpolated commands once", () => { + 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", + ); + expect(sentry`issue list ${"sentry"}/`).toBe( + "sentry-mcp issue list sentry/", + ); }); test("command hints run this program again", () => { diff --git a/packages/cli/test/lib/dsn/errors.test.ts b/packages/cli/test/lib/dsn/errors.test.ts index 00e5c6bc7..6781e46fa 100644 --- a/packages/cli/test/lib/dsn/errors.test.ts +++ b/packages/cli/test/lib/dsn/errors.test.ts @@ -62,7 +62,7 @@ describe("formatConflictError", () => { expect(error).toContain("Project ID: 456"); expect(error).toContain("Project ID: 101112"); expect(error).toContain("sentry <command> <org>/<project>"); - expect(error).toContain("sentry config set defaults.org"); + expect(error).toContain("sentry cli defaults org"); }); test("formats error with DSN without projectId", () => { @@ -186,7 +186,7 @@ describe("formatNoDsnError", () => { expect(error).toContain("JavaScript/TypeScript source code"); expect(error).toContain("export SENTRY_DSN="); expect(error).toContain("sentry <command> <org>/<project>"); - expect(error).toContain("sentry config set defaults.org"); + expect(error).toContain("sentry cli defaults org"); }); test("includes accessible projects when showProjects is true", async () => { diff --git a/scripts/cli-lint-rules.test.mjs b/scripts/cli-lint-rules.test.mjs index c573f1b15..01d23c951 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -553,11 +553,16 @@ tester.run( valid: [ { filename: source, - code: "function hint() { return `Run '${asInvocationCommand(\"sentry auth login\")}' first.`; }", + code: "function hint() { return `Run '${sentry`auth login`}' first.`; }", }, { filename: source, - code: "function hint(org) { return asInvocationCommand(`sentry project list ${org}/`); }", + code: "function hint(org) { return sentry`project list ${org}/`; }", + }, + // A command held in a variable goes through the runtime form. + { + filename: source, + code: "function hint(usageHint) { return `Use: ${asInvocationCommand(usageHint)}`; }", }, { filename: commands, @@ -567,6 +572,11 @@ tester.run( filename: source, code: "const message = 'Sentry CLI could not reach sentry.io';", }, + // Only `sentry <route>` is a command; the binary's name in prose is not. + { + 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, @@ -588,7 +598,8 @@ tester.run( code: "function hint(org) { return `Try: sentry project list ${org}/`; }", errors: 1, }, - // An interpolated subcommand or a <command> placeholder is still a command. + // An interpolated subcommand, a <command> placeholder, or a flag is + // still a command. { filename: commands, code: "function hint(name) { return `Run sentry ${name} <org>/`; }", @@ -599,6 +610,11 @@ tester.run( code: "function hint() { return ' sentry <command> <org>/<project>'; }", errors: 1, }, + { + filename: commands, + code: "function hint() { return ' sentry --help See all commands'; }", + errors: 1, + }, // Every command runs under npx or another name, not only sentry-mcp. { filename: commands, @@ -610,10 +626,27 @@ tester.run( code: "const MESSAGE = 'Usage: sentry local run -- <command>';", errors: 1, }, + // A literal command takes the tag, not the runtime form. + { + filename: source, + code: "function hint() { return asInvocationCommand('sentry auth login'); }", + errors: 1, + }, + // The tag adds the program name itself. + { + filename: source, + code: "function hint() { return sentry`sentry auth login`; }", + errors: 1, + }, // At module load the running program is not known yet. { filename: source, - code: "const HINT = asInvocationCommand('sentry auth login');", + code: "const HINT = sentry`auth login`;", + errors: 1, + }, + { + filename: source, + code: "const HINT = asInvocationCommand(USAGE_HINT);", errors: 1, }, ],