diff --git a/.oxlintrc.json b/.oxlintrc.json index 5b76dc938..6a86d7b0d 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 4e74554cc..b7ac98c41 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -826,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 write a hint's command with the `` sentry`…` `` tag from `src/lib/app-identity.ts`, without the program name (`` sentry`issue list ${org}/` ``), inside the function that builds the hint. A command held in a variable goes through `asInvocationCommand(command)`. A module-level usage line (`USAGE_HINT`, `commandPrefix`) is fine when the code that prints it wraps it, as `ContextError`, `ResolutionError`, `buildValidationMessage`, and `org-list` do. Command help (`docs`, `fullDescription`, `brief`, `examples`) stays `sentry …`. Lint (`sentry-cli/no-hardcoded-sentry-command`) flags a bare `sentry ` (routes come from `src/command-manifest.ts`), and `pnpm run check:doc-examples` checks each tag's command and flags against the route tree. - **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 dd182a51c..37ab5c620 100644 --- a/packages/cli/lint-rules/cli-oxlint-plugin.js +++ b/packages/cli/lint-rules/cli-oxlint-plugin.js @@ -358,6 +358,109 @@ function untaggedParser(value) { return undefined; } +/** + * Top-level routes of the CLI, read from `src/command-manifest.ts` (one + * `name: route(…)` or `name: hidden(…)` entry each). Only `sentry ` + * is a command; `sentry binary` or `sentry v1.2` is prose. + */ +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 ``, or an + * interpolation. + */ +const SENTRY_WORD_RE = + /(?:^|[\s`'"(])sentry (\uFFFC|<|--?[a-z]|[a-z][a-z0-9-]*)/g; + +/** Whether `text` names a `sentry `. */ +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 `, such as a usage line or a command prefix. */ +function isWholeSentryCommand(text) { + return namesSentryCommand(text) && text.startsWith("sentry "); +} + +/** The tag that builds a command hint: `` sentry`issue list` ``. */ +function isSentryTag(node) { + return ( + 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([ + "docs", + "fullDescription", + "brief", + "examples", +]); + +/** 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 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 (isSentryTag(current) || isInvocationCall(current)) return true; + if ( + current.type === "Property" && + 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; +} + const rules = { "no-silent-catch": rule( "Handle or report caught errors instead of discarding them.", @@ -802,6 +905,61 @@ const rules = { }; }, ), + "no-hardcoded-sentry-command": rule( + "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 (!namesSentryCommand(text) || isInvocationSafe(node)) return; + // A module-level constant that holds a whole command is data: the + // code that prints it wraps it, as ContextError and org-list do. + if (isWholeSentryCommand(text) && !hasFunctionAncestor(node)) return; + report(node); + }; + // The running program is known only once an entry point has started, + // 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 { + CallExpression(node) { + if (!isInvocationCall(node)) return; + checkModuleLevel(node); + const [arg] = node.arguments; + const text = + arg?.type === "Literal" + ? arg.value + : arg?.type === "TemplateLiteral" + ? templateText(arg) + : undefined; + if (typeof text === "string" && text.startsWith("sentry ")) + report( + node, + "Write a literal command with the sentry`…` tag; asInvocationCommand() is for commands held in variables.", + ); + }, + TaggedTemplateExpression(node) { + if (!isSentryTag(node)) return; + checkModuleLevel(node); + 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) { + if (!isSentryTag(node.parent)) check(node, templateText(node)); + }, + }; + }, + ), "no-skipped-tests": rule( "Do not leave disabled tests in the suite.", (_context, file, report) => diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 6e6db6dc7..8e5c588eb 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, @@ -70,6 +74,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"]); @@ -258,6 +265,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 `` 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) => `${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")) @@ -324,9 +365,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) { logger.success( - `${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 722491f2b..afc2547d8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,6 +14,7 @@ * stream error handlers and calls `startCli()`. */ +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"; @@ -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., ${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 a77ac7aca..3b7b15682 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 { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { parseText, textParser } from "../../lib/completion-target.js"; import { @@ -302,8 +303,8 @@ 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: sentry`agent-conversation list ${scope} -c next${flagSuffix}`, + prevHint: 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..eabfcdb20 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, 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"; @@ -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: ${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: ${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..aafed67e6 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 { sentry } 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,7 @@ export const viewCommand = buildCommand({ const { targets } = await resolveProjectBoundTargets(parsed, { cwd, - usageHint: "sentry alert issues view //", + usageHint: 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..bd6cd4632 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 { sentry } 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 /", + 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..01026eceb 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, 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"; @@ -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: ${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: ${sentry`alert metrics /`}`, ); } diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index f1df2164a..0454f7609 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 { sentry } from "../../lib/app-identity.js"; import { ATTRIBUTE_TYPES, parseAttributeFilter, @@ -130,11 +131,11 @@ 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: ${sentry`explore ${target} --dataset metrics -m -F ${shellQuote(key)}`}`; } const target = project ? `${org}/${project}` : `${org}/`; 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: ${sentry`${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`}`; } /** @@ -174,7 +175,7 @@ function pageHint( filter: string | undefined, ): string { const filterArg = filter ? ` ${shellQuote(filter)}` : ""; - const parts = [`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 77df081e4..742a5e3a2 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 { sentry } 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 '${sentry`auth switch `}'.`, + }; } if (stored.length === 0) { - return { hint: "Log in with 'sentry auth login'." }; + return { + hint: `Log in with '${sentry`auth login`}'.`, + }; } - return { hint: "Add another account with 'sentry auth login'." }; + return { + 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 52cba8634..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:", - " sentry auth login --read-only", - " 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: sentry cli import | " + - `To pass it directly: sentry auth login --token ${urlHint}` + `To import it: ${sentry`cli import`} | ` + + `To pass it directly: ${sentry`auth login --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 { // 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 '${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 '${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..a710e3d4e 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 { sentry } 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,7 @@ export const logoutCommand = buildCommand({ if (selectors.length > 0) { targets = uniqueAccounts( selectors.map((sel) => - resolveAccountSelector(sel, COMMAND, { url: flags.url }), + resolveAccountSelector(sel, sentry`auth logout`, { url: flags.url }), ), ); } else { @@ -188,8 +187,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 '${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..5ab79e94b 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 { sentry } 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 '${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..783614abd 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 { sentry } 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 \`${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 1c37c7793..3f52b2943 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 { sentry } 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 = 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,8 @@ 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 '${sentry`auth list`}' to see stored accounts`], ); } const [only] = candidates; @@ -68,7 +68,7 @@ async function pickAccount( return await promptForAccount( candidates, "Switch to which account?", - COMMAND, + command, ); } @@ -153,7 +153,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 '${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..baf37dddf 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 { 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"; @@ -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", + 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 \`${sentry`auth login`}\` or a personal access token`, + `Run \`${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 \`${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 '${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")} [ []]`, + `Too many arguments. Usage: ${sentry`cli defaults`} [ []]`, "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} `)}'.`, + `Cannot use --clear with a value. Use either '${sentry`cli defaults ${keyArg} --clear`}' or '${sentry`cli defaults ${keyArg} `}'.`, "args", ); } diff --git a/packages/cli/src/commands/cli/fix.ts b/packages/cli/src/commands/cli/fix.ts index 3bbe5cf6a..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 { @@ -339,8 +339,8 @@ 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" + - ` sudo ${asInvocationCommand("sentry cli fix")}` + "Or fix it automatically:\n\n" + + ` sudo ${sentry`cli fix`}` ); } diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 50ee7a35c..dabe83555 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 { 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"; @@ -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 ?? ""}`, + ` ${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: ${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..ec4029df9 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 { sentry } from "../../lib/app-identity.js"; import { assertAutoLoginHostTrusted } from "../../lib/auto-auth.js"; import { determineInstallDir, @@ -482,9 +483,9 @@ function printWelcomeMessage( 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(" 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"); } @@ -864,7 +865,7 @@ export const setupCommand = buildCommand({ await warmCachesAfterLogin(); } } else { - log.info("Run 'sentry auth login' to authenticate later."); + log.info(`Run '${sentry`auth login`}' to authenticate later.`); } }, warn, diff --git a/packages/cli/src/commands/cli/upgrade.ts b/packages/cli/src/commands/cli/upgrade.ts index 83048238f..f2a4cf3a5 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 { sentry } 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 \`${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.", + `\`${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 '${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 \`${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"; + ? sentry`cli upgrade ${target}` + : 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 \`${sentry`cli upgrade`}\`.`, ); } if (!isEbusyError(error) || attempt === SPAWN_MAX_ATTEMPTS) { diff --git a/packages/cli/src/commands/code-mappings/upload.ts b/packages/cli/src/commands/code-mappings/upload.ts index 292e783c7..9600394c3 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 { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseFilePath, @@ -258,7 +259,7 @@ export const uploadCommand = buildCommand({ if (!repository) { throw new ContextError( "Repository name", - "sentry code-mappings upload --repo ", + 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..d80d27516 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 { sentry } 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 / ", + 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>", + 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..bc34d4d74 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 { sentry } from "../../lib/app-identity.js"; import { openInBrowser } from "../../lib/browser.js"; import { advancePaginationState, @@ -352,8 +353,8 @@ 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: 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 0001b9ee9..4f78a798a 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 { sentry } from "../../lib/app-identity.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; import { DATASET_FIELDS, @@ -464,19 +465,19 @@ async function build404Error( throw new ResolutionError( `Organization ${org}`, "not found or has no dashboards", - "sentry dashboard list <org>/", + sentry`dashboard list <org>/`, [ - "Verify the organization slug with: sentry org list", + `Verify the organization slug with: ${sentry`org list`}`, "Check that you have access to the organization", ], ); } - const listHint = `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: sentry dashboard list", + `Check the dashboard ID or title with: ${sentry`dashboard list`}`, ]; if (ctx.orgSlug) { try { @@ -504,7 +505,7 @@ async function build404Error( throw new ResolutionError( `Organization ${org}`, "not found", - "sentry org list", + sentry`org list`, ["Verify the organization slug and your access"], ); } @@ -520,14 +521,14 @@ function build403Error( throw new ResolutionError( `Dashboard ${ctx.dashboardId} in ${org}`, "access denied", - `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", - "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 dd54647c9..006a567a3 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 { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { advancePaginationState, @@ -210,8 +211,8 @@ 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: sentry`dashboard revisions ${orgSlug}/ ${dashboardId} -c prev`, + nextHint: sentry`dashboard revisions ${orgSlug}/ ${dashboardId} -c next`, }); const navStr = nav ? ` ${nav}` : ""; @@ -222,7 +223,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: ${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..5dae0becf 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 { sentry } 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', + ` ${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: ${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..b4ce0535c 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 { sentry } 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", + ` ${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..7f136e680 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, 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"; @@ -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: ${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..1cde436a4 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, sentry } 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: ${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..091e1304d 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 { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { @@ -86,7 +87,7 @@ 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 = 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..371103f2d 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 { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -58,7 +59,7 @@ function nextPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`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. */ @@ -66,7 +67,7 @@ function prevPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`sentry event list ${issueArg} -c prev`, flags); + return appendEventsFlags(sentry`event list ${issueArg} -c prev`, flags); } // --------------------------------------------------------------------------- @@ -208,7 +209,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 '${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..8c26e216b 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 { sentry } 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'", + 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..485d7690f 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, sentry } 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: ${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. '${sentry`event view 17370`}'`, `Or use the @latest selector for the latest event of the most recent unresolved issue: ${SELECTOR_HINT}`, ]); } @@ -594,7 +595,7 @@ 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: sentry`event view ${recoveryCtx.org || "<org>"}/${recoveryCtx.project ?? "<project>"}/<id>`, logTag: "event.view", }); } @@ -691,7 +692,7 @@ export async function resolveEventTarget( const resolved = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - `sentry event view <org>/${parsed.projectSlug} ${eventId}`, + sentry`event view <org>/${parsed.projectSlug} ${eventId}`, parsed.originalSlug, ); target = { @@ -746,7 +747,7 @@ export async function resolveOrgAllTarget( throw new ResolutionError( `Event ${eventId} in organization "${org}"`, "not found", - `sentry event view ${org}/<project> ${eventId}`, + sentry`event view ${org}/<project> ${eventId}`, ); } return { @@ -779,7 +780,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: ${sentry`event view ${resolved.org}/${resolved.project} ${eventId}`}`, ); return { org: resolved.org, @@ -961,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: 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}`, - `sentry event view ${org}/<project> ${eventId}`, + sentry`event view ${org}/<project> ${eventId}`, suggestions, ); } @@ -1039,7 +1040,7 @@ async function resolveSelectorScope( return await resolveProjectBoundSlug( parsed.projectSlug, SELECTOR_HINT, - `sentry event view <org>/${parsed.projectSlug}/${selector}`, + sentry`event view <org>/${parsed.projectSlug}/${selector}`, parsed.originalSlug, ); case ProjectSpecificationType.AutoDetect: { @@ -1083,7 +1084,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 +1128,7 @@ 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", sentry`event view ${issueId}`); } const org = resolved.org; log.info(`Fetching latest event for issue ${issueId}...`); @@ -1188,7 +1189,7 @@ async function resolveIssueShortcut( if (!resolved) { throw new ContextError( "Organization", - `sentry issue view ${issueShortId}`, + sentry`issue view ${issueShortId}`, ); } @@ -1201,7 +1202,7 @@ async function resolveIssueShortcut( throw new ResolutionError( `Issue '${issueShortId}'`, "has no associated project", - `sentry event view <org>/<project> ${eventId}`, + sentry`event view <org>/<project> ${eventId}`, ["Specify the project explicitly to view this event"], ); } @@ -1230,7 +1231,7 @@ async function resolveIssueShortcut( return { org: resolved.org, data, - hint: `Tip: Use '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 90b2521c5..9495c1bed 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 { sentry } 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' + + ` ${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" + @@ -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" + + ` ${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" + - ' 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", ); } @@ -1073,11 +1074,11 @@ export const exploreCommand = buildListCommand("explore", { hasPrev, hasMore, prevHint: appendFlagHints( - `sentry explore ${baseTarget} -c prev`, + sentry`explore ${baseTarget} -c prev`, hintFlags, ), nextHint: appendFlagHints( - `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 6f634a9b0..f86335d5a 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 { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { @@ -111,7 +112,7 @@ function pageHint( flags: Pick<ListFlags, "status" | "limit" | "query" | "period">, ): string { return appendFeedbackFlags( - `sentry feedback list ${formatScope(org, project)} -c ${direction}`, + sentry`feedback list ${formatScope(org, project)} -c ${direction}`, flags, ); } @@ -286,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 '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 1ca7460d1..b3f2d825e 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 { sentry } 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>`, + 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..e142860bf 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 { sentry } 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`, + 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`, + sentry`feedback list ${org}/ --status all`, ["The @latest selector only matches unresolved Feedback."], ); } @@ -102,15 +103,15 @@ export async function resolveFeedback( issueArg: feedbackArg, cwd, command, - commandBase: "sentry feedback", + commandBase: 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, sentry`feedback`), + [`List available Feedback: ${sentry`feedback list`}`], ); } throw error; @@ -121,8 +122,8 @@ 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"], + 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 0514f8e51..65a405400 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 { 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"; @@ -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 \`${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/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..7fc35a948 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 { sentry } from "../../lib/app-identity.js"; import { issueSelectorHelp } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -52,7 +53,7 @@ function nextPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`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. */ @@ -60,7 +61,7 @@ function prevPageHint( issueArg: string, flags: Pick<EventsFlags, "query" | "full" | "period">, ): string { - return appendEventsFlags(`sentry issue events ${issueArg} -c prev`, flags); + return appendEventsFlags(sentry`issue events ${issueArg} -c prev`, flags); } // --------------------------------------------------------------------------- @@ -184,7 +185,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 '${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..8cd367883 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 { 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"; @@ -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: ${sentry`issue plan ${primaryArg}`}`, + }; }, }); diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 2a01252e7..f674bac31 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 { sentry } 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 '${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 '${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 '${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)}`, + sentry`issue list ${shellQuote(target)}`, `--limit ${flags.limit}`, `--sort ${flags.sort}`, `--period ${shellQuote(formatTimeRangeFlag(flags.period))}`, @@ -765,7 +766,7 @@ function pageHint( target: string, flags: ListFlags, ): string { - return appendIssueFlags(`sentry issue list ${target} -c ${direction}`, flags); + return appendIssueFlags(sentry`issue list ${target} -c ${direction}`, flags); } /** @@ -1063,7 +1064,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: ${sentry`project list <org>/`}`, ); return formatDetailWithSuggestions(lines, suggestions); @@ -1182,10 +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: sentry auth login"); + lines.push(` • Re-authenticate with: ${sentry`auth login`}`); } - lines.push(" • Verify project membership: sentry project list <org>/"); + lines.push(` • Verify project membership: ${sentry`project list <org>/`}`); return lines.join("\n "); } @@ -1198,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: sentry project list <org>/`; + return `${base}\n Verify project membership: ${sentry`project list <org>/`}`; } /** @@ -1817,7 +1818,7 @@ export const listCommand = buildListCommand("issue", { compact: true, } satisfies IssueListResult); return { - hint: `Tip: Use '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 358dcb624..1cadc34a8 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 { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseIssueReference } from "../../lib/completion-target.js"; import { @@ -337,10 +338,10 @@ 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: ${sentry`issue merge CLI-K9 --into CLI-15H`}` + : `Example: ${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` + + `'${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..2594520ae 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 { 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"; @@ -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: ${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..e776dc866 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 { sentry } from "../../lib/app-identity.js"; import { ISSUE_TARGET } from "../../lib/completion-target.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -66,7 +67,7 @@ export function requireIssueArgument( if (issue) { return issue; } - const { command, commandBase = "sentry issue" } = issueCommand; + const { command, commandBase = sentry`issue` } = issueCommand; throw new ContextError("Issue ID", `${commandBase} ${command} <issue>`); } @@ -78,7 +79,7 @@ export function requireIssueArgument( function issueTarget( issueCommand: IssueCommand, ): (this: IssueTargetContext, input?: string) => IssueTarget { - const { command, commandBase = "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 b221d287b..8da5e645d 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 { sentry } 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 (${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..38b732fb3 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 { 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"; @@ -184,7 +185,7 @@ 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", + 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..bfcd05b1f 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 { sentry } 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 = sentry`issue`, trailingArgs: readonly string[] = [], ): string { return appendCommandArguments( @@ -625,7 +626,7 @@ export async function findSelectorIssue( throw new ResolutionError( `Selector '${selector}'`, "no unresolved issues found", - `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.`, ], @@ -670,7 +671,7 @@ async function resolveIssueSearch( return { org, issue }; } - const listCommand = `sentry issue list ${org}/ --query ${shellQuote(query)}`; + const listCommand = sentry`issue list ${org}/ --query ${shellQuote(query)}`; if (!issue) { throw new ResolutionError( `Search ${shellQuote(query)}`, @@ -925,7 +926,7 @@ export async function resolveIssue( options: ResolveIssueOptions, ): Promise<ResolvedIssueResult> { const { cwd, command, commandBase, trailingArgs = [] } = options; - const effectiveCommandBase = commandBase ?? "sentry issue"; + const effectiveCommandBase = commandBase ?? sentry`issue`; const { text, parsed, baseUrl } = issueArgumentOf(options.issueArg); if (baseUrl) { applySentryUrlContext(baseUrl); @@ -1216,7 +1217,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: ${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..b0923cb87 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 { 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"; @@ -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 '${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..939c99cd0 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 { sentry } 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: ${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 ${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..d9f40cd04 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1,3 +1,4 @@ +import { sentry } 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: ${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(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..9b6c5b964 100644 --- a/packages/cli/src/commands/local/ui.ts +++ b/packages/cli/src/commands/local/ui.ts @@ -1,3 +1,4 @@ +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"; @@ -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 ${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..c9973fb8d 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 { sentry } 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 '${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: ${sentry`log list ${org}/${traceId}`}`; } else { - hint += `\nFilter by project: 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 f254b22b4..15f4a2753 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 { sentry } 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: ${sentry`issue view ${first}`}`, }), }; } @@ -139,7 +140,7 @@ async function validateAndRecoverLogId( }); return handleRecoveryResult(result, err, { entityType: "log", - canonicalCommand: `sentry log view ${target.org}/${target.project}/<id>`, + canonicalCommand: sentry`log view ${target.org}/${target.project}/<id>`, logTag: "log.view", }); } @@ -178,7 +179,7 @@ async function resolveTarget( const result = await resolveProjectBoundSlug( parsed.projectSlug, USAGE_HINT, - `sentry log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, + sentry`log view <org>/${parsed.projectSlug} ${rawLogIds.join(" ")}`, parsed.originalSlug, ); if ( @@ -379,7 +380,7 @@ function throwNotFoundError( throw new ResolutionError( `Log '${id}'`, `not found in ${org}/${project}`, - `sentry log view ${org}/${project}/${id}`, + 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..6ba8fcc2d 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 { sentry } from "../../lib/app-identity.js"; import { buildCommand } from "../../lib/command.js"; import { parseAccountSelector, @@ -320,11 +321,11 @@ function mergeAccountOrgs(results: AccountOrgs[]): { */ function reloginCommand(account: AccountSummary): string { if (account.email) { - return `sentry auth login ${accountSelectorOf(account)}`; + return sentry`auth login ${accountSelectorOf(account)}`; } return account.host - ? `sentry auth login --url ${account.host}` - : "sentry auth login"; + ? sentry`auth login --url ${account.host}` + : sentry`auth login`; } /** @@ -351,7 +352,7 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { } const account = resolveAccountSelector( selector, - "sentry org list --account", + sentry`org list --account`, { fuzzy: true, }, @@ -510,10 +511,10 @@ 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 '${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 '${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..c359faa3c 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 { 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"; @@ -70,7 +71,7 @@ export const uuidCommand = buildCommand({ if (!path?.trim()) { throw new ContextError( "Mapping file path", - "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 a86a3e656..c46c2052e 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, 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"; @@ -134,10 +135,10 @@ function buildPlatformError(nameArg: string, platform?: string): string { `${heading}\n` + didYouMean + "\nUsage:\n" + - ` sentry project create ${nameArg}:<platform>\n\n` + + ` ${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 '${sentry`platform list`}' to see all valid platform identifiers.\n` + + `Run '${sentry`project create <name>:<platform>`}' with any valid Sentry platform identifier.` ); } @@ -180,7 +181,7 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Team '${teamSlug}'`, `not found in ${orgSlug}`, - `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(", ")}`], ); } @@ -200,10 +201,10 @@ async function handleCreateProject404(opts: { throw new ResolutionError( `Project '${name}' in ${orgSlug}`, "could not be created", - `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: sentry team list ${orgSlug}/`, + `List teams: ${sentry`team list ${orgSlug}/`}`, ], ); } @@ -320,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: sentry project view ${orgSlug}/${slug}`, + `View it: ${sentry`project view ${orgSlug}/${slug}`}`, ); } @@ -552,7 +553,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..7b4093482 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 { sentry } 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: ${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..d4f2d3662 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 { sentry } from "../../lib/app-identity.js"; import { explicitProjectSlugs, formatProjectTarget, @@ -375,7 +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 `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. */ @@ -392,7 +393,7 @@ function autoDetectHeader( const org = orgs[0] as string; return ( `Showing ${count} projects (more available). ` + - `Use '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.`; @@ -407,7 +408,7 @@ function selfHostedWarning( } return ( `Note: ${skippedSelfHosted} DSN(s) could not be resolved. ` + - "Specify the organization explicitly: sentry project list <org>/" + `Specify the organization explicitly: ${sentry`project list <org>/`}` ); } @@ -458,7 +459,7 @@ export async function handleAutoDetect( hintParts.push(footer); } hintParts.push( - "Tip: Use 'sentry project view <org>/<project>' for details", + `Tip: Use '${sentry`project view <org>/<project>`}' for details`, ); } @@ -509,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 'sentry project list ${org}/' to see all projects`, + `Tip: Use '${sentry`project list ${org}/`}' to see all projects`, }; } } @@ -517,7 +518,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 '${sentry`project list ${org}/`}' to see all projects`, }; } const project: ProjectWithOrg = { ...projectResult.value, orgSlug: org }; @@ -533,7 +534,7 @@ export async function handleExplicit( return { items: filtered, - hint: `Tip: Use 'sentry project view ${org}/${projectSlug}' for details`, + hint: `Tip: Use '${sentry`project view ${org}/${projectSlug}`}' for details`, }; } @@ -573,14 +574,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 '${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 '${sentry`project list ${org}/`}' to see all projects`, ); } if (nameFiltered.length > 0) { @@ -595,7 +596,7 @@ async function handleExplicitProjects( } if (hintParts.length === 0) { hintParts.push( - `Tip: Use 'sentry project view ${org}/<project>' for details`, + `Tip: Use '${sentry`project view ${org}/<project>`}' for details`, ); } @@ -660,7 +661,7 @@ export async function handleOrgAll( let hint: string | undefined; let header: string | undefined; - const base = `sentry project list ${org}/`; + const base = sentry`project list ${org}/`; const suffix = flags.platform ? ` --platform ${flags.platform}` : ""; const navHint = paginationHint({ hasPrev, @@ -688,7 +689,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 '${sentry`project view <org>/<project>`}' for details`; hint = tip; } @@ -735,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 'sentry project view <org>/<project>' for details", + hint: `Tip: Use '${sentry`project view <org>/<project>`}' for details`, }; } @@ -821,7 +822,7 @@ export async function handleProjectSearch( return { items: limited, header, - hint: "Tip: Use 'sentry project view <org>/<project>' for details", + hint: `Tip: Use '${sentry`project view <org>/<project>`}' for details`, }; } @@ -836,7 +837,7 @@ function handleProjectSearchNotFound( throw new ResolutionError( `Project '${resolution.displaySlug}'`, "not found", - `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 66f8ae85f..ac3728605 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, sentry } 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}`, + 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>`, + 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..d49b03ece 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 { sentry } 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: ${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..7212a200b 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 { sentry } 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: 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: ${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..eebe71908 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 { sentry } from "../../lib/app-identity.js"; import { buildCommand, numberParser } from "../../lib/command.js"; import { parseFilePath, @@ -70,8 +71,8 @@ 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`, + 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.", @@ -243,8 +244,8 @@ 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", + sentry`release set-commits 1.0.0 --commit owner/repo@abc123`, + sentry`release set-commits 1.0.0 --commit owner/repo@prev..abc123`, ], "commit", ); @@ -294,7 +295,7 @@ 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"], + [sentry`release set-commits 1.0.0 --path apps/mobile,packages/shared`], "path", ); } @@ -303,12 +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 ? [ - "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`, + 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", + 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", ); @@ -318,7 +319,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"], + [sentry`release set-commits 1.0.0 --from v0.9.0`], "from", FROM_RANGE_NOTE, ); @@ -330,8 +331,8 @@ 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", + 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, @@ -341,8 +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.", [ - "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", + 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, @@ -508,9 +509,9 @@ 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", + 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 a6228fb80..6f1e61289 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 { 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"; @@ -118,7 +119,7 @@ export const downloadCommand = buildCommand({ throw new ResolutionError( `Replay '${replayId}'`, "not found", - `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", @@ -179,7 +180,7 @@ function noRecordingError(org: string, replayId: string): ResolutionError { return new ResolutionError( `Replay '${replayId}'`, "has no recording to download", - `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 5a5d0e079..db0cd2388 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 { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, @@ -200,7 +201,7 @@ function nextPageHint( flags: Pick<ListFlags, "environment" | "query" | "sort" | "period">, ): string { return appendReplayFlags( - `sentry replay list ${formatScope(org, project)} -c next`, + sentry`replay list ${formatScope(org, project)} -c next`, flags, ); } @@ -211,7 +212,7 @@ function prevPageHint( flags: Pick<ListFlags, "environment" | "query" | "sort" | "period">, ): string { return appendReplayFlags( - `sentry replay list ${formatScope(org, project)} -c prev`, + sentry`replay list ${formatScope(org, project)} -c prev`, flags, ); } @@ -426,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 '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 e8399d039..c7d063ee5 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 { sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -182,9 +183,9 @@ export async function validateReplayProjectScope( throw new ResolutionError( `Replay '${replayId}'`, "has no project association", - `sentry replay ${command} ${org}/${project}/${replayId}`, + sentry`replay ${command} ${org}/${project}/${replayId}`, [ - `Use the org-scoped replay instead: sentry replay ${command} ${org}/${replayId}`, + `Use the org-scoped replay instead: ${sentry`replay ${command} ${org}/${replayId}`}`, ], ); } @@ -194,9 +195,9 @@ export async function validateReplayProjectScope( throw new ResolutionError( `Replay '${replayId}'`, `is not in project '${project}'`, - `sentry replay ${command} ${org}/${project}/${replayId}`, + sentry`replay ${command} ${org}/${project}/${replayId}`, [ - `Use the org-scoped replay instead: sentry replay ${command} ${org}/${replayId}`, + `Use the org-scoped replay instead: ${sentry`replay ${command} ${org}/${replayId}`}`, ], ); } @@ -390,7 +391,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Replay '${replayId}'`, "not found", - `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 68451c102..b723f7c0e 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 { 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"; @@ -231,13 +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 = "sentry schema"; - const searchHint = `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 ? `sentry schema ${closest}` : browseHint; + const primaryHint = closest ? sentry`schema ${closest}` : browseHint; const suggestions = closest - ? ["sentry schema Browse all resources", searchHint] + ? [sentry`schema Browse all resources`, searchHint] : [searchHint]; return new ResolutionError( `Resource "${resource}"`, @@ -290,8 +291,8 @@ function pathNotFoundError( `Endpoint '${label}'`, "does not exist in the schema", example - ? `sentry schema "${example.method} ${example.path}"` - : "sentry schema", + ? sentry`schema "${example.method} ${example.path}"` + : sentry`schema`, [`Available methods at this path: ${methods}`], ); } @@ -299,16 +300,16 @@ function pathNotFoundError( const segment = lastConcreteSegment(query.path); const resourceExists = segment !== undefined && getEndpointsByResource(segment).length > 0; - let hint = "sentry schema"; + let hint = sentry`schema`; if (resourceExists) { - hint = `sentry schema ${segment}`; + hint = sentry`schema ${segment}`; } else if (segment) { - hint = `sentry schema --search ${segment}`; + hint = sentry`schema --search ${segment}`; } - const suggestions = ["sentry schema Browse all resources"]; + const suggestions = [sentry`schema Browse all resources`]; if (resourceExists && segment) { suggestions.unshift( - `sentry schema --search ${segment} Search endpoints by keyword`, + sentry`schema --search ${segment} Search endpoints by keyword`, ); } throw new ResolutionError( @@ -364,8 +365,8 @@ 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"], + 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 97e09b29f..fe0d25dd0 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 { 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"; @@ -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}`, + `\`${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 9705e2492..60c7af868 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 { sentry } 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 \`${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..43219be0d 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 { sentry } 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 \`${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..9c1a396a7 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 { sentry } from "../../lib/app-identity.js"; import { type ParsedOrgProject, parseOrgProjectInput, @@ -293,7 +294,7 @@ function traceNextPageHint( flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { return appendFlagHints( - `sentry span list ${org}/${project}/${traceId} -c next`, + sentry`span list ${org}/${project}/${traceId} -c next`, flags, ); } @@ -306,7 +307,7 @@ function tracePrevPageHint( flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { return appendFlagHints( - `sentry span list ${org}/${project}/${traceId} -c prev`, + sentry`span list ${org}/${project}/${traceId} -c prev`, flags, ); } @@ -317,7 +318,7 @@ function projectNextPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendFlagHints(`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. */ @@ -326,7 +327,7 @@ function projectPrevPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendFlagHints(`sentry span list ${org}/${project} -c prev`, flags); + return appendFlagHints(sentry`span list ${org}/${project} -c prev`, flags); } // --------------------------------------------------------------------------- @@ -493,7 +494,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 '${sentry`span view ${traceId} <span-id>`}' to view span details.`; } return { @@ -580,7 +581,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 '${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..a0940b864 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 { 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"; @@ -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: ${sentry`span view <trace-id> ${validateSpanId(first)}`}`, + `Use '${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 '${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: ${sentry`span view ${traceArg}/<trace-id> ${spanId}`}`, ]); } } @@ -199,7 +200,7 @@ 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: 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..00a766ba6 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 { 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"; @@ -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 \`${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..9b83867e7 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, 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"; @@ -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 '${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}`, + 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: ${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..edbf56036 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 { 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"; @@ -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: ${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..9ba61513a 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 { 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"; @@ -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 '${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..26cd31725 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 { sentry } from "../../lib/app-identity.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { choiceParser, textParser } from "../../lib/completion-target.js"; import { @@ -110,7 +111,7 @@ function nextPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendTraceFlags(`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. */ @@ -119,7 +120,7 @@ function prevPageHint( project: string, flags: Pick<ListFlags, "sort" | "query" | "period">, ): string { - return appendTraceFlags(`sentry trace list ${org}/${project} -c prev`, flags); + return appendTraceFlags(sentry`trace list ${org}/${project} -c prev`, flags); } /** @@ -335,7 +336,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 '${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..6b3fdb6f3 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 { sentry } 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: ${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: ${sentry`trace logs ${org}/${traceId}`}`, }; } return { - hint: `Filter by project: 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 c0cb654e8..b5294b276 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 { 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"; @@ -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: ${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: ${sentry`trace view ${org}/${firstProject}/${traceId}`}`; } - return `Tip: Open in browser with 'sentry trace view --web ${traceId}'`; + return `Tip: Open in browser with '${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: ${sentry`issue view ${first}`}`, + }), }; } @@ -404,7 +407,7 @@ async function resolveTraceFromIssue( const resolved = await resolveOrg({ cwd }); if (!resolved) { - throw new ContextError("Organization", `sentry issue view ${issueShortId}`); + throw new ContextError("Organization", sentry`issue view ${issueShortId}`); } const org = resolved.org; @@ -420,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: sentry issue view ${issueShortId}`, + `Try: ${sentry`issue view ${issueShortId}`}`, ); } @@ -449,7 +452,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 '${sentry`span view`}' for specific spans.`, ); } @@ -588,7 +591,7 @@ export const viewCommand = buildCommand({ throw new ResolutionError( `Trace '${traceId}'`, "not found", - `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 dcc985903..10b1e6b65 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 { 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"; @@ -245,11 +246,11 @@ 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 '${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 '${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..77ca93c21 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, 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"; @@ -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>", + 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..7fe272aa9 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 { sentry } 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: ${sentry`wasm-split <input>`}`, "input", ); } diff --git a/packages/cli/src/lib/account-selection.ts b/packages/cli/src/lib/account-selection.ts index 49cac0584..1dba4c807 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 { sentry } from "./app-identity.js"; import { type AccountSummary, findAccounts, @@ -92,9 +93,9 @@ export function resolveAccountSelector( similar.length > 0 ? [ ...similar.map((a) => `Did you mean ${describeAccount(a)}?`), - "Run 'sentry auth list' to see stored accounts", + `Run '${sentry`auth list`}' to see stored accounts`, ] - : ["Run '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 cdf9c3793..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 { 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: 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: 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.: 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: 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: 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 654744594..90e420841 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 { sentry } 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: ${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 649e210cf..5ec5fa879 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 { sentry } from "../app-identity.js"; import { getCiCheckout } from "../ci.js"; import { getEnv } from "../env.js"; import { @@ -500,11 +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.", [ - `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>", - `sentry release set-commits ${target} --local`, + sentry`release set-commits ${target} --local`, ] : []), ], @@ -603,10 +604,10 @@ function noMatchingRepositoryError( const examples = [ ...suggestions.map( (name) => - `sentry release set-commits ${target} --commit '${name}@${commit}'`, + sentry`release set-commits ${target} --commit '${name}@${commit}'`, ), ...(isInsideGitWorkTree(cwd) - ? [`sentry release set-commits ${target} --local`] + ? [sentry`release set-commits ${target} --local`] : []), ]; const didYouMean = @@ -646,9 +647,9 @@ function noRepoIntegrationError( cwd?: string, ): ApiError { const detail = buildValidationMessage(connectIntegrationHint(orgSlug), [ - `sentry integration list ${orgSlug}/`, + sentry`integration list ${orgSlug}/`, ...(isInsideGitWorkTree(cwd) - ? [`sentry release set-commits ${orgSlug}/${version} --local`] + ? [sentry`release set-commits ${orgSlug}/${version} --local`] : []), ]); return new ApiError( @@ -880,7 +881,7 @@ export async function explainSetCommitsError( : (ref?.commit ?? "<commit-sha>"); const target = `${orgSlug}/${version}`; const commitExample = (name: string) => - `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; @@ -909,7 +910,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}/`], + [sentry`integration list ${orgSlug}/`], COMMIT_REPOSITORY_FIELD, connectIntegrationHint(orgSlug), ); @@ -924,7 +925,7 @@ export async function explainSetCommitsError( : ""; return validationError( `${headline}${didYouMean}`, - [...suggestions.map(commitExample), `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 5e7cd3d1f..280272800 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 { sentry } 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", + sentry`issue view PROJECT-1`, + sentry`issue explain PROJECT-1`, + sentry`issue explain 123456789`, + 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: ${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..da1bff038 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) */ @@ -107,7 +109,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/command.ts b/packages/cli/src/lib/command.ts index 1edb64535..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 { 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., 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 0da5409a2..2fcd43363 100644 --- a/packages/cli/src/lib/dashboard-arguments.ts +++ b/packages/cli/src/lib/dashboard-arguments.ts @@ -17,6 +17,7 @@ * apply. */ +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"; @@ -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 <command> ${url.org}/ <id-or-title>`, + `Try: ${sentry`dashboard <command> ${url.org}/ <id-or-title>`}`, "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` + + ` ${sentry`dashboard view ${orgPrefix}${url.dashboardId}`}\n\n` + `To list dashboards${orgNote}:\n\n` + - ` 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 e2a484359..0c296fa99 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 { sentry } 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 \`${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 \`${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 069e3e7b2..9978940a2 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`, ); } @@ -156,7 +156,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 7f2e7d001..8e294f0f7 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 { sentry } from "../app-identity.js"; import { getDsnSourceDescription } from "./detector.js"; import { SENTRY_DSN_ENV } from "./env.js"; import { getAccessibleProjects } from "./resolver.js"; @@ -31,11 +32,11 @@ export function formatConflictError(result: DsnDetectionResult): string { }); lines.push("To resolve, specify which project to use:"); - lines.push(" sentry <command> <org>/<project>"); + lines.push(` ${sentry`<command> <org>/<project>`}`); lines.push(""); lines.push("Or set a default project:"); - lines.push(" sentry config set defaults.org <org>"); - lines.push(" sentry config set defaults.project <project>"); + lines.push(` ${sentry`cli defaults org <org>`}`); + lines.push(` ${sentry`cli defaults project <project>`}`); return lines.join("\n"); } @@ -92,11 +93,11 @@ export async function formatNoDsnError( ); lines.push(""); lines.push("3. Specify project explicitly:"); - lines.push(" sentry <command> <org>/<project>"); + lines.push(` ${sentry`<command> <org>/<project>`}`); lines.push(""); lines.push("4. Set default project:"); - lines.push(" sentry config set defaults.org <org>"); - lines.push(" sentry config set defaults.project <project>"); + lines.push(` ${sentry`cli defaults org <org>`}`); + lines.push(` ${sentry`cli defaults project <project>`}`); return lines.join("\n"); } @@ -120,7 +121,7 @@ export function formatResolutionError(error: Error, dsnRaw: string): string { " - The DSN is invalid or expired", "", "Try specifying the project explicitly:", - " 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 ae8645822..9d16e5b5d 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 { sentry } 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: ${sentry`<command> <org>/<project>`}`, ); } 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..3b196eb5e 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 { sentry } 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 <dsn> -m 'My message'"; +function eventSendDsnHint(): string { + return sentry`event send <dsn> -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<string> { 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 ${sentry`auth login`}.`, + 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..21ee0184d 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 { sentry } 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 <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 ef1dab498..076506192 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -30,7 +30,12 @@ import { buildOrgSettingsUrl, buildSeerSettingsUrl, } from "./sentry-urls.js"; -import { getLoginCommand, getUrlLoginCommand } from "./app-identity.js"; +import { + asInvocationCommand, + getLoginCommand, + getUrlLoginCommand, + sentry, +} from "./app-identity.js"; // --------------------------------------------------------------------------- // Exit code constants @@ -370,12 +375,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 <org>/' 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 '${sentry`org list`}' to find your organization slug`, + `Run '${sentry`project list <org>/`}' to find project slugs`, + ]; +} /** * Build the formatted context error message with usage hints. @@ -489,13 +497,13 @@ 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(); + // 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, }), @@ -503,7 +511,7 @@ export class ContextError extends CliError { ); this.name = "ContextError"; this.resource = resource; - this.command = command; + this.command = usage; this.alternatives = resolvedAlternatives; this.note = note; @@ -558,15 +566,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 { @@ -595,7 +606,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) { @@ -735,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 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 ${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 4111c8508..90b920f7d 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 { sentry } 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: ${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..cfe4777f8 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 { sentry } 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; ${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 <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 41e9cbca4..f8ca8678c 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 { sentry } 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,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 `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 3636546a4..9d1a7d090 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 { sentry } 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,8 @@ 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}`; + ? sentry`event view ${data.org}/${project}/${associatedEventId}` + : sentry`event view ${associatedEventId}`; rows.push(["Linked error", feedbackCodeSpan(command)]); } @@ -363,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 - ? `sentry replay view ${data.org}/${replayId}` - : `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 3cb1b7e48..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("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: \`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: \`${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 \`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 e346ade89..d105dbe5f 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 { sentry } 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: \`${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..773b08511 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 { sentry } 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: \`${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: \`${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: ${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: ${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..addb73135 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 { sentry } 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"], + [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,7 @@ export function getCommitLog( `Unknown git ref '${from}': not found in this repository.`, [ `git rev-parse ${from}`, - "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 3792f2164..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 { 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,7 +137,7 @@ function toHelpCommand( .filter((sub: RouteMapEntry) => !sub.hidden) .map((sub: RouteMapEntry) => sub.name.original); return { - usage: `sentry ${routeName} ${pickSubcommands(routeName, subNames).join(" | ")}`, + usage: sentry`${routeName} ${pickSubcommands(routeName, subNames).join(" | ")}`, description: brief, }; } @@ -149,13 +149,13 @@ function toHelpCommand( getPositionalString(entry.target.parameters.positional); const usageSuffix = placeholder ? ` ${placeholder}` : ""; return { - usage: `sentry ${routeName}${usageSuffix}`, + usage: sentry`${routeName}${usageSuffix}`, description: brief, }; } return { - usage: `sentry ${routeName}`, + usage: sentry`${routeName}`, description: brief, }; } @@ -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,7 @@ type CustomHelpOptions = { */ export function printCustomHelp(options: CustomHelpOptions = {}): string { const loggedIn = isAuthenticated(); - const example = loggedIn ? EXAMPLE_LOGGED_IN : EXAMPLE_LOGGED_OUT; + const example = loggedIn ? sentry`issue list` : sentry`auth`; const overview = Boolean(options.overview) && !detectAgent(); const header: string[] = []; @@ -370,7 +367,7 @@ 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(sentry`help`)}`); } lines.push(""); @@ -859,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(""); @@ -872,6 +869,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/hex-id-recovery.ts b/packages/cli/src/lib/hex-id-recovery.ts index 40d34cfe9..964bbbbd6 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, sentry } from "./app-identity.js"; import { type ParsedOrgProject, ProjectSpecificationType, @@ -589,7 +590,7 @@ function buildSlugHint( const orgPart = org ? `${org}/` : "<org>/"; const cmd = SLUG_REDIRECT_COMMAND[entityType]; if (cmd) { - return `'${input}' looks like a project slug, not a ${entityType} ID. Try: ${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 +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 `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 3047b418c..80854cbd8 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 { sentry } 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 <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 146ca4edc..9188d67fa 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, sentry } 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: <org>/<project> <${label}>`, "positional", ); @@ -267,7 +268,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 '${sentry`integration list`}'.`, "integration-id", ); } @@ -395,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", ); } @@ -429,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)}`, ); } @@ -542,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/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/issue-arguments.ts b/packages/cli/src/lib/issue-arguments.ts index 3fb9a2970..fa6633542 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 { 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"; @@ -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 <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 758c4a75f..30101e255 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 { sentry } 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: ${sentry`${routeName} ${target.trim()}`}\n`, ), ); return; diff --git a/packages/cli/src/lib/mcp/server.ts b/packages/cli/src/lib/mcp/server.ts index 969bfcad2..636d83e9a 100644 --- a/packages/cli/src/lib/mcp/server.ts +++ b/packages/cli/src/lib/mcp/server.ts @@ -243,6 +243,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..606b0d834 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 { sentry } from "./app-identity.js"; import { ResolutionError } from "./errors.js"; import { fuzzyMatch } from "./fuzzy.js"; @@ -50,7 +51,7 @@ 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`, + sentry`explore my-org/ -m ${metricName} --agg sum --dataset metrics`, ); } @@ -66,7 +67,7 @@ export function resolveMetricField( throw new ResolutionError( `Metric '${metricName}'`, "not found in this project", - `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 e77b2ad8e..d5a431623 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, sentry } from "./app-identity.js"; import { explicitProjectSlugs, type ParsedOrgProject } from "./arg-parsing.js"; import { advancePaginationState, @@ -709,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, @@ -806,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`, ); } @@ -857,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}`, @@ -921,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; } @@ -973,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; } @@ -1053,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: sentry project list ${projectSlug}/`, - `Specify a project: ${config.commandPrefix} ${projectSlug}/<project>`, + `List projects: ${sentry`project list ${projectSlug}/`}`, + `Specify a project: ${asInvocationCommand(config.commandPrefix)} ${projectSlug}/<project>`, ], ); } @@ -1073,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), ); } @@ -1232,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.`; @@ -1280,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); @@ -1340,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.", ), ); @@ -1559,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-target.ts b/packages/cli/src/lib/resolve-target.ts index d76a0e74c..7a0e04057 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 { sentry } 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", + 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: ${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}/`, + 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}/`, + 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}/`, + sentry`project list ${org}/`, ); } return id; @@ -1446,7 +1447,7 @@ export async function resolveAllTargets( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? "sentry <command> <org>/<project>", + options.usageHint ?? sentry`<command> <org>/<project>`, [], ); } @@ -1665,7 +1666,7 @@ export async function resolveOrgAndProject( if (org || project) { throw new ContextError( "Organization and project", - options.usageHint ?? "sentry <command> <org>/<project>", + options.usageHint ?? sentry`<command> <org>/<project>`, [], ); } @@ -1916,7 +1917,7 @@ async function promptForOrgProject(): Promise<ResolvedTarget | null> { throw new ResolutionError( `Organization '${orgSlug}'`, "has no accessible projects", - `sentry project list ${orgSlug}/`, + sentry`project list ${orgSlug}/`, ); } @@ -1938,7 +1939,7 @@ async function promptForOrgProject(): Promise<ResolvedTarget | null> { setDefaultOrganization(orgSlug); setDefaultProject(projectSlug); log.info( - `Saved ${orgSlug}/${projectSlug} as your default. Change it with: 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); @@ -2049,14 +2050,14 @@ async function buildAccountContextError( ); } alternatives.push( - `List projects example: 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: sentry org list"); + alternatives.push(`List all organizations: ${sentry`org list`}`); } return new ContextError( "Organization and project", @@ -2093,7 +2094,7 @@ export async function guideOrgProjectFailure( > & Partial<Pick<ResolveOptions, "cwd">>, ): Promise<ResolvedTarget> { - const usageHint = options.usageHint ?? "sentry <command> <org>/<project>"; + const usageHint = options.usageHint ?? sentry`<command> <org>/<project>`; if (canPromptForTarget(options.interactive)) { const picked = await promptForOrgProject(); @@ -2263,7 +2264,7 @@ export async function resolveProjectBoundSlug( "is an organization, not a project", usageHint.replace("<org>/<project>", `${projectSlug}/<project>`), [ - `List projects: sentry project list ${projectSlug}/`, + `List projects: ${sentry`project list ${projectSlug}/`}`, `Specify a project: ${projectSlug}/<project>`, ], ); @@ -2445,8 +2446,7 @@ export async function resolveProjectBoundTarget( commandName: string, options: ResolveProjectBoundTargetOptions = {}, ): Promise<ResolvedProjectBoundTarget> { - const usageHint = - options.usageHint ?? `sentry ${commandName} <org>/<project>`; + const usageHint = options.usageHint ?? sentry`${commandName} <org>/<project>`; switch (parsed.type) { case "explicit": { @@ -2457,7 +2457,7 @@ export async function resolveProjectBoundTarget( case "org-all": throw new ContextError( "Project", - `sentry ${commandName} ${parsed.org}/<project>`, + sentry`${commandName} ${parsed.org}/<project>`, [], ); @@ -2470,8 +2470,8 @@ export async function resolveProjectBoundTarget( throw new ResolutionError( `'${parsed.projectSlug}'`, "is an organization, not a project", - `sentry ${commandName} ${parsed.projectSlug}/<project>`, - [`List projects: sentry project list ${parsed.projectSlug}/`], + sentry`${commandName} ${parsed.projectSlug}/<project>`, + [`List projects: ${sentry`project list ${parsed.projectSlug}/`}`], ); } @@ -2494,12 +2494,12 @@ export async function resolveProjectBoundTarget( const { projects } = resolution; if (projects.length > 1) { const projectOptions = projects - .map((m) => ` sentry ${commandName} ${m.orgSlug}/${m.slug}`) + .map((m) => ` ${sentry`${commandName} ${m.orgSlug}/${m.slug}`}`) .join("\n"); throw new ResolutionError( `Project '${resolution.displaySlug}'`, "is ambiguous", - `sentry ${commandName} <org>/${parsed.projectSlug}`, + sentry`${commandName} <org>/${parsed.projectSlug}`, [ `Found in ${projects.length} organizations. Specify one:\n${projectOptions}`, ], @@ -2716,7 +2716,7 @@ export async function resolveProjectBoundTargets( throw new ResolutionError( `Organization '${org}'`, "has no accessible projects", - `sentry project list ${org}/`, + sentry`project list ${org}/`, ["Check that you have access to projects in this organization"], ); } @@ -2740,8 +2740,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 <org>/<project>"], + sentry`issue view ${displaySlug}`, + [ + `To list issues in a project: ${sentry`issue list <org>/<project>`}`, + ], ); } @@ -2755,7 +2757,7 @@ export async function resolveProjectBoundTargets( "is an organization, not a project", `${prefix} ${parsed.projectSlug}/`, [ - `List projects: sentry project list ${parsed.projectSlug}/`, + `List projects: ${sentry`project list ${parsed.projectSlug}/`}`, `Specify a project: ${prefix} ${parsed.projectSlug}/<project>`, ], ); @@ -2852,7 +2854,7 @@ export async function resolveOrgOptionalTarget( parsed: ParsedOrgProject, cwd: string, commandName: string, - usageHint = `sentry ${commandName} <target>`, + usageHint = sentry`${commandName} <target>`, ): Promise<ResolvedOrgOptionalTarget> { // org-all: resolve the org slug only if (parsed.type === "org-all") { @@ -2866,7 +2868,7 @@ export async function resolveOrgOptionalTarget( if (!resolved) { throw new ContextError("Organization", usageHint, [ "SENTRY_ORG environment variable", - "sentry cli defaults", + sentry`cli defaults`, ]); } return withTelemetryContext({ org: resolved.org }); @@ -2909,7 +2911,7 @@ export async function resolveOrgOnlyTarget( parsed: ParsedOrgProject, cwd: string, commandName: string, - usageHint = `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/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/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 6805b093a..a7f8659aa 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 { sentry } 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: ${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 1225ccf74..3cccddb97 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 { sentry } 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 '${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..856f8bf8f 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 { sentry } 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='<setup-code>' 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/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 c59f9bd96..cbb0d23bc 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, sentry } from "./app-identity.js"; import { firstNonBlankLine, type ListTarget, @@ -111,7 +112,7 @@ export function orgProjectTarget<R>( requireExplicitTarget( parsed, "Project target", - `sentry ${commandNameOf(context)} <org>/<project>`, + sentry`${commandNameOf(context)} <org>/<project>`, ), ); } @@ -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", @@ -216,7 +219,7 @@ export const orgTarget = boundPositional( resolveOrgInput( input, context.cwd, - `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 abaffff06..3747f6514 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, sentry } from "./app-identity.js"; import { type ParsedOrgProject, parseOrgProjectArg, @@ -301,7 +302,7 @@ async function recoverTraceId( const result = await recoverHexId(rawTraceId, "trace", ctx); return handleRecoveryResult(result, failure, { entityType: "trace", - canonicalCommand: `sentry trace view ${ctx.org || "<org>"}/${ctx.project ?? "<project>"}/<id>`, + canonicalCommand: sentry`trace view ${ctx.org || "<org>"}/${ctx.project ?? "<project>"}/<id>`, logTag: "trace-target", }); } @@ -420,8 +421,8 @@ export async function resolveTraceOrgProject( case "org-scoped": throw new ContextError("Specific project", usageHint, [ - `Use: ${usageHint.replace(USAGE_TARGET_RE, `${parsed.org}/<project>/${parsed.traceId}`)}`, - `List projects: sentry project list ${parsed.org}/`, + `Use: ${asInvocationCommand(usageHint.replace(USAGE_TARGET_RE, `${parsed.org}/<project>/${parsed.traceId}`))}`, + `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 750b3f246..2f110912c 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 { sentry } from "./app-identity.js"; import { acquireLock, cleanupOldBinary, @@ -1180,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 `sentry cli upgrade` to retry.", + `This is usually transient — rerun \`${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 \`${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..e2920a13f 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 { sentry } 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(`"${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(`"${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..59c022f97 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 { sentry } 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 ${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 28a7f1237..6bccbbce5 100644 --- a/packages/cli/test/lib/app-identity.test.ts +++ b/packages/cli/test/lib/app-identity.test.ts @@ -1,12 +1,16 @@ import { afterEach, describe, expect, test } from "vitest"; import { asInvocationCommand, - asRunningAppCommand, getLoginCommand, getUrlLoginCommand, + sentry, 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"]); @@ -44,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", () => { @@ -65,4 +97,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/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 5b31400e5..01d23c951 100644 --- a/scripts/cli-lint-rules.test.mjs +++ b/scripts/cli-lint-rules.test.mjs @@ -545,3 +545,110 @@ tester.run( ], }, ); + +tester.run( + "no-hardcoded-sentry-command", + plugin.rules["no-hardcoded-sentry-command"], + { + valid: [ + { + filename: source, + code: "function hint() { return `Run '${sentry`auth login`}' first.`; }", + }, + { + filename: source, + 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, + 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';", + }, + // 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, + code: "const USAGE_HINT = 'sentry issue list <org>/<project>';", + }, + { + filename: commands, + code: "const docs = { examples: [{ command: 'sentry issue list' }] };", + }, + ], + invalid: [ + { + filename: source, + code: "function hint() { return \"Run 'sentry auth login' first.\"; }", + errors: 1, + }, + { + filename: commands, + code: "function hint(org) { return `Try: sentry project list ${org}/`; }", + errors: 1, + }, + // An interpolated subcommand, a <command> placeholder, or a flag 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, + }, + { + 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, + code: "function hint() { return 'Run sentry issue list'; }", + errors: 1, + }, + { + filename: commands, + 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 = sentry`auth login`;", + errors: 1, + }, + { + filename: source, + code: "const HINT = asInvocationCommand(USAGE_HINT);", + errors: 1, + }, + ], + }, +);