Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
1 change: 1 addition & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <route>` (routes come from `src/command-manifest.ts`), and `pnpm run check:doc-examples` checks each tag's command and flags against the route tree.
- **Examples belong to the command**: add them to `docs.examples` (`{ description, command }[]`, see `src/commands/agent-conversation/list.ts`). They render in `--help`, the generated docs, and the skill references. Don't hand-write examples in `fullDescription` (an `Examples:` block, or indented `sentry …` lines under any label) or an Examples heading in a fragment; the `sentry-cli/no-examples-in-full-description` lint rule and `pnpm run check:fragments` reject both.
- **Target syntax belongs to the positional**: describe accepted target forms in the positional `brief` (use `ORG_ALL_TARGET_BRIEF` or `PROJECT_TARGET_BRIEF` from `src/lib/list-command.ts` for `[<org>/][<project>]` targets), not in a `Target patterns:` block. The brief renders in `--help`, the generated docs, and the skill references.
- Use fragments in `apps/cli-docs/src/fragments/commands/` only for prose the command metadata can't carry: guides, permissions, caveats.
Expand Down
158 changes: 158 additions & 0 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <route>`
* 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 `<placeholder>`, or an
* interpolation.
*/
const SENTRY_WORD_RE =
/(?:^|[\s`'"(])sentry (\uFFFC|<|--?[a-z]|[a-z][a-z0-9-]*)/g;

/** Whether `text` names a `sentry <command>`. */
function namesSentryCommand(text) {
for (const [, word] of text.matchAll(SENTRY_WORD_RE)) {
if (
word === INTERPOLATION ||
word === "<" ||
word.startsWith("-") ||
ROUTE_NAMES.has(word)
)
return true;
}
return false;
}

/** A whole `sentry <command>`, such as a usage line or a command prefix. */
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.",
Expand Down Expand Up @@ -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) =>
Expand Down
57 changes: 56 additions & 1 deletion packages/cli/script/check-doc-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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 `<arg>` placeholder.
*/
function sentryTagCommands(file: string, code: string): string[] {
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest);
const commands: string[] = [];
const visit = (node: ts.Node): void => {
if (
ts.isTaggedTemplateExpression(node) &&
ts.isIdentifier(node.tag) &&
node.tag.text === "sentry"
) {
const { template } = node;
const text = ts.isNoSubstitutionTemplateLiteral(template)
? template.text
: template.head.text +
template.templateSpans
.map((span) => `<arg>${span.literal.text}`)
.join("");
commands.push(`sentry ${text}`);
}
ts.forEachChild(node, visit);
};
visit(source);
return commands;
}

function listSource(dir: string): string[] {
return readdirSync(dir, { recursive: true, encoding: "utf-8" })
.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
.map((f) => join(dir, f));
}

function listMarkdown(dir: string): string[] {
return readdirSync(dir, { recursive: true, encoding: "utf-8" })
.filter((f) => f.endsWith(".md") || f.endsWith(".mdx"))
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]);
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/agent-conversation/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`,
}),
};
},
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/agent-conversation/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
);
}
Expand Down
11 changes: 6 additions & 5 deletions packages/cli/src/commands/alert/issues/rule-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
);
}
Expand All @@ -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-id-or-name>`,
"rule",
);
Expand All @@ -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",
);
}
Expand Down Expand Up @@ -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 <command> <org>/<project>/<rule-id>",
`Use an explicit target: ${sentry`alert issues <command> <org>/<project>/<rule-id>`}`,
);
}
throw new ResolutionError(
Expand Down Expand Up @@ -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 <command> <org>/<project>/<rule-id-or-name>",
`Use an explicit target: ${sentry`alert issues <command> <org>/<project>/<rule-id-or-name>`}`,
);
}

Expand Down
Loading
Loading