diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 7bf3ff175..4e98fd9c6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,6 +12,7 @@ "packages/cli/plugins/**", "packages/cli/README.md", "packages/cli/DEVELOPMENT.md", + "packages/cli/CONTRIBUTING.md", "packages/cli/CHANGELOG.md", "packages/cli/AGENTS.md", "packages/mcp-server-mocks/src/fixtures/**", diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 7dc9d82e9..2c7b2c504 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -34,7 +34,9 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ## Safety Rules -- Always confirm with the user before running destructive commands: `project delete`, `trial start` + +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` + - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -210,7 +212,7 @@ sentry release deploy my-org/1.0.0 production sentry agent-conversation list # Explicit org, last 24 hours -sentry agent-conversation list my-org --period 24h +sentry agent-conversation list my-org/ --period 24h # View a conversation transcript sentry agent-conversation view my-org/conv-123 diff --git a/apps/cli-docs/src/content/docs/agentic-usage.md b/apps/cli-docs/src/content/docs/agentic-usage.md index 2f6e6ff27..b688e81ad 100644 --- a/apps/cli-docs/src/content/docs/agentic-usage.md +++ b/apps/cli-docs/src/content/docs/agentic-usage.md @@ -3,7 +3,11 @@ title: Agentic Usage description: Enable AI coding agents to use the Sentry CLI --- -AI coding agents can use the Sentry CLI through the skill system. The CLI detects and supports Claude Code (including Cowork), Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, and any agent that reads skills from `~/.agents`. This allows agents to interact with Sentry directly from your development environment. +AI coding agents can use the Sentry CLI through the skill system, which lets them interact with Sentry directly from your development environment. Any agent that reads skills from `~/.agents` can use the skill. + + +The CLI detects Claude Code, Claude Cowork, Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Antigravity, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, and OpenClaw. + ## Automatic Installation @@ -66,7 +70,7 @@ When you ask your agent about Sentry errors or want to investigate an issue, the - "Show me the latest issues in my project" → `sentry issue list` - "What's the stack trace for ISSUE-123?" → `sentry issue view ISSUE-123` -- "List all projects in my organization" → `sentry project list my-org` +- "List all projects in my organization" → `sentry project list my-org/` - "What API endpoints exist for releases?" → `sentry schema releases` - "How do I set up source maps for Next.js?" → `sentry docs "source maps Next.js"` - "What is Sentry's status right now?" → `sentry status` @@ -79,4 +83,4 @@ The skill uses your existing CLI authentication, so you'll need to run `sentry a ## Requirements - An authenticated Sentry CLI installation (`sentry auth login`) -- An AI coding agent that supports the skills system (e.g., Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, or any agent that reads from `~/.agents`) +- An AI coding agent that supports the skills system, such as one of the agents listed at the top of this page or any agent that reads from `~/.agents` diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..473d573e7 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -27,7 +27,7 @@ pnpm install cd packages/cli # Run CLI in development mode -pnpm run cli -- --help +pnpm run cli --help # Run tests pnpm run test diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index ee2b01822..02efb7ae4 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -61,6 +61,9 @@ Set `SENTRY_INIT=1` to run [`sentry init`](../commands/init/) right after instal curl https://cli.sentry.dev/install -fsS | SENTRY_INIT=1 bash ``` +The installer reports its own errors to Sentry. Set `SENTRY_CLI_NO_TELEMETRY=1` +to turn this off; the CLI honors the same variable. + ### Windows (PowerShell) On Windows, install the latest stable release from PowerShell: diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md index d1ab7faf7..9748a71f0 100644 --- a/apps/cli-docs/src/fragments/commands/index.md +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -9,6 +9,20 @@ All commands support the following global options: - `--log-level ` - Set log verbosity (`error`, `warn`, `log`, `info`, `debug`, `trace`). Overrides `SENTRY_LOG_LEVEL` - `--verbose` - Shorthand for `--log-level debug` +## Targeting Organizations and Projects + +Many commands take an optional `/` target as their first positional argument, or as a prefix of an ID (e.g. `my-org/my-project/`). + + +| Target | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `/` | That project in that organization | +| `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | The whole organization | +| `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | + + ## JSON Output Most list and view commands support `--json` flag for JSON output, making it easy to integrate with other tools: diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index d8c93fa03..383f2f80d 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -13,7 +13,7 @@ In organization, team, and monorepo app selectors, type to filter the loaded cho | _(omitted)_ | Auto-detect org and project | | `acme/` | Use org `acme`, auto-detect or create project | | `acme/my-app` | Use org `acme` and project `my-app` | -| `my-app` | Search for project `my-app` across all accessible orgs | +| `my-app` | Use the existing project `my-app` from any accessible org. If there is none, use org `my-app` when one exists; otherwise create a new project named `my-app` | Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the directory. The order of target and directory can be swapped — the CLI will auto-correct with a warning. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index e5e673631..d6cd24a0b 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -39,7 +39,7 @@ Before working on this codebase, read the Cursor rules: # Development pnpm install # Install dependencies (from repo root) pnpm run dev # Run CLI in dev mode -pnpm run cli -- # Run the CLI with arguments +pnpm run cli # Run the CLI with arguments # Build pnpm run build # Build for current platform @@ -253,8 +253,9 @@ one of these types and never reads `.resolved`. Resolvers need no command name: they read it from `this.commandPrefix`, which Stricli's `forCommand` and the SDK invoker set (test mock contexts set it -too). In a `buildDeleteCommand` command, the target refuses to auto-detect, so -delete commands don't call `requireExplicitTarget` for it. +too). In a destructive command (`buildDeleteCommand`, or `buildCommand` with +`destructive: true`), the target refuses to auto-detect, so those commands +don't call `requireExplicitTarget` for it. Both steps are lazy (`lazyPositional` in `src/lib/positional-parse.ts`): the parse runs on the first `parsed` read and the resolver on the first diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 4fb3df99a..524dca951 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -16,13 +16,17 @@ sentry project list [org] [--limit N] [--json] sentry issue list [/] [--json] ``` -**Target syntax**: - -- `/` - Explicit organization and project (e.g., `my-org/frontend`) -- `/,` - Several projects in the same org (e.g., `my-org/web,api`) -- `/` - All projects in the specified organization -- `` - Search for project by name across all accessible organizations -- _(omit)_ - Auto-detect from DSN or config +**Target syntax** (generated from `src/lib/target-syntax.ts`): + + +| Target | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `/` | That project in that organization | +| `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | The whole organization | +| `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | + **Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 0287298ce..54102fc0e 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -31,7 +31,7 @@ Dev mode (`pnpm run cli`) falls back to the CLI's public sentry.io client ID whe `pnpm run cli` loads `.env.local` automatically when it exists: ```bash -pnpm run cli -- auth login +pnpm run cli auth login ``` ## Testing the Device Flow @@ -39,7 +39,7 @@ pnpm run cli -- auth login 1. Run the CLI login command: ```bash -pnpm run cli -- auth login +pnpm run cli auth login ``` 2. You'll see output like: diff --git a/packages/cli/README.md b/packages/cli/README.md index 030e115b2..f7c97e2a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -45,6 +45,8 @@ bun add -g sentry > The npm/pnpm/yarn packages require Node.js 20+. On Node.js 22.15+ the CLI uses the built-in `node:sqlite`; on Node.js 20–22.14 it transparently falls back to a bundled WASM SQLite driver. +Homebrew and package manager installs don't add agent skills (package managers also skip shell completions). Run `sentry cli setup` once afterwards; see the [installation guide](https://cli.sentry.dev/getting-started/). + ### Run Without Installing ```bash @@ -149,11 +151,11 @@ cd packages/cli ### Running Locally ```bash -# Run CLI in development mode -pnpm run cli -- --help +# Run CLI in development mode (generates missing build-time files on first run) +pnpm run cli --help -# With environment variables (create .env.local first, see DEVELOPMENT.md) -pnpm run cli -- --help +# Loads packages/cli/.env.local automatically when present (see DEVELOPMENT.md) +pnpm run cli auth status ``` ### Scripts diff --git a/packages/cli/install b/packages/cli/install index 4a140d673..186231448 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -90,6 +90,7 @@ Environment Variables: SENTRY_INSTALL_DIR Override the installation directory SENTRY_VERSION Install a specific version (e.g., 0.42.2, nightly) SENTRY_INIT Set to 1 to run \`sentry init\` after installing + SENTRY_CLI_NO_TELEMETRY Set to 1 to disable installer error reporting Examples: curl -fsSL https://cli.sentry.dev/install | bash diff --git a/packages/cli/package.json b/packages/cli/package.json index 48e245c05..a63b8a3b3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,8 +56,8 @@ }, "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", - "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", - "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", + "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", + "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", @@ -72,7 +72,7 @@ "generate:sdk": "pnpm tsx script/generate-sdk.ts", "generate:skill": "pnpm tsx script/generate-skill.ts", "generate:banner": "pnpm tsx script/generate-banner-sixel.ts", - "generate:docs": "pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:skill && pnpm run generate:docs-sections", + "generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill", "generate:completions": "pnpm tsx script/generate-completion-targets.ts", "generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts", "generate:schema": "pnpm tsx script/generate-api-schema.ts", @@ -89,7 +89,10 @@ "check:docs-sections": "pnpm tsx script/generate-docs-sections.ts --check", "check:env-coverage": "pnpm tsx script/check-env-coverage.ts", "check:stale-refs": "pnpm tsx script/check-stale-references.ts", - "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs" + "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", + "check:installer-docs": "pnpm tsx script/check-installer-docs.ts", + "check:destructive": "pnpm tsx script/check-destructive-commands.ts", + "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/plugins/README.md b/packages/cli/plugins/README.md index f7848b78f..4acc7e69b 100644 --- a/packages/cli/plugins/README.md +++ b/packages/cli/plugins/README.md @@ -22,7 +22,8 @@ npx @sentry/dotagents add https://cli.sentry.dev sentry-cli ### Cursor -Skills are automatically available in `.cursor/skills/` for Cursor users. +`packages/cli/.cursor/skills/sentry-cli/` symlinks to the skill files, so +Cursor picks them up when you open this repository. ### Other Agents @@ -48,25 +49,21 @@ The skill will guide the assistant to provide accurate CLI commands. ## Repository Structure + ``` -cli/ # Repository root -├── .claude-plugin/ -│ └── marketplace.json # Marketplace manifest -├── .cursor/ -│ └── skills/ -│ └── sentry-cli/ -│ └── SKILL.md # Symlink to plugins location -├── plugins/ -│ ├── README.md # This file -│ └── sentry-cli/ -│ ├── .claude-plugin/ -│ │ └── plugin.json # Plugin manifest -│ └── skills/ -│ └── sentry-cli/ -│ └── SKILL.md # CLI usage skill (auto-generated) -└── script/ - └── generate-skill.ts # Generates SKILL.md from CLI commands +packages/cli/.claude-plugin/marketplace.json # Marketplace manifest +packages/cli/.cursor/skills/sentry-cli/SKILL.md # Symlink to the plugin skill +packages/cli/.cursor/skills/sentry-cli/references # Symlink to the plugin references +packages/cli/plugins/README.md # This file +packages/cli/plugins/sentry-cli/.claude-plugin/plugin.json # Plugin manifest +packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md # CLI usage skill (generated) +packages/cli/plugins/sentry-cli/skills/sentry-cli/references # One file per command group (generated) +packages/cli/script/generate-skill.ts # Generates SKILL.md and references/ ``` + + +The docs site also serves these files, plus a generated `index.json` discovery +manifest, from `apps/cli-docs/public/.well-known/skills/`. ## Updating SKILL.md diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 478eb2d67..f5536126b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -46,7 +46,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ### Safety Rules -- Always confirm with the user before running destructive commands: `project delete`, `trial start` +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -222,7 +222,7 @@ sentry release deploy my-org/1.0.0 production sentry agent-conversation list # Explicit org, last 24 hours -sentry agent-conversation list my-org --period 24h +sentry agent-conversation list my-org/ --period 24h # View a conversation transcript sentry agent-conversation view my-org/conv-123 diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index e2d4e6531..5e023155a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -186,7 +186,7 @@ sentry cli setup --no-agent-skills Uninstall Sentry CLI **Flags:** -- `--keep-config - Keep the config directory (~/.sentry) and auth tokens` +- `--keep-config - Keep the config directory (default ~/.config/sentry) and auth tokens` - `-y, --yes - Skip confirmation prompt` - `-f, --force - Force the operation without confirmation` - `-n, --dry-run - Show what would happen without making changes` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 4d05a546a..28300dc63 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -230,13 +230,13 @@ sentry dashboard revisions 'Frontend Performance' sentry dashboard revisions 12345 # Pass the organization explicitly -sentry dashboard revisions my-org 12345 +sentry dashboard revisions my-org/ 12345 # Fetch the next page -sentry dashboard revisions my-org 12345 -c next +sentry dashboard revisions my-org/ 12345 -c next # Output as JSON -sentry dashboard revisions my-org 12345 --json +sentry dashboard revisions my-org/ 12345 --json ``` ### `sentry dashboard restore ` @@ -256,7 +256,7 @@ sentry dashboard restore 'Frontend Performance' --revision 3 sentry dashboard restore 12345 --revision 42 # Pass the organization explicitly -sentry dashboard restore my-org 12345 --revision 42 +sentry dashboard restore my-org/ 12345 --revision 42 # Output the restored dashboard as JSON sentry dashboard restore 12345 --revision 42 --json diff --git a/packages/cli/script/check-destructive-commands.ts b/packages/cli/script/check-destructive-commands.ts new file mode 100644 index 000000000..12d61a5a4 --- /dev/null +++ b/packages/cli/script/check-destructive-commands.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env tsx +/** + * Commands that send an HTTP DELETE must be marked destructive. + * + * The agent Safety Rules list destructive commands from command metadata + * (`buildDeleteCommand`, or `buildCommand({ destructive: true })`). This check + * derives the commands that can delete something from the code instead of + * trusting that metadata: + * + * 1. DELETE operations come from the `@sentry/api` SDK itself (operations + * built on `client.delete(...)`), plus raw requests with + * `method: "DELETE"`. + * 2. A function in `src/` that calls one, directly or through other + * functions, imports, and barrel re-exports, deletes too. + * 3. A command module that uses a deleting function must be built with + * `buildDeleteCommand` or pass `destructive: true`. + * + * Only DELETE is inferred. POST and PUT also create, update, and restore, so + * irreversible non-DELETE operations (`issue merge`, `trial start`) keep an + * explicit `destructive: true`. + * + * Usage: + * tsx script/check-destructive-commands.ts + * + * Exit codes: + * 0 - Every command that can send a DELETE is marked destructive + * 1 - A command sends a DELETE but is not marked destructive + */ + +import { readFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { glob } from "tinyglobby"; +import ts from "typescript"; + +/** `var deleteProject = (options) => (options.client ?? client).delete({` */ +const SDK_DELETE_OP_RE = + /\bvar (\w+) = \(options\) => \(options\.client \?\? client\)\.delete\(/g; + +const SDK_MODULE = "@sentry/api"; + +/** Where a local name comes from: a module path (or the SDK) and its export. */ +type Binding = { module: string; name: string }; + +type Module = { + /** Import bindings: local name → source. */ + imports: Map; + /** `export { a as b } from "./x.js"`: exported name → source. */ + reExports: Map; + /** `export * from "./x.js"` targets. */ + starExports: string[]; + /** Top-level functions: name → identifiers they reference. */ + functions: Map>; + /** Top-level functions that issue a raw `method: "DELETE"` request. */ + rawDelete: Set; + /** Every identifier in the file. */ + identifiers: Set; + /** Built with `buildDeleteCommand` or `destructive: true`. */ + markedDestructive: boolean; +}; + +async function loadSdkDeleteOps(): Promise> { + const entry = fileURLToPath(import.meta.resolve(SDK_MODULE)); + const source = await readFile(entry, "utf-8"); + const ops = new Set([...source.matchAll(SDK_DELETE_OP_RE)].map((m) => m[1])); + if (ops.size === 0) { + throw new Error( + `No DELETE operations found in ${entry}; the SDK build format changed. Update SDK_DELETE_OP_RE.`, + ); + } + return ops as Set; +} + +function resolveSpecifier(file: string, specifier: string): string | null { + if (specifier === SDK_MODULE) { + return SDK_MODULE; + } + if (!specifier.startsWith(".")) { + return null; + } + return relative( + process.cwd(), + resolve(dirname(file), specifier.replace(/\.js$/, ".ts")), + ); +} + +function collectIdentifiers(node: ts.Node, into: Set): void { + if (ts.isIdentifier(node)) { + into.add(node.text); + } + node.forEachChild((child) => collectIdentifiers(child, into)); +} + +function hasRawDelete(node: ts.Node): boolean { + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "method" && + ts.isStringLiteral(node.initializer) && + node.initializer.text === "DELETE" + ) { + return true; + } + return node.forEachChild(hasRawDelete) ?? false; +} + +function isMarkedDestructive(node: ts.Node): boolean { + if ( + ts.isCallExpression(node) && + node.expression.getText() === "buildDeleteCommand" + ) { + return true; + } + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "destructive" && + node.initializer.kind === ts.SyntaxKind.TrueKeyword + ) { + return true; + } + return node.forEachChild(isMarkedDestructive) ?? false; +} + +/** Name and body of a top-level function declaration or `const f = () => …`. */ +function topLevelFunctions(statement: ts.Statement): [string, ts.Node][] { + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { + return [[statement.name.text, statement.body]]; + } + if (!ts.isVariableStatement(statement)) { + return []; + } + return statement.declarationList.declarations.flatMap( + (d): [string, ts.Node][] => + ts.isIdentifier(d.name) && + d.initializer && + (ts.isArrowFunction(d.initializer) || + ts.isFunctionExpression(d.initializer)) + ? [[d.name.text, d.initializer]] + : [], + ); +} + +function parseModule(file: string, source: string): Module { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const mod: Module = { + imports: new Map(), + reExports: new Map(), + starExports: [], + functions: new Map(), + rawDelete: new Set(), + identifiers: new Set(), + markedDestructive: isMarkedDestructive(sf), + }; + collectIdentifiers(sf, mod.identifiers); + + for (const statement of sf.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const module = resolveSpecifier(file, statement.moduleSpecifier.text); + const bindings = statement.importClause?.namedBindings; + if (module && bindings && ts.isNamedImports(bindings)) { + for (const el of bindings.elements) { + const name = (el.propertyName ?? el.name).text; + mod.imports.set(el.name.text, { module, name }); + } + } + continue; + } + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const module = resolveSpecifier(file, statement.moduleSpecifier.text); + if (!module) { + continue; + } + const clause = statement.exportClause; + if (!clause) { + mod.starExports.push(module); + } else if (ts.isNamedExports(clause)) { + for (const el of clause.elements) { + const name = (el.propertyName ?? el.name).text; + mod.reExports.set(el.name.text, { module, name }); + } + } + continue; + } + for (const [name, body] of topLevelFunctions(statement)) { + const refs = new Set(); + collectIdentifiers(body, refs); + mod.functions.set(name, refs); + if (hasRawDelete(body)) { + mod.rawDelete.add(name); + } + } + } + return mod; +} + +const sdkDeleteOps = await loadSdkDeleteOps(); +const files = await glob("src/**/*.ts", { + ignore: ["src/generated/**", "**/*.test.ts"], +}); +const modules = new Map(); +for (const file of files) { + modules.set(file, parseModule(file, await readFile(file, "utf-8"))); +} + +/** `file#name` → a human-readable chain ending at the DELETE. */ +const deleting = new Map(); + +/** Whether `name` exported from `module` deletes; returns its chain. */ +function exportDeletes( + module: string, + name: string, + seen = new Set(), +): string | undefined { + if (module === SDK_MODULE) { + return sdkDeleteOps.has(name) ? `${SDK_MODULE} ${name}` : undefined; + } + const key = `${module}#${name}`; + if (deleting.has(key)) { + return deleting.get(key); + } + const mod = modules.get(module); + if (!mod || seen.has(key)) { + return; + } + seen.add(key); + const reExport = mod.reExports.get(name); + if (reExport) { + return exportDeletes(reExport.module, reExport.name, seen); + } + for (const target of mod.starExports) { + const chain = exportDeletes(target, name, seen); + if (chain) { + return chain; + } + } + return; +} + +/** Whether identifier `ref` used in `file` refers to something that deletes. */ +function refDeletes( + file: string, + mod: Module, + ref: string, +): string | undefined { + const local = deleting.get(`${file}#${ref}`); + if (local) { + return local; + } + const binding = mod.imports.get(ref); + return binding ? exportDeletes(binding.module, binding.name) : undefined; +} + +// Fixed point: propagate "deletes" through calls until nothing changes. +let changed = true; +while (changed) { + changed = false; + for (const [file, mod] of modules) { + for (const [name, refs] of mod.functions) { + const key = `${file}#${name}`; + if (deleting.has(key)) { + continue; + } + if (mod.rawDelete.has(name)) { + deleting.set(key, `${key} (method: "DELETE")`); + changed = true; + continue; + } + for (const ref of refs) { + const chain = ref === name ? undefined : refDeletes(file, mod, ref); + if (chain) { + deleting.set(key, `${key} → ${chain}`); + changed = true; + break; + } + } + } + } +} + +const problems: string[] = []; +let deletingCommands = 0; +for (const [file, mod] of modules) { + if (!file.startsWith("src/commands/")) { + continue; + } + let chain: string | undefined; + for (const ref of mod.identifiers) { + chain = refDeletes(file, mod, ref); + if (chain) { + break; + } + } + if (!chain) { + continue; + } + deletingCommands += 1; + if (!mod.markedDestructive) { + problems.push(`${file} sends a DELETE via ${chain}`); + } +} + +if (problems.length === 0) { + console.log( + `✓ All ${deletingCommands} command module(s) that send a DELETE are marked destructive`, + ); + process.exit(0); +} + +console.error( + `✗ ${problems.length} command(s) send a DELETE but are not marked destructive:\n`, +); +for (const problem of problems) { + console.error(` ${problem}`); +} +console.error( + "\n Build them with buildDeleteCommand, or pass `destructive: true` to buildCommand.", +); +process.exit(1); diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts new file mode 100644 index 000000000..e4258f965 --- /dev/null +++ b/packages/cli/script/check-doc-examples.ts @@ -0,0 +1,351 @@ +#!/usr/bin/env tsx +/** + * Check `sentry …` examples in hand-written docs against the live route tree. + * + * Every `sentry` invocation in a fenced shell block or inline code span, and + * every structured command example (`docs.examples`, which feeds `--help` and + * the generated command pages), must: + * 1. Name a command that exists (e.g. `sentry upgrade` → `sentry cli upgrade`) + * 2. Use only flags (and single-letter aliases) that command defines + * 3. Not pass a bare org-looking slug (`my-org`) as a target. A bare name + * matches a project first, so examples that mean the org must use the + * trailing-slash form (`my-org/`) + * + * Placeholders (`/`, `[args]`, `...`) are skipped. Generated + * command pages are not scanned: their examples are checked at the source. + * + * Usage: + * tsx script/check-doc-examples.ts + * + * Exit codes: + * 0 - All examples are valid + * 1 - At least one example names a missing command, flag, or ambiguous target + */ + +import { mkdirSync, readdirSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { + Command, + PositionalParam, + RouteMap, + RouteTarget, +} from "../src/lib/introspect.js"; +import { blankCodeBlocks, markdownCodeBlocks } from "./markdown.js"; +import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; + +// Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) +const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; +const skillContentExists = await access(SKILL_CONTENT_PATH).then( + () => true, + () => false, +); +if (!skillContentExists) { + mkdirSync("src/generated", { recursive: true }); + await writeFile( + SKILL_CONTENT_PATH, + "export const SKILL_FILES: [string, string][] = [];\n", + ); +} + +const { routes } = await import("../src/app.js"); +const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); +const { getCompletionTarget, ORG_PROJECT_TARGET } = + await import("../src/lib/completion-target.js"); + +/** Generated from the route tree, so not worth re-checking. */ +const GENERATED_DOCS_DIR = `${DOCS_CONTENT}/commands`; + +/** Hand-written Markdown outside the docs site that shows CLI usage. */ +const EXTRA_DOC_FILES = [ + "README.md", + "CONTRIBUTING.md", + "DEVELOPMENT.md", + "plugins/README.md", +]; + +/** Flags Stricli handles before command dispatch. */ +const BUILTIN_FLAGS = new Set(["help", "help-all", "version"]); +const BUILTIN_ALIASES = new Set(["h", "H"]); + +/** + * Slugs that docs use for an organization. As a bare target they resolve to + * a project first, so an example meaning "the org" needs `my-org/`. + */ +const ORG_PLACEHOLDER_RE = /^(?:my-org|acme)$/; + +/** Placeholders of positionals parsed with `parseOrgProjectArg()`. */ +const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; + +const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); +const INLINE_CODE_RE = /(?[\]{}]|\.\.\.|…|^\$/; +const SHORT_ALIAS_RE = /^-([a-zA-Z])$/; +const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/; + +type Finding = { file: string; example: string; problem: string }; + +/** Stricli's alias-aware lookup, not part of the simplified RouteMap type. */ +type RoutingMap = RouteMap & { + getRoutingTargetForInput(input: string): RouteTarget | undefined; +}; + +/** Split a shell command into words, honoring single and double quotes. */ +function tokenize(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: string | null = null; + let hasToken = false; + for (const ch of command) { + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + hasToken = true; + continue; + } + if (ch === "#" && !hasToken) { + break; // comment + } + if (/\s/.test(ch)) { + if (hasToken) { + tokens.push(current); + current = ""; + hasToken = false; + } + continue; + } + current += ch; + hasToken = true; + } + if (hasToken) { + tokens.push(current); + } + return tokens; +} + +/** Collect `sentry …` invocations from one Markdown file. */ +function extractInvocations(markdown: string): string[] { + const invocations: string[] = []; + for (const { code, lang } of markdownCodeBlocks(markdown)) { + if (!SHELL_LANGS.has(lang)) { + continue; + } + for (const rawLine of code.replace(CONTINUATION_RE, " ").split("\n")) { + const line = rawLine.trim().replace(PROMPT_RE, ""); + if (line.startsWith("#")) { + continue; + } + for (const segment of line.split(COMMAND_SPLIT_RE)) { + if (segment.startsWith("sentry ")) { + invocations.push(segment); + } + } + } + } + const prose = blankCodeBlocks(markdown); + for (const [, code = ""] of prose.matchAll(INLINE_CODE_RE)) { + invocations.push(code); + } + return invocations; +} + +/** Whether `--name` (or the boolean negation `--no-name`) is accepted. */ +function hasFlag(cmd: Command, name: string): boolean { + const flags = cmd.parameters.flags ?? {}; + const negated = name.startsWith("no-") ? name.slice(3) : undefined; + return ( + BUILTIN_FLAGS.has(name) || + Object.hasOwn(flags, name) || + (negated !== undefined && Object.hasOwn(flags, negated)) + ); +} + +/** Walk the route tree; returns the command and the remaining tokens. */ +function resolve( + tokens: string[], +): { cmd: Command; rest: string[] } | { error: string } | null { + let target: RouteTarget = routes as unknown as RouteMap; + let i = 0; + while (isRouteMap(target)) { + const word = tokens[i]; + if (word === undefined || word.startsWith("-")) { + const fallback = target.getDefaultCommand?.(); + if (fallback && isCommand(fallback)) { + return { cmd: fallback, rest: tokens.slice(i) }; + } + // `sentry issue` alone names a group; `--help` on a group is fine. + return word === undefined || word === "--help" || word === "-h" + ? null + : { error: `\`${tokens.slice(0, i).join(" ")}\` is a group` }; + } + if (PLACEHOLDER_RE.test(word)) { + return null; + } + // Resolves aliases too (`issue ignore` → `issue archive`, `new` → `create`) + const next = (target as RoutingMap).getRoutingTargetForInput(word); + if (!next) { + const fallback = target.getDefaultCommand?.(); + if (i > 0 && fallback && isCommand(fallback)) { + return { cmd: fallback, rest: tokens.slice(i) }; + } + const path = ["sentry", ...tokens.slice(0, i + 1)].join(" "); + return { error: `unknown command \`${path}\`` }; + } + target = next; + i += 1; + } + return isCommand(target) ? { cmd: target, rest: tokens.slice(i) } : null; +} + +/** + * Whether a positional takes an `/` target, where a bare name + * is a project search. Raw org slugs (placeholder `org`) are not targets. + */ +function isTargetParam(param: PositionalParam | undefined): boolean { + if (!param) { + return false; + } + return ( + getCompletionTarget(param.parse) === ORG_PROJECT_TARGET || + TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") + ); +} + +/** Problems with one `sentry …` invocation. */ +function checkInvocation(invocation: string): string[] { + const words = tokenize(invocation); + // Skip a `sudo` or `NAME=value` prefix, e.g. `SENTRY_DSN=… sentry monitor run` + const start = words.findIndex( + (word) => word !== "sudo" && !ENV_ASSIGNMENT_RE.test(word), + ); + if (words[start] !== "sentry") { + return []; + } + const tokens = words.slice(start + 1); + const resolved = resolve(tokens); + if (!resolved) { + return []; + } + if ("error" in resolved) { + return [resolved.error]; + } + const { cmd, rest } = resolved; + const problems: string[] = []; + const aliases = cmd.parameters.aliases ?? {}; + const positionals: string[] = []; + for (const token of rest) { + if (token === "--") { + break; + } + if (token.startsWith("--")) { + const name = token.slice(2).split("=")[0] ?? ""; + if (!(PLACEHOLDER_RE.test(name) || hasFlag(cmd, name))) { + problems.push(`unknown flag \`--${name}\``); + } + continue; + } + const alias = token.match(SHORT_ALIAS_RE)?.[1]; + if (alias) { + if (!(BUILTIN_ALIASES.has(alias) || Object.hasOwn(aliases, alias))) { + problems.push(`unknown flag \`-${alias}\``); + } + continue; + } + positionals.push(token); + } + + const firstParam = + cmd.parameters.positional?.kind === "tuple" + ? cmd.parameters.positional.parameters[0] + : cmd.parameters.positional?.parameter; + const first = positionals[0]; + if (first && ORG_PLACEHOLDER_RE.test(first) && isTargetParam(firstParam)) { + problems.push( + `bare \`${first}\` resolves to a project first; use \`${first}/\` for the organization`, + ); + } + return problems; +} + +function listMarkdown(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf-8" }) + .filter((f) => f.endsWith(".md") || f.endsWith(".mdx")) + .map((f) => join(dir, f)) + .filter((f) => !f.startsWith(GENERATED_DOCS_DIR)); +} + +const files = [ + ...listMarkdown(DOCS_CONTENT), + ...listMarkdown(DOCS_FRAGMENTS), + ...EXTRA_DOC_FILES, +]; + +/** + * Structured command examples (`docs.examples`). These feed `--help` and the + * generated command pages. Examples hand-written into `fullDescription` are + * not checked; they belong in `docs.examples`. + */ +function commandExamples( + target: RouteTarget, + path: string[], +): { source: string; invocation: string }[] { + if (isRouteMap(target)) { + return target + .getAllEntries() + .filter((e) => !e.hidden) // hidden entries are plural aliases + .flatMap((e) => commandExamples(e.target, [...path, e.name.original])); + } + if (!isCommand(target)) { + return []; + } + const source = `\`${["sentry", ...path].join(" ")}\` help`; + return (target.__examples ?? []).map((e) => ({ + source, + invocation: e.command, + })); +} + +const findings: Finding[] = []; +let checked = 0; +for (const { source, invocation } of commandExamples( + routes as unknown as RouteMap, + [], +)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file: source, example: invocation, problem }); + } +} +for (const file of files) { + const content = await readFile(file, "utf-8"); + for (const invocation of extractInvocations(content)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file, example: invocation, problem }); + } + } +} + +if (findings.length === 0) { + console.log( + `✓ ${checked} sentry examples in ${files.length} files match the CLI`, + ); + process.exit(0); +} + +console.error(`✗ ${findings.length} doc example problem(s):\n`); +for (const f of findings) { + console.error(` ${f.file}: ${f.example}`); + console.error(` → ${f.problem}\n`); +} +process.exit(1); diff --git a/packages/cli/script/check-env-coverage.ts b/packages/cli/script/check-env-coverage.ts index 01234b6e8..dfef7e506 100644 --- a/packages/cli/script/check-env-coverage.ts +++ b/packages/cli/script/check-env-coverage.ts @@ -2,10 +2,10 @@ /** * Environment variable documentation coverage. * - * Scans the source tree for environment variables the CLI reads at runtime and - * fails if any user-facing variable is missing from `ENV_VAR_REGISTRY` - * (src/lib/env-registry.ts), which is the single source of truth for the - * generated `configuration.md` docs. + * Scans the source tree and the install scripts (`install`, `install.ps1`) for + * environment variables they read and fails if any user-facing variable is + * missing from `ENV_VAR_REGISTRY` (src/lib/env-registry.ts), which is the + * single source of truth for the generated `configuration.md` docs. * * This closes the class of "code reads an env var but the docs never mention it" * bugs: a new `getEnv().SENTRY_FOO` read forces a deliberate choice — either @@ -20,7 +20,8 @@ * tsx script/check-env-coverage.ts * * Exit codes: - * 0 - Every referenced user-facing env var is documented or explicitly internal + * 0 - Every user-facing env var read by the CLI or an installer is documented + * or explicitly internal * 1 - Coverage gap, redundant allowlist entry, or stale allowlist entry */ @@ -141,8 +142,51 @@ for (const [file, content] of fileContents) { } } +// --------------------------------------------------------------------------- +// Install scripts: they run before the CLI exists, so src/ never reads their +// variables. Script-local assignments (`SENTRY_DSN_KEY="..."`) are constants, +// not inputs. +// --------------------------------------------------------------------------- + +const INSTALLERS: readonly { file: string; read: RegExp; assign?: RegExp }[] = [ + { + file: "install", + read: /\$\{?(SENTRY_[A-Z0-9_]+)/g, + assign: /^\s*(?:local\s+)?(SENTRY_[A-Z0-9_]+)=/gm, + }, + { file: "install.ps1", read: /\$env:(SENTRY_[A-Z0-9_]+)/g }, +]; + +/** name -> first "file:line" where an installer reads it. */ +const installerReads = new Map(); + +for (const { file, read, assign } of INSTALLERS) { + const content = await readFile(file, "utf-8"); + const assigned = new Set( + assign ? [...content.matchAll(assign)].map((m) => m[1]) : [], + ); + for (const match of content.matchAll(read)) { + const name = match[1]; + if (!name || assigned.has(name) || installerReads.has(name)) { + continue; + } + const line = content.slice(0, match.index).split("\n").length; + installerReads.set(name, `${file}:${line}`); + } +} + const errors: string[] = []; +for (const [name, location] of installerReads) { + if (!registryNames.has(name)) { + errors.push( + `Undocumented installer env var: ${name} (read at ${location})\n` + + " Add it to ENV_VAR_REGISTRY in src/lib/env-registry.ts (with `installOnly: true`\n" + + " if the CLI itself never reads it) so it appears in configuration.md.", + ); + } +} + // A user-facing var read in code must be documented or explicitly internal. for (const [name, location] of referenced) { if (registryNames.has(name) || INTERNAL_ENV_VARS.has(name)) { @@ -183,6 +227,7 @@ if (errors.length > 0) { } console.log( - `✓ All ${referenced.size} user-facing env var(s) read in src/ are documented ` + + `✓ All ${referenced.size} user-facing env var(s) read in src/ and ` + + `${installerReads.size} read by the installers are documented ` + `(${registryNames.size} in registry, ${INTERNAL_ENV_VARS.size} internal).`, ); diff --git a/packages/cli/script/check-installer-docs.ts b/packages/cli/script/check-installer-docs.ts new file mode 100644 index 000000000..2e59897cd --- /dev/null +++ b/packages/cli/script/check-installer-docs.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env tsx +/** + * Installer documentation coverage. + * + * The install scripts (`install` for bash, `install.ps1` for PowerShell) have + * their own help text and flags. This check keeps those in sync: + * + * 1. Every `SENTRY_*` variable an installer reads, and every flag the bash + * installer parses, appears in that installer's own help text. + * 2. Every flag the bash installer parses appears in the installation guide + * (getting-started.mdx). + * + * Installer environment variables are documented in configuration.md, which + * is generated from ENV_VAR_REGISTRY; check-env-coverage.ts enforces that. + * + * Usage: + * tsx script/check-installer-docs.ts + * + * Exit codes: + * 0 - Installer options are documented + * 1 - An option is missing from installer help or the installation guide + */ + +import { readFile } from "node:fs/promises"; +import { DOCS_CONTENT } from "./paths.js"; + +const GUIDE = `${DOCS_CONTENT}/getting-started.mdx`; + +/** `${SENTRY_FOO:-}` / `$SENTRY_FOO` reads in bash. */ +const BASH_ENV_READ_RE = /\$\{?(SENTRY_[A-Z0-9_]+)/g; +/** `SENTRY_FOO="..."` script-local assignments (constants, not inputs). */ +const BASH_ASSIGN_RE = /^\s*(?:local\s+)?(SENTRY_[A-Z0-9_]+)=/gm; +/** `--flag)` or `-f|--flag)` case arms in the bash argument parser. */ +const BASH_FLAG_ARM_RE = /^\s*(?:-[a-z]\|)?(--[a-z][a-z-]*)\)/gm; +/** The `usage()` heredoc body in the bash installer. */ +const BASH_USAGE_RE = /usage\(\) \{\n\s*cat <` comment-based help block in PowerShell. */ +const PS_HELP_RE = /^<#([\s\S]*?)#>/; + +/** Flags handled by every CLI; not worth documenting in the guide. */ +const UNDOCUMENTED_FLAGS = new Set(["--help"]); + +type Installer = { + file: string; + reads: Set; + flags: Set; + help: string; +}; + +function matchAll(re: RegExp, text: string): Set { + return new Set([...text.matchAll(re)].map((m) => m[1] ?? "")); +} + +async function loadBash(): Promise { + const file = "install"; + const source = await readFile(file, "utf-8"); + const help = source.match(BASH_USAGE_RE)?.[1]; + if (!help) { + throw new Error(`${file}: usage() heredoc not found`); + } + const assigned = matchAll(BASH_ASSIGN_RE, source); + const reads = new Set( + [...matchAll(BASH_ENV_READ_RE, source)].filter((v) => !assigned.has(v)), + ); + return { file, reads, flags: matchAll(BASH_FLAG_ARM_RE, source), help }; +} + +async function loadPowerShell(): Promise { + const file = "install.ps1"; + const source = await readFile(file, "utf-8"); + const help = source.match(PS_HELP_RE)?.[1]; + if (!help) { + throw new Error(`${file}: <# ... #> help block not found`); + } + return { + file, + reads: matchAll(PS_ENV_READ_RE, source), + flags: new Set(), + help, + }; +} + +const installers = [await loadBash(), await loadPowerShell()]; +const guide = await readFile(GUIDE, "utf-8"); +const problems: string[] = []; + +for (const { file, reads, flags, help } of installers) { + for (const name of [...reads, ...flags]) { + if (!(UNDOCUMENTED_FLAGS.has(name) || help.includes(name))) { + problems.push(`${file} reads ${name} but its help text omits it`); + } + } + for (const name of flags) { + if (!(UNDOCUMENTED_FLAGS.has(name) || guide.includes(name))) { + problems.push(`${file} supports ${name} but ${GUIDE} omits it`); + } + } +} + +if (problems.length === 0) { + console.log( + `✓ Installer options are documented (${installers.map((i) => i.file).join(", ")})`, + ); + process.exit(0); +} + +console.error(`✗ ${problems.length} undocumented installer option(s):\n`); +for (const problem of problems) { + console.error(` ${problem}`); +} +process.exit(1); diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs new file mode 100644 index 000000000..92370db0e --- /dev/null +++ b/packages/cli/script/ensure-generated.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Generate the gitignored `src/generated/` files when they are missing. + * + * A fresh checkout has no API schema, skill content, or completion targets, so + * `pnpm run cli`, `typecheck`, and the tests used to fail until contributors + * ran `generate:schema` and `generate:docs` by hand. Scripts that need those + * files call this first, so there is no bootstrap step to document or forget. + * + * Usage: + * node script/ensure-generated.mjs # everything the CLI imports + * node script/ensure-generated.mjs schema # only the API schema + * + * Plain Node (no tsx) so the check adds no noticeable startup time. + */ + +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:docs`; imported at runtime by `src/`. */ +const DOCS_OUTPUTS = [ + "src/generated/skill-content.ts", + "src/generated/completion-targets.json", +]; + +function run(script) { + console.error(`Missing generated files; running \`pnpm run ${script}\`...`); + execSync(`pnpm run ${script}`, { stdio: "inherit" }); +} + +if (!existsSync(SCHEMA)) { + run("generate:schema"); +} +if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) { + run("generate:docs"); +} diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index dabba3ad5..edfa991fa 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -11,6 +11,10 @@ * - self-hosted.md: OAuth scopes, env var table * - README.md: dev prerequisites, library prerequisites, dev scripts * - getting-started.mdx: platform support table + * - agent-guidance.md: destructive commands (from `destructive` commands) + * - agentic-usage.md: detected agents (from AGENT_DISPLAY_NAMES) + * - commands/index.md fragment, CONTRIBUTING.md: target syntax (TARGET_FORMS) + * - plugins/README.md: skill file layout (paths checked on disk) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), * this script edits committed files in-place between marker pairs. @@ -20,9 +24,9 @@ * tsx script/generate-docs-sections.ts --check # Dry-run, exit 1 if stale */ -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; -import { DOCS_CONTENT } from "./paths.js"; +import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -38,12 +42,16 @@ if (!skillContentExists) { } import type { EnvVarEntry } from "../src/lib/env-registry.js"; +import type { TargetForm } from "../src/lib/target-syntax.js"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); const { extractAllRoutes } = await import("../src/lib/introspect.js"); const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); +const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); +const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); +const { parseOrgProjectArg } = await import("../src/lib/arg-parsing.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); @@ -556,6 +564,109 @@ function generatePlatformSupport(): string { return lines.join("\n"); } +// --------------------------------------------------------------------------- +// Section: Destructive Commands (agent-guidance.md) +// --------------------------------------------------------------------------- + +const SENTRY_PREFIX_RE = /^sentry /; + +/** + * Generate the Safety Rules bullet that lists destructive commands. + * + * Commands opt in with `buildCommand({ destructive: true })`; + * `buildDeleteCommand` sets it automatically, so new delete commands are + * listed without touching the docs. + */ +function generateDestructiveCommands(allRoutes: RouteInfo[]): string { + const names = allRoutes + .flatMap((route) => route.commands) + .filter((cmd) => cmd.destructive) + .map((cmd) => cmd.path.replace(SENTRY_PREFIX_RE, "")) + .sort((a, b) => a.localeCompare(b)); + if (names.length === 0) { + throw new Error("No destructive commands found in the route tree"); + } + const list = names.map((name) => `\`${name}\``).join(", "); + return `- Always confirm with the user before running destructive commands: ${list}`; +} + +// --------------------------------------------------------------------------- +// Section: Detected Agents (agentic-usage.md) +// --------------------------------------------------------------------------- + +/** Generate the list of agents the CLI detects, from AGENT_DISPLAY_NAMES. */ +function generateDetectedAgents(): string { + const names: string[] = Object.values(AGENT_DISPLAY_NAMES); + const last = names.pop(); + return `The CLI detects ${names.join(", ")}, and ${last}.`; +} + +// --------------------------------------------------------------------------- +// Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) +// --------------------------------------------------------------------------- + +/** + * Generate the target syntax table from TARGET_FORMS. Each example goes + * through the real parser, so generation fails when the docs and the parser + * disagree about what a form means. + */ +function generateTargetSyntax(): string { + const lines = ["| Target | Meaning |", "|--------|---------|"]; + for (const [type, forms] of Object.entries(TARGET_FORMS)) { + for (const form of forms as readonly TargetForm[]) { + const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); + if (parsed.type !== type) { + throw new Error( + `TARGET_FORMS: ${form.syntax} example ${JSON.stringify(form.example)} parses as "${parsed.type}", not "${type}"`, + ); + } + lines.push(`| ${form.syntax} | ${form.meaning} |`); + } + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Section: Skill File Layout (plugins/README.md) +// --------------------------------------------------------------------------- + +/** + * Skill-related paths under `packages/cli/`, in tree order. Generation fails + * when one no longer exists, so the README cannot describe a stale layout. + */ +const SKILL_LAYOUT: readonly [path: string, note: string][] = [ + [".claude-plugin/marketplace.json", "Marketplace manifest"], + [".cursor/skills/sentry-cli/SKILL.md", "Symlink to the plugin skill"], + [".cursor/skills/sentry-cli/references", "Symlink to the plugin references"], + ["plugins/README.md", "This file"], + ["plugins/sentry-cli/.claude-plugin/plugin.json", "Plugin manifest"], + [ + "plugins/sentry-cli/skills/sentry-cli/SKILL.md", + "CLI usage skill (generated)", + ], + [ + "plugins/sentry-cli/skills/sentry-cli/references", + "One file per command group (generated)", + ], + ["script/generate-skill.ts", "Generates SKILL.md and references/"], +]; + +/** Render {@link SKILL_LAYOUT} as an annotated path list. */ +function generateSkillLayout(): string { + const missing = SKILL_LAYOUT.filter(([path]) => !existsSync(path)); + if (missing.length > 0) { + throw new Error( + `plugins/README.md layout lists missing paths: ${missing.map(([p]) => p).join(", ")}`, + ); + } + const paths = SKILL_LAYOUT.map(([path]) => `packages/cli/${path}`); + const width = Math.max(...paths.map((p) => p.length)); + const rows = SKILL_LAYOUT.map( + ([, note], i) => `${paths[i]?.padEnd(width)} # ${note}`, + ); + return ["```", ...rows, "```"].join("\n"); +} + // --------------------------------------------------------------------------- // Section Definitions // --------------------------------------------------------------------------- @@ -647,6 +758,32 @@ const sections: SectionDef[] = [ generate: generatePlatformSupport, markerStyle: "mdx", }, + // -- Lists that mirror code -- + { + filePath: `${DOCS_CONTENT}/agent-guidance.md`, + sectionName: "destructive-commands", + generate: () => generateDestructiveCommands(routeInfos), + }, + { + filePath: `${DOCS_CONTENT}/agentic-usage.md`, + sectionName: "detected-agents", + generate: generateDetectedAgents, + }, + { + filePath: `${DOCS_FRAGMENTS}/commands/index.md`, + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "CONTRIBUTING.md", + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "plugins/README.md", + sectionName: "skill-layout", + generate: generateSkillLayout, + }, ]; let staleCount = 0; diff --git a/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index 7cdfa283e..772053849 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/script/generate-skill.ts @@ -128,6 +128,9 @@ function stripMdxComponents(markdown: string): string { // Documentation Loading // --------------------------------------------------------------------------- +/** `` / `END` lines from generate-docs-sections.ts */ +const GENERATED_MARKER_LINE_RE = /^\n/gm; + /** Load and parse a documentation file */ async function loadDoc(relativePath: string): Promise { const fullPath = `${DOCS_PATH}/${relativePath}`; @@ -139,7 +142,9 @@ async function loadDoc(relativePath: string): Promise { return null; } const content = await readFile(fullPath, "utf-8"); - return stripMdxComponents(stripFrontmatter(content)); + return stripMdxComponents( + stripFrontmatter(content).replace(GENERATED_MARKER_LINE_RE, ""), + ); } /** Extract npm command from PackageManagerCode Astro component */ diff --git a/packages/cli/script/strip-arg-separator.mjs b/packages/cli/script/strip-arg-separator.mjs new file mode 100644 index 000000000..210fac230 --- /dev/null +++ b/packages/cli/script/strip-arg-separator.mjs @@ -0,0 +1,14 @@ +/** + * Preload for the dev CLI scripts (`pnpm run cli`, `pnpm run dev`). + * + * npm needs `npm run cli -- --help` to forward arguments, but pnpm and Yarn + * forward the `--` too, so the CLI would see `-- --help` and treat `--help` as + * a positional. A leading `--` is never meaningful to the CLI itself, so drop + * it and let either habit work with any package manager. + * + * Dev scripts only; the built binary keeps its arguments unchanged. + */ + +if (process.argv[2] === "--") { + process.argv.splice(2, 1); +} diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index cba15b5ff..6fd354665 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -30,7 +30,11 @@ import type { SentryContext } from "../../context.js"; import { getSkillInstallPath } from "../../lib/agent-skills.js"; import { isHomebrewInstall } from "../../lib/binary.js"; import { getCompletionPath } from "../../lib/completions.js"; -import { closeDatabase, getConfigDir } from "../../lib/db/index.js"; +import { + closeDatabase, + DEFAULT_CONFIG_DIR_DISPLAY, + getConfigDir, +} from "../../lib/db/index.js"; import { getInstallInfo } from "../../lib/db/install-info.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; @@ -328,7 +332,7 @@ function gatherArtifacts(home: string): UninstallArtifact[] { } } - // 5. Config directory (~/.sentry/) + // 5. Config directory (SENTRY_CONFIG_DIR, XDG, or legacy ~/.sentry) const configDir = getConfigDir(); artifacts.push({ label: "config directory", @@ -452,7 +456,7 @@ export const uninstallCommand = buildDeleteCommand({ flags: { "keep-config": { kind: "boolean", - brief: "Keep the config directory (~/.sentry) and auth tokens", + brief: `Keep the config directory (default ${DEFAULT_CONFIG_DIR_DISPLAY}) and auth tokens`, default: false, optional: true, }, diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 0e4eaa340..d55f5c99b 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -70,7 +70,7 @@ export const restoreCommand = buildCommand({ }, { description: "Pass the organization explicitly", - command: "sentry dashboard restore my-org 12345 --revision 42", + command: "sentry dashboard restore my-org/ 12345 --revision 42", }, { description: "Output the restored dashboard as JSON", diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 35bfa9cb2..6665f3fda 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -131,15 +131,15 @@ export const revisionsCommand = buildCommand({ }, { description: "Pass the organization explicitly", - command: "sentry dashboard revisions my-org 12345", + command: "sentry dashboard revisions my-org/ 12345", }, { description: "Fetch the next page", - command: "sentry dashboard revisions my-org 12345 -c next", + command: "sentry dashboard revisions my-org/ 12345 -c next", }, { description: "Output as JSON", - command: "sentry dashboard revisions my-org 12345 --json", + command: "sentry dashboard revisions my-org/ 12345 --json", }, ], }, diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 54f2e6442..41a28971b 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -251,6 +251,7 @@ async function orderForMerge( } export const mergeCommand = buildCommand({ + destructive: true, docs: { brief: "Merge 2+ issues into a single canonical group", fullDescription: diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index e1f01445d..913d82b3c 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -77,6 +77,7 @@ function parseTrialStartArgs( } export const startCommand = buildCommand({ + destructive: true, docs: { brief: "Start a product trial", fullDescription: diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 9e6835651..c3df5aafc 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -65,7 +65,11 @@ import { parseLogLevel, setLogLevel, } from "./logger.js"; -import { applyLazyPositionals, rawPositional } from "./positional-parse.js"; +import { + applyLazyPositionals, + markDestructive, + rawPositional, +} from "./positional-parse.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; import { applyTipsPreference } from "./tips.js"; @@ -193,6 +197,18 @@ type LocalCommandBuilderArguments< * repo-local `.sentryclirc` URL mismatches the current token. */ readonly skipRcUrlCheck?: boolean; + /** + * Mark the command as destructive or irreversible. Defaults to `false`. + * + * Destructive commands are listed in the agent Safety Rules (generated from + * this flag), so agents ask the user before running them, and their lazy + * target positionals refuse an auto-detected target. + * `buildDeleteCommand` sets this automatically, and `check:destructive` + * fails CI when a command that sends an HTTP DELETE lacks it. Set it + * explicitly for irreversible operations that use other verbs (e.g. + * `issue merge`, `trial start`). + */ + readonly destructive?: boolean; }; // --------------------------------------------------------------------------- @@ -721,6 +737,10 @@ export function buildCommand< existingParams.positional, this, ); + // Lazy target positionals of destructive commands refuse to auto-detect + if (builderArgs.destructive) { + markDestructive(args); + } applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, @@ -899,6 +919,9 @@ export function buildCommand< (cmd as unknown as Record).__examples = builderArgs.docs.examples; } + if (builderArgs.destructive) { + (cmd as unknown as Record).__destructive = true; + } // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 8fbfad802..185209854 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -27,6 +27,9 @@ const LEGACY_CONFIG_DIR_NAME = ".sentry"; /** Sub-directory used under the XDG config base directory. */ const XDG_CONFIG_SUBDIR = "sentry"; +/** Default config directory as shown to users (help text, docs). */ +export const DEFAULT_CONFIG_DIR_DISPLAY = `~/.config/${XDG_CONFIG_SUBDIR}`; + const DB_FILENAME = "cli.db"; /** 7-day TTL for cache entries (milliseconds) */ diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index 80b34863f..d5c5ecd6b 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -10,8 +10,8 @@ * names for known agent executables. Runs as a non-blocking background * task so it never delays CLI startup. * - * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or - * {@link PROCESS_NAME_AGENTS}. + * To add a new agent, add it to {@link AGENT_DISPLAY_NAMES}, then add entries + * to {@link ENV_VAR_AGENTS} and/or {@link PROCESS_NAME_AGENTS}. */ import { execFile } from "node:child_process"; @@ -31,11 +31,42 @@ export type AgentInfo = { role?: string; }; +/** + * Canonical agent name → display name, in documentation order. + * + * This is the list of agents the CLI can detect: {@link KnownAgent} is derived + * from its keys, and the detection tables below only accept known agents, so + * TypeScript rejects a detectable agent with no display name. The + * agentic-usage docs list is generated from it. + */ +export const AGENT_DISPLAY_NAMES = { + claude: "Claude Code", + cowork: "Claude Cowork", + cursor: "Cursor", + windsurf: "Windsurf", + "github-copilot": "GitHub Copilot", + gemini: "Gemini CLI", + codex: "OpenAI Codex", + antigravity: "Antigravity", + goose: "Goose", + amp: "Amp", + augment: "Augment", + opencode: "OpenCode", + cline: "Cline", + grok: "Grok", + kimi: "Kimi", + junie: "Junie", + openclaw: "OpenClaw", +} as const satisfies Record; + +/** Canonical name of an agent the CLI can detect. */ +export type KnownAgent = keyof typeof AGENT_DISPLAY_NAMES; + /** * Canonical name aliases — maps alternative agent identifiers to their * canonical family name. Checked after lowercasing. */ -export const AGENT_ALIASES = new Map([ +export const AGENT_ALIASES = new Map([ ["claude-code", "claude"], ["claudecode", "claude"], ["claude_code", "claude"], @@ -123,7 +154,7 @@ export function normalizeAgent(raw: string): AgentInfo | undefined { * Env var → agent name. Checked in insertion order — first match wins. * Each env var maps directly to the agent that sets it. */ -export const ENV_VAR_AGENTS = new Map([ +export const ENV_VAR_AGENTS = new Map([ // Cursor ["CURSOR_TRACE_ID", "cursor"], ["CURSOR_AGENT", "cursor"], @@ -170,7 +201,7 @@ export const ENV_VAR_AGENTS = new Map([ * Process executable basename (lowercase) → agent name. * Used when scanning the parent process tree as a fallback. */ -export const PROCESS_NAME_AGENTS = new Map([ +export const PROCESS_NAME_AGENTS = new Map([ ["cursor", "cursor"], ["claude", "claude"], ["goose", "goose"], @@ -250,7 +281,7 @@ export function detectAgent(): AgentInfo | undefined { // The role marker also exists outside agent execution; only this value // identifies an agent. Check before the map to retain Cursor's priority. if (env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec") { - return normalizeAgent("cursor"); + return normalizeAgent("cursor" satisfies KnownAgent); } // 2. Table-driven env var check (Map iteration preserves insertion order). @@ -263,7 +294,8 @@ export function detectAgent(): AgentInfo | undefined { // 3. Claude Code / Cowork — requires branching logic, so not in the map if (env.CLAUDECODE || env.CLAUDE_CODE) { - return normalizeAgent(env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"); + const agent: KnownAgent = env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; + return normalizeAgent(agent); } // 4. Lowest priority: generic AGENT fallback diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 819266b9b..f033386a0 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -176,6 +176,13 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ example: "1", installOnly: true, }, + { + name: "SENTRY_NO_MODIFY_PATH", + description: + "Used with the PowerShell install script (`install.ps1`). When set to `1`, the installer leaves the user `PATH` unchanged. The bash install script takes the `--no-modify-path` flag instead.", + example: "1", + installOnly: true, + }, // -- Init wizard -- { name: "SENTRY_INIT_TUI", diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 5a86214d6..3c8e59d12 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -68,6 +68,8 @@ export type Command = { */ __primaryUsage?: string; __examples?: readonly CommandExample[]; + /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ + __destructive?: boolean; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -124,6 +126,8 @@ export type CommandInfo = { examples: string[]; /** JSON output field metadata extracted from `OutputConfig.schema` */ jsonFields?: SchemaFieldInfo[]; + /** Present (and `true`) when the command is destructive or irreversible */ + destructive?: true; }; /** Extracted metadata for a single flag */ @@ -320,6 +324,7 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { ({ description, command }) => `# ${description}\n${command}`, ), jsonFields: jsonFields?.length ? jsonFields : undefined, + ...(cmd.__destructive ? { destructive: true as const } : {}), }; } diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index 53912b838..baed2da39 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -23,7 +23,6 @@ import { buildCommand, type CommandDocumentation } from "./command.js"; import { CliError, ContextError } from "./errors.js"; import type { CommandReturn } from "./formatters/output.js"; import { logger } from "./logger.js"; -import { markDestructive } from "./positional-parse.js"; // --------------------------------------------------------------------------- // Level A: shared flag / alias definitions @@ -295,6 +294,8 @@ export type DeleteCommandOptions = { * 3. Runs a non-interactive guard before `func()` — refuses to proceed if * stdin is not a TTY and neither `--yes`/`--force` was passed (dry-run * bypasses the guard since it makes no changes) + * 4. Marks the command `destructive`, which lists it in the generated agent + * Safety Rules and makes its lazy target positionals refuse auto-detect * * Commands still handle their own confirmation prompts using * {@link confirmByTyping} + {@link isConfirmationBypassed} inside `func()`, @@ -400,9 +401,6 @@ export function buildDeleteCommand< ); } - // Lazy target positionals refuse to auto-detect when they resolve - markDestructive(args); - return originalFunc.call(this, flags, ...(args as unknown as ARGS)); } as typeof originalFunc; @@ -411,5 +409,6 @@ export function buildDeleteCommand< parameters: mergedParams, func: wrappedFunc, output: builderArgs.output, + destructive: true, }); } diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts index b91c5fe52..aec33f5c8 100644 --- a/packages/cli/src/lib/positional-parse.ts +++ b/packages/cli/src/lib/positional-parse.ts @@ -29,8 +29,9 @@ * * Shared code that handles any positional (plural alias interception, `help` * recovery, telemetry) reads the raw input with {@link rawPositional}. - * `buildDeleteCommand` calls {@link markDestructive} on its positionals, so - * resolvers see `destructive: true` and can refuse to auto-detect. + * `buildCommand` calls {@link markDestructive} on the positionals of + * destructive commands (`destructive: true`, which `buildDeleteCommand` sets), + * so resolvers see `destructive: true` and can refuse to auto-detect. */ import { diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index c73f85c0a..47ce9a6cc 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -11,8 +11,8 @@ * * Resolution gets the command path from `commandPrefix`, which Stricli's * `forCommand` (and the SDK invoker) set on the context, so usage hints need - * no hand-written command name. Destructive commands (`buildDeleteCommand`) - * refuse an auto-detected target. + * no hand-written command name. Destructive commands (`destructive: true`, + * which `buildDeleteCommand` sets) refuse an auto-detected target. */ import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts new file mode 100644 index 000000000..810f8ded1 --- /dev/null +++ b/packages/cli/src/lib/target-syntax.ts @@ -0,0 +1,69 @@ +/** + * Documented forms of the `/` target positional, keyed by the + * `parseOrgProjectArg()` result type each form produces. + * + * The target syntax tables in the commands overview and CONTRIBUTING.md are + * generated from this map (`script/generate-docs-sections.ts`). The key set is + * checked by TypeScript, so a new parse result type cannot ship undocumented, + * and the generator parses every example and fails when the parser returns a + * different type, so the table cannot disagree with the parser. + */ + +import type { ProjectSpecificationType } from "./arg-parsing.js"; + +/** A `parseOrgProjectArg()` result type. */ +type SpecificationType = + (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; + +/** One documented target form. */ +export type TargetForm = { + /** Syntax shown in the docs table (Markdown). */ + readonly syntax: string; + /** Example value; `undefined` means the argument is omitted. */ + readonly example: string | undefined; + /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ + readonly multi?: boolean; + /** What the form selects (Markdown). */ + readonly meaning: string; +}; + +/** Target forms by parse result type, in documentation order. */ +export const TARGET_FORMS = { + "auto-detect": [ + { + syntax: "_(omitted)_", + example: undefined, + meaning: + "Auto-detect the org and project from config, DSNs, or the directory name", + }, + ], + explicit: [ + { + syntax: "`/`", + example: "my-org/my-project", + meaning: "That project in that organization", + }, + { + syntax: "`/,`", + example: "my-org/web,api", + multi: true, + meaning: + "Several projects in the same organization (commands that accept several projects)", + }, + ], + "org-all": [ + { + syntax: "`/`", + example: "my-org/", + meaning: "The whole organization", + }, + ], + "project-search": [ + { + syntax: "``", + example: "my-project", + meaning: + "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", + }, + ], +} as const satisfies Record; diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 0103e0536..3899744f9 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -49,7 +49,10 @@ const echoTarget = orgProjectTarget(echoResolver); type EchoTarget = OrgProjectTarget>>; /** Run `test cmd ...args`, recording what `target.resolved` yields. */ -async function runWithTarget(args: string[]): Promise<{ +async function runWithTarget( + args: string[], + { destructive = false } = {}, +): Promise<{ resolved: unknown[]; stderr: string; exitCode: number | undefined; @@ -62,6 +65,7 @@ async function runWithTarget(args: string[]): Promise<{ >({ docs: { brief: "Takes a target" }, auth: false, + destructive, parameters: { positional: { kind: "tuple", @@ -127,6 +131,15 @@ describe("orgProjectTarget", () => { ]); }); + test("a destructive: true command refuses an auto-detected target", async () => { + const { resolved, stderr, exitCode } = await runWithTarget([], { + destructive: true, + }); + expect(resolved).toEqual([]); + expect(exitCode).toBe(EXIT.CONTEXT_MISSING); + expect(stderr).toContain("Auto-detection is disabled"); + }); + test("invalid input fails inside the command with the CliError exit code", async () => { const { resolved, stderr, exitCode } = await runWithTarget(["@latest"]); expect(resolved).toEqual([]);