From d26f68477fbf3abf80e55acc127e4ee592963e03 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:17:40 +0000 Subject: [PATCH 1/4] docs(cli): Correct alias, DSN scan, and Local session docs Show issue aliases the way issue list prints them, list every scanned extension, document configurable Local session retention, drop the duplicated defaults from Local port and host briefs, and replace the unsupported -l limit alias in SDK examples with -n. --- .../src/content/docs/agent-guidance.md | 2 +- apps/cli-docs/src/content/docs/features.md | 41 +++++++------------ .../src/content/docs/library-usage.md | 2 +- apps/cli-docs/src/fragments/commands/local.md | 18 +++++--- packages/cli/AGENTS.md | 11 ++--- packages/cli/README.md | 2 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/local.md | 8 ++-- packages/cli/src/commands/local/run.ts | 4 +- packages/cli/src/commands/local/server.ts | 4 +- packages/cli/src/index.ts | 2 +- 11 files changed, 46 insertions(+), 50 deletions(-) diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index c016145f4..24d0ca35c 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -327,7 +327,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value ## Common Mistakes -- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. +- **Wrong issue ID format**: Use the full short ID (`PROJECT-123`) or the numeric issue ID from a Sentry URL (`123456789`). A bare suffix without its project prefix, such as `123`, is read as a numeric issue ID, not as a short ID. - **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account. With several stored accounts, commands pick the one that knows the target organization or server automatically; use `sentry auth switch` only to change the default. - **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. - **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. diff --git a/apps/cli-docs/src/content/docs/features.md b/apps/cli-docs/src/content/docs/features.md index 08bd6869b..871e473a7 100644 --- a/apps/cli-docs/src/content/docs/features.md +++ b/apps/cli-docs/src/content/docs/features.md @@ -31,7 +31,7 @@ For monorepos or when DSN detection picks up the wrong project, use a [`.sentryc ### Supported Languages -The CLI scans source files for DSN URLs (the `https://…@….ingest.sentry.io/…` pattern) using a universal regex — no language-specific parsing is needed. Any text file with a recognized extension is scanned, including: +The CLI scans source files for DSN URLs (the `https://@/` pattern, so self-hosted DSNs match too) using a universal regex — no language-specific parsing is needed. Any text file with a recognized extension is scanned, including: | Language Family | File Extensions | |----------------|-----------------| @@ -46,7 +46,8 @@ The CLI scans source files for DSN URLs (the `https://…@….ingest.sentry.io/ | Rust | `.rs` | | Dart/Flutter | `.dart` | | Elixir/Erlang | `.ex`, `.exs`, `.erl` | -| Config files | `.json`, `.yaml`, `.yml`, `.toml`, `.xml`, `.properties` | +| Lua | `.lua` | +| Config files | `.json`, `.yaml`, `.yml`, `.toml`, `.xml`, `.properties`, `.config` | ### Caching @@ -69,14 +70,9 @@ sentry issue list my-org/my-project sentry issue list ``` -The CLI will show which project was detected: - -``` -Detected project: my-app (from .env) - -ID SHORT ID TITLE COUNT -123456789 MYAPP-ABC TypeError: Cannot read prop... 142 -``` +The list title names the project the CLI resolved (for example, +`Issues in my-org/my-app:`). View commands such as `sentry org view` also print a +`Detected from ` hint naming the file or variable the DSN came from. ## Monorepo Support & Alias System @@ -94,7 +90,7 @@ Aliases are the shortest unique prefix of each project slug. For example: | Project Slug | Alias | |--------------|-------| -| `frontend` | `f` | +| `frontend` | `fr` | | `functions` | `fu` | | `backend` | `b` | @@ -108,20 +104,14 @@ For projects with a common prefix (like `spotlight-electron`, `spotlight-website ### Using Alias-Suffix Format -After running `issue list`, you can reference issues using the `alias-suffix` format: +After running `issue list`, you can reference issues using the `alias-suffix` format. +When several projects are listed, the `SHORT ID` column shows each issue's +alias-suffix below its short ID (`SPOTLIGHT-ELEC-4Y` → `e-4y`): ```bash -# List issues - note the ALIAS column sentry issue list ``` -``` -ALIAS SHORT ID TITLE COUNT -e SPOTLIGHT-ELEC-4Y TypeError: Cannot read prop... 142 -w SPOTLIGHT-WEB-ABC Failed to fetch user data 89 -b SPOTLIGHT-BACK-XYZ Connection timeout 34 -``` - ```bash # View issue using alias-suffix sentry issue view e-4Y @@ -135,13 +125,12 @@ sentry issue plan b-XYZ ### Cross-Organization Support -If you work with multiple organizations that have projects with the same slug, the CLI uses org-prefixed aliases: +If you work with multiple organizations that have projects with the same slug, the CLI prefixes those aliases with the shortest unique org prefix: -``` -ALIAS SHORT ID TITLE -o1:api ORG1-API-123 Error in API handler -o2:api ORG2-API-456 Database connection failed -``` +| Org/Project | Alias | +|-------------|-------| +| `acme/api` | `a/a` | +| `globex/api` | `g/a` | ## Issue ID Formats diff --git a/apps/cli-docs/src/content/docs/library-usage.md b/apps/cli-docs/src/content/docs/library-usage.md index 6df3f5e0b..2695193a9 100644 --- a/apps/cli-docs/src/content/docs/library-usage.md +++ b/apps/cli-docs/src/content/docs/library-usage.md @@ -112,7 +112,7 @@ pass raw CLI flags, use `sdk.run()`: ```typescript // Run any CLI command — returns parsed JSON by default const version = await sdk.run("--version"); -const issues = await sdk.run("issue", "list", "-l", "5"); +const issues = await sdk.run("issue", "list", "-n", "5"); const help = await sdk.run("help", "issue"); ``` diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 2a918146b..ab3be0872 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -146,24 +146,30 @@ experimentation only; do not run it with production credentials. ## Daemon-owned sessions Use `--session ` to capture into the shared loopback daemon and retain data -after your foreground command exits. Sessions keep up to 500 envelopes for three -hours after closing and subsequent ingestion. Stopping or restarting the daemon -clears all sessions. +after your foreground command exits. Sessions keep up to 500 envelopes by default +(`--max-envelopes` accepts 1 to 50,000) for three hours after closing and +subsequent ingestion. Session commands start the daemon when it isn't running; +`sentry local daemon start` starts it ahead of time. Stopping or restarting the +daemon (`sentry local daemon restart`) clears all sessions. ```bash sentry local run --session web -- npm run dev sentry local serve --session worker sentry local session list --json +sentry local session view web --json +sentry local session retention web --max-envelopes 2000 sentry local event list web --json sentry local event view web --json sentry local envelope list web --json +sentry local envelope view web --json sentry local session reset web sentry local session close web sentry local daemon status sentry local daemon stop ``` -`local session create --json` returns separate SDK ingest and viewer read -capability URLs. Keep these URLs private. `--clientId ` makes retried session -creation idempotent. Use the session ID when multiple sessions share a label. +`sentry local session create --json` returns separate SDK ingest and +viewer read capability URLs. Keep these URLs private. `--clientId ` makes +retried session creation idempotent. Use the session ID when multiple sessions +share a label. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index ac6537f4d..a41ff4014 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -16,7 +16,7 @@ Guidelines for AI agents working in this codebase. ### Key Features -- **DSN Auto-Detection** - Scans `.env` files and source code (JS, Python, Go, Java, Ruby, PHP) to find Sentry DSNs +- **DSN Auto-Detection** - Scans `.env` files and source code (any file type in `TEXT_EXTENSIONS`, `src/lib/scan/constants.ts`) to find Sentry DSNs - **Project Root Detection** - Walks up from CWD to find project boundaries using VCS, language, and build markers - **Directory Name Inference** - Fallback project matching using bidirectional word boundary matching - **Multi-Region Support** - Automatic region detection with fan-out to regional APIs (us.sentry.io, de.sentry.io) @@ -123,9 +123,10 @@ Top-level layout: `wasm-split.ts`, plus the hidden `bash-hook.ts`, `send-event.ts`, and `send-envelope.ts`). - **`src/lib/`** — shared utilities. Key subtrees: `api/` (domain API modules), - `db/` (SQLite layer), `dsn/` (DSN detection, with per-language extractors under - `dsn/languages/`), and `formatters/` (output formatting). See the file-locations - table below and the JSDoc in each module for details. + `db/` (SQLite layer), `dsn/` (DSN detection with one language-agnostic regex), + `scan/` (file walker and scanned extensions), and `formatters/` (output + formatting). See the file-locations table below and the JSDoc in each module + for details. - **`src/types/`** — TypeScript types and Valibot schemas. - **`test/`** — tests mirroring `src/` (unit, `*.property.test.ts`, `*.model-based.test.ts`, `e2e/`, `fixtures/`, `mocks/`). @@ -1097,7 +1098,7 @@ vi.mock("./some-module", () => ({ | Add config types | `src/types/config.ts` | | Add Seer types | `src/types/seer.ts` | | Add utility | `src/lib/` | -| Add DSN language support | `src/lib/dsn/languages/` | +| Add DSN language support | `TEXT_EXTENSIONS` in `src/lib/scan/constants.ts` | | Add DB operations | `src/lib/db/` | | Build scripts | `script/` | | Add property tests | `test/lib/.property.test.ts` | diff --git a/packages/cli/README.md b/packages/cli/README.md index f7c97e2a2..9b535b030 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -112,7 +112,7 @@ await sdk.dashboard.widget.add( // Escape hatch for any CLI command const version = await sdk.run("--version"); -const text = await sdk.run("issue", "list", "-l", "5"); +const text = await sdk.run("issue", "list", "-n", "5"); ``` Options (all optional): 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 4841380bb..1b5fb4488 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -337,7 +337,7 @@ When querying the Events API (directly or via `sentry api`), valid dataset value ### Common Mistakes -- **Wrong issue ID format**: Use `PROJECT-123` (short ID), not the numeric ID `123456789`. The short ID includes the project prefix. +- **Wrong issue ID format**: Use the full short ID (`PROJECT-123`) or the numeric issue ID from a Sentry URL (`123456789`). A bare suffix without its project prefix, such as `123`, is read as a numeric issue ID, not as a short ID. - **Pre-authenticating unnecessarily**: Don't run `sentry auth login` before every command. The CLI detects missing/expired auth and prompts automatically. Only run `sentry auth login` to add an account. With several stored accounts, commands pick the one that knows the target organization or server automatically; use `sentry auth switch` only to change the default. - **Missing `--json` for piping**: Human-readable output includes formatting. Use `--json` when parsing output programmatically. - **Specifying org/project when not needed**: Auto-detection resolves org/project from `.sentryclirc` config files, DSNs, env vars, and directory names. Let it work first — only add `/` if the CLI says it can't detect the target or detects the wrong one. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index a83885f26..1e82bb0b2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -77,8 +77,8 @@ View a decoded Local event Start the local dev server and tail events **Flags:** -- `-p, --port - Port to listen on (default 8969) - (default: "8969")` -- `-H, --host - Hostname to bind to (default localhost) - (default: "localhost")` +- `-p, --port - Port to listen on - (default: "8969")` +- `-H, --host - Hostname to bind to - (default: "localhost")` - `-q, --quiet - Suppress per-envelope tail output` - `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` @@ -186,8 +186,8 @@ Run a command with the local dev server enabled | `` | Command to run | **Flags:** -- `-p, --port - Port for the local server (default 8969) - (default: "8969")` -- `--host - Hostname for the local server (default localhost) - (default: "localhost")` +- `-p, --port - Port for the local server - (default: "8969")` +- `--host - Hostname for the local server - (default: "localhost")` - `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` - `-V, --verify - Verify SDK sends events, then exit` - `-t, --timeout - Kill the child after N seconds (0 = no timeout; defaults to 30 s in --verify mode) - (default: "0")` diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index c3a138106..2711b402a 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -429,13 +429,13 @@ export const runCommand = buildCommand({ port: { kind: "parsed", parse: parsePort, - brief: `Port for the local server (default ${DEFAULT_PORT})`, + brief: "Port for the local server", default: String(DEFAULT_PORT), }, host: { kind: "parsed", parse: String, - brief: "Hostname for the local server (default localhost)", + brief: "Hostname for the local server", default: "localhost", }, filter: { diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index b56eb1dc2..fa91eb610 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1031,13 +1031,13 @@ export const serverCommand = buildCommand({ port: { kind: "parsed", parse: parsePort, - brief: `Port to listen on (default ${DEFAULT_PORT})`, + brief: "Port to listen on", default: String(DEFAULT_PORT), }, host: { kind: "parsed", parse: String, - brief: "Hostname to bind to (default localhost)", + brief: "Hostname to bind to", default: "localhost", }, quiet: { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a164e756b..ad9a20e46 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -50,7 +50,7 @@ export type { SentrySDK } from "./sdk.generated.js"; * * // Escape hatch for any CLI command * const version = await sdk.run("--version"); - * const text = await sdk.run("issue", "list", "-l", "5"); + * const text = await sdk.run("issue", "list", "-n", "5"); * ``` */ export function createSentrySDK( From 5c76a8e38d2eb2656fab1a1003997769f9b597a7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:49:53 +0000 Subject: [PATCH 2/4] docs(cli): Guard docs against drifting from CLI behavior Generate the DSN extension and alias tables on the features page from the scanner and alias code, check sdk.run() examples like CLI examples, fail fragment subcommand coverage instead of warning, and test that value flag briefs don't restate their declared defaults. --- apps/cli-docs/src/content/docs/features.md | 14 +- packages/cli/AGENTS.md | 3 + packages/cli/package.json | 2 +- .../skills/sentry-cli/references/alert.md | 2 +- .../skills/sentry-cli/references/auth.md | 2 +- .../skills/sentry-cli/references/dashboard.md | 2 +- .../skills/sentry-cli/references/sourcemap.md | 2 +- packages/cli/script/check-doc-examples.ts | 48 ++++++- packages/cli/script/check-fragments.ts | 3 +- packages/cli/script/generate-docs-sections.ts | 131 ++++++++++++++++++ .../cli/src/commands/alert/issues/create.ts | 2 +- packages/cli/src/commands/auth/default.ts | 2 +- packages/cli/src/commands/auth/login.ts | 2 +- packages/cli/src/commands/dashboard/view.ts | 3 +- packages/cli/src/commands/sourcemap/upload.ts | 2 +- packages/cli/test/lib/flag-briefs.test.ts | 31 +++++ 16 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 packages/cli/test/lib/flag-briefs.test.ts diff --git a/apps/cli-docs/src/content/docs/features.md b/apps/cli-docs/src/content/docs/features.md index 871e473a7..3a18a699b 100644 --- a/apps/cli-docs/src/content/docs/features.md +++ b/apps/cli-docs/src/content/docs/features.md @@ -33,6 +33,7 @@ For monorepos or when DSN detection picks up the wrong project, use a [`.sentryc The CLI scans source files for DSN URLs (the `https://@/` pattern, so self-hosted DSNs match too) using a universal regex — no language-specific parsing is needed. Any text file with a recognized extension is scanned, including: + | Language Family | File Extensions | |----------------|-----------------| | JavaScript/TypeScript | `.js`, `.ts`, `.jsx`, `.tsx`, `.mjs`, `.cjs`, `.astro`, `.vue`, `.svelte` | @@ -48,6 +49,7 @@ The CLI scans source files for DSN URLs (the `https://@/` | Elixir/Erlang | `.ex`, `.exs`, `.erl` | | Lua | `.lua` | | Config files | `.json`, `.yaml`, `.yml`, `.toml`, `.xml`, `.properties`, `.config` | + ### Caching @@ -88,19 +90,23 @@ When you run `sentry issue list`, the CLI: Aliases are the shortest unique prefix of each project slug. For example: + | Project Slug | Alias | -|--------------|-------| +|---|---| | `frontend` | `fr` | | `functions` | `fu` | | `backend` | `b` | + For projects with a common prefix (like `spotlight-electron`, `spotlight-website`), the prefix is stripped first: + | Project Slug | Alias | -|--------------|-------| +|---|---| | `spotlight-electron` | `e` | | `spotlight-website` | `w` | | `spotlight-backend` | `b` | + ### Using Alias-Suffix Format @@ -127,10 +133,12 @@ sentry issue plan b-XYZ If you work with multiple organizations that have projects with the same slug, the CLI prefixes those aliases with the shortest unique org prefix: + | Org/Project | Alias | -|-------------|-------| +|---|---| | `acme/api` | `a/a` | | `globex/api` | `g/a` | + ## Issue ID Formats diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index a41ff4014..d350413a6 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -518,6 +518,9 @@ All command docs and skill files are generated via `pnpm run generate:docs` (whi - `pnpm run check:fragments` also rejects any shell code block in a fragment that runs a `sentry` command, whatever its heading. For a real workflow, sample output, or cross-command guide, put `` on the line directly above the block. - `pnpm run check:fragments` validates fragment ↔ route consistency. - Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. +- Don't restate a value flag's `default` in its `brief`; help and docs already show it (`test/lib/flag-briefs.test.ts`). +- `pnpm run check:doc-examples` also checks string-literal `sdk.run(…)` examples in the docs, `README.md`, and `src/index.ts`. +- Lists that mirror code (scanned DSN extensions, alias examples, destructive commands, detected agents) are generated sections in `script/generate-docs-sections.ts`; add new mirrors there instead of hand-writing them. ### Valibot Schemas for Validation diff --git a/packages/cli/package.json b/packages/cli/package.json index 086505ce3..b44859f65 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,7 +83,7 @@ "bench:save": "pnpm tsx script/bench.ts --save-baseline", "bench:compare": "pnpm tsx script/bench.ts --compare", "bench:sweep": "pnpm tsx script/bench-sweep.ts", - "check:fragments": "pnpm tsx script/check-fragments.ts", + "check:fragments": "pnpm tsx script/check-fragments.ts --strict", "check:deps": "pnpm tsx script/check-no-deps.ts", "check:errors": "pnpm tsx script/check-error-patterns.ts", "check:patches": "pnpm tsx script/check-patches.ts", diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index e5af865c7..5659cdbd4 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -87,7 +87,7 @@ Create an issue alert rule - `--name - Rule name` - `-c, --condition ... - Condition object JSON (repeatable, or pass one JSON array)` - `-a, --action ... - Action object JSON (repeatable, or pass one JSON array)` -- `--frequency - Frequency in minutes (default: 30) - (default: 30)` +- `--frequency - Frequency in minutes - (default: 30)` - `--environment - Environment filter` - `--filter ... - Filter object JSON (repeatable, or pass one JSON array)` - `-m, --filter-match - Filter match mode: all or any` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md index d5b08c9e8..783f338d5 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -34,7 +34,7 @@ Authenticate with Sentry **Flags:** - `--token - Authenticate using an API token instead of OAuth` -- `--timeout - Timeout for OAuth flow in seconds (default: 900) - (default: "900")` +- `--timeout - Timeout for OAuth flow in seconds - (default: "900")` - `--force - Replace the stored login for this server without prompting, instead of adding another account` - `--url - Sentry instance URL to authenticate against (e.g. https://sentry.example.com). Required for self-hosted; defaults to SaaS (https://sentry.io).` - `--read-only - Request only read-only OAuth scopes (project:read, org:read, event:read, member:read, team:read). Useful for handing tokens to AI agents or CI jobs that should not be able to mutate Sentry state.` 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 3926700ca..5ae687c55 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 @@ -73,7 +73,7 @@ View a dashboard - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01"` -- `--renderer - Graphics renderer (defaults to auto; falls back to auto when unavailable) - (default: "auto")` +- `--renderer - Graphics renderer (falls back to auto when unavailable) - (default: "auto")` - `--no-graphics-cap - Use the terminal-native graphics width (may fall back to ASCII for very large dashboards)` **Examples:** diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md index 667784cdc..08bfb5476 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md @@ -57,7 +57,7 @@ Upload sourcemaps to Sentry **Flags:** - `--release - Release version to associate with the upload` - `--dist - Distribution identifier to disambiguate builds within a release` -- `--url-prefix - URL prefix for uploaded files (default: ~/) - (default: "~/")` +- `--url-prefix - URL prefix for uploaded files - (default: "~/")` - `--ext - Comma-separated file extensions to process (default: .js,.cjs,.mjs)` - `--ignore - Comma-separated glob patterns to exclude (gitignore-style)` - `--ignore-file - Path to a file with gitignore-style patterns to exclude` diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index e4258f965..ef8be972b 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -11,6 +11,9 @@ * matches a project first, so examples that mean the org must use the * trailing-slash form (`my-org/`) * + * Library examples that pass only string literals to `sdk.run(…)` get the same + * checks, since `run()` takes the same argv as the CLI. + * * Placeholders (`/`, `[args]`, `...`) are skipped. Generated * command pages are not scanned: their examples are checked at the source. * @@ -64,6 +67,9 @@ const EXTRA_DOC_FILES = [ "plugins/README.md", ]; +/** Source files whose JSDoc shows `sdk.run(…)` examples. */ +const SDK_EXAMPLE_FILES = ["src/index.ts"]; + /** Flags Stricli handles before command dispatch. */ const BUILTIN_FLAGS = new Set(["help", "help-all", "version"]); const BUILTIN_ALIASES = new Set(["h", "H"]); @@ -85,6 +91,11 @@ const CONTINUATION_RE = /\\\n\s*/g; const PLACEHOLDER_RE = /[<>[\]{}]|\.\.\.|…|^\$/; const SHORT_ALIAS_RE = /^-([a-zA-Z])$/; const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/; +/** `sdk.run(…)` calls; arguments are checked only when all are string literals. */ +const SDK_RUN_RE = /\bsdk\.run\(([^)]*)\)/g; +const SDK_RUN_ARGS_RE = + /^\s*(?:"[^"\\]*"|'[^'\\]*')(?:\s*,\s*(?:"[^"\\]*"|'[^'\\]*'))*\s*,?\s*$/; +const STRING_LITERAL_RE = /"([^"\\]*)"|'([^'\\]*)'/g; type Finding = { file: string; example: string; problem: string }; @@ -159,6 +170,23 @@ function extractInvocations(markdown: string): string[] { return invocations; } +/** + * Argv arrays from `sdk.run("issue", "list", …)` calls. Calls with any + * non-literal argument (variables, spreads) are skipped. + */ +function extractSdkRunArgs(text: string): string[][] { + const calls: string[][] = []; + for (const [, args = ""] of text.matchAll(SDK_RUN_RE)) { + if (!SDK_RUN_ARGS_RE.test(args)) { + continue; + } + calls.push( + [...args.matchAll(STRING_LITERAL_RE)].map((m) => m[1] ?? m[2] ?? ""), + ); + } + return calls; +} + /** Whether `--name` (or the boolean negation `--no-name`) is accepted. */ function hasFlag(cmd: Command, name: string): boolean { const flags = cmd.parameters.flags ?? {}; @@ -223,7 +251,11 @@ function isTargetParam(param: PositionalParam | undefined): boolean { /** Problems with one `sentry …` invocation. */ function checkInvocation(invocation: string): string[] { - const words = tokenize(invocation); + return checkWords(tokenize(invocation)); +} + +/** Problems with one tokenized `sentry …` invocation. */ +function checkWords(words: string[]): string[] { // 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), @@ -326,19 +358,27 @@ for (const { source, invocation } of commandExamples( findings.push({ file: source, example: invocation, problem }); } } -for (const file of files) { +for (const file of [...files, ...SDK_EXAMPLE_FILES]) { const content = await readFile(file, "utf-8"); - for (const invocation of extractInvocations(content)) { + const invocations = file.endsWith(".ts") ? [] : extractInvocations(content); + for (const invocation of invocations) { checked += 1; for (const problem of checkInvocation(invocation)) { findings.push({ file, example: invocation, problem }); } } + for (const argv of extractSdkRunArgs(content)) { + checked += 1; + const example = `sdk.run(${argv.map((a) => JSON.stringify(a)).join(", ")})`; + for (const problem of checkWords(["sentry", ...argv])) { + findings.push({ file, example, problem }); + } + } } if (findings.length === 0) { console.log( - `✓ ${checked} sentry examples in ${files.length} files match the CLI`, + `✓ ${checked} sentry examples in ${files.length + SDK_EXAMPLE_FILES.length} files match the CLI`, ); process.exit(0); } diff --git a/packages/cli/script/check-fragments.ts b/packages/cli/script/check-fragments.ts index 4198d1e2c..dc9edc4d0 100644 --- a/packages/cli/script/check-fragments.ts +++ b/packages/cli/script/check-fragments.ts @@ -7,7 +7,8 @@ * 2. Every fragment file corresponds to an existing route (or is index.md) * 3. Fragment files don't accidentally contain frontmatter or the generated marker * 4. Top-level fragments (e.g., configuration.md) exist - * 5. Fragment content covers all subcommands in a route (warnings only) + * 5. Fragment content covers all subcommands in a route (errors with + * --strict, which `pnpm run check:fragments` passes) * 6. Fragments don't contain Examples sections (examples belong in docs.examples) * 7. Fragment shell code blocks don't run `sentry` commands unless marked * with `` (see fragment-commands.ts) diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index 099d77fc3..069f2b396 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -16,6 +16,8 @@ * - commands/index.md fragment, CONTRIBUTING.md: target syntax (from the * `@form` lines in grammar/org-project-target.pegjs) * - plugins/README.md: skill file layout (paths checked on disk) + * - features.md: DSN-scanned extensions (from TEXT_EXTENSIONS) and project + * alias examples (computed by buildOrgAwareAliases) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), * this script edits committed files in-place between marker pairs. @@ -51,6 +53,8 @@ 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 { TEXT_EXTENSIONS } = await import("../src/lib/scan/constants.js"); +const { buildOrgAwareAliases } = await import("../src/lib/alias.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); const TARGET_GRAMMAR = "grammar/org-project-target.pegjs"; const targetGrammar = await readFile(TARGET_GRAMMAR, "utf-8"); @@ -731,6 +735,91 @@ function generateSkillLayout(): string { return ["```", ...rows, "```"].join("\n"); } +// --------------------------------------------------------------------------- +// Section: DSN-Scanned Extensions (features.md) +// --------------------------------------------------------------------------- + +/** + * Language family labels for {@link TEXT_EXTENSIONS}. Generation fails when an + * extension is scanned but unlisted here, or listed here but not scanned, so + * the features page can't drift from the scanner. + */ +const DSN_EXTENSION_FAMILIES: [string, string[]][] = [ + [ + "JavaScript/TypeScript", + [".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".astro", ".vue", ".svelte"], + ], + ["Python", [".py"]], + ["Go", [".go"]], + [ + "JVM (Java, Kotlin, Scala, Groovy)", + [".java", ".kt", ".kts", ".scala", ".groovy"], + ], + [".NET (C#, F#, VB)", [".cs", ".fs", ".vb"]], + ["Ruby", [".rb", ".erb"]], + ["PHP", [".php"]], + ["Swift/Objective-C", [".swift", ".m", ".mm"]], + ["Rust", [".rs"]], + ["Dart/Flutter", [".dart"]], + ["Elixir/Erlang", [".ex", ".exs", ".erl"]], + ["Lua", [".lua"]], + [ + "Config files", + [".json", ".yaml", ".yml", ".toml", ".xml", ".properties", ".config"], + ], +]; + +/** Generate the scanned-extension table, checked against TEXT_EXTENSIONS. */ +function generateDsnExtensions(): string { + const listed = DSN_EXTENSION_FAMILIES.flatMap(([, exts]) => exts); + const unlisted = [...TEXT_EXTENSIONS].filter((ext) => !listed.includes(ext)); + const unscanned = listed.filter((ext) => !TEXT_EXTENSIONS.has(ext)); + if (unlisted.length > 0 || unscanned.length > 0) { + throw new Error( + "DSN_EXTENSION_FAMILIES is out of sync with TEXT_EXTENSIONS: " + + `unlisted ${unlisted.join(", ") || "none"}; ` + + `not scanned ${unscanned.join(", ") || "none"}`, + ); + } + const rows = DSN_EXTENSION_FAMILIES.map( + ([family, exts]) => + `| ${family} | ${exts.map((ext) => `\`${ext}\``).join(", ")} |`, + ); + return [ + "| Language Family | File Extensions |", + "|----------------|-----------------|", + ...rows, + ].join("\n"); +} + +// --------------------------------------------------------------------------- +// Section: Project Alias Examples (features.md) +// --------------------------------------------------------------------------- + +type AliasPair = { org: string; project: string }; + +/** Render the aliases the CLI generates for `pairs` as a Markdown table. */ +function generateAliasTable( + header: string, + pairs: AliasPair[], + label: (pair: AliasPair) => string, +): string { + const { aliasMap } = buildOrgAwareAliases(pairs); + const rows = pairs.map((pair) => { + const alias = aliasMap.get(`${pair.org}/${pair.project}`); + if (!alias) { + throw new Error(`No alias generated for ${pair.org}/${pair.project}`); + } + return `| \`${label(pair)}\` | \`${alias}\` |`; + }); + return [`| ${header} | Alias |`, "|---|---|", ...rows].join("\n"); +} + +const projectSlug = (pair: AliasPair) => pair.project; +const orgProject = (pair: AliasPair) => `${pair.org}/${pair.project}`; +const inOrg = (org: string, projects: string[]): AliasPair[] => + projects.map((project) => ({ org, project })); + // --------------------------------------------------------------------------- // Section Definitions // --------------------------------------------------------------------------- @@ -848,6 +937,48 @@ const sections: SectionDef[] = [ sectionName: "skill-layout", generate: generateSkillLayout, }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "dsn-extensions", + generate: generateDsnExtensions, + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "alias-unique-prefix", + generate: () => + generateAliasTable( + "Project Slug", + inOrg("acme", ["frontend", "functions", "backend"]), + projectSlug, + ), + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "alias-common-prefix", + generate: () => + generateAliasTable( + "Project Slug", + inOrg("acme", [ + "spotlight-electron", + "spotlight-website", + "spotlight-backend", + ]), + projectSlug, + ), + }, + { + filePath: `${DOCS_CONTENT}/features.md`, + sectionName: "alias-cross-org", + generate: () => + generateAliasTable( + "Org/Project", + [ + { org: "acme", project: "api" }, + { org: "globex", project: "api" }, + ], + orgProject, + ), + }, ]; let staleCount = 0; diff --git a/packages/cli/src/commands/alert/issues/create.ts b/packages/cli/src/commands/alert/issues/create.ts index 05cd8726e..39627f29d 100644 --- a/packages/cli/src/commands/alert/issues/create.ts +++ b/packages/cli/src/commands/alert/issues/create.ts @@ -135,7 +135,7 @@ export const createCommand = buildCommand({ kind: "parsed", parse: numberParser, default: 30, - brief: "Frequency in minutes (default: 30)", + brief: "Frequency in minutes", }, environment: { kind: "parsed", diff --git a/packages/cli/src/commands/auth/default.ts b/packages/cli/src/commands/auth/default.ts index 1c59f84fa..498271988 100644 --- a/packages/cli/src/commands/auth/default.ts +++ b/packages/cli/src/commands/auth/default.ts @@ -127,7 +127,7 @@ export const authDefaultCommand = buildCommand({ timeout: { kind: "parsed", parse: numberParser, - brief: "Timeout for OAuth flow in seconds (default: 900)", + brief: "Timeout for OAuth flow in seconds", default: "900", }, force: { diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 3e106e24f..214142a87 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -595,7 +595,7 @@ export const loginCommand = buildCommand({ timeout: { kind: "parsed", parse: numberParser, - brief: "Timeout for OAuth flow in seconds (default: 900)", + brief: "Timeout for OAuth flow in seconds", // Stricli passes string defaults through parse(); numberParser converts to number default: "900", }, diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 08a70c3a8..55ede214e 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -249,8 +249,7 @@ export const viewCommand = buildCommand({ renderer: { kind: "enum", values: ["auto", "kitty", "sixel"], - brief: - "Graphics renderer (defaults to auto; falls back to auto when unavailable)", + brief: "Graphics renderer (falls back to auto when unavailable)", default: "auto", }, "no-graphics-cap": { diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index 914a35c09..abf915a4a 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -276,7 +276,7 @@ export const uploadCommand = buildCommand({ "url-prefix": { kind: "parsed", parse: String, - brief: "URL prefix for uploaded files (default: ~/)", + brief: "URL prefix for uploaded files", optional: true, default: "~/", }, diff --git a/packages/cli/test/lib/flag-briefs.test.ts b/packages/cli/test/lib/flag-briefs.test.ts new file mode 100644 index 000000000..f74b5b8b4 --- /dev/null +++ b/packages/cli/test/lib/flag-briefs.test.ts @@ -0,0 +1,31 @@ +/** + * Flag brief conventions across the real route tree. + * + * `--help`, the generated command pages, and the skill references all render + * a value flag's `default` after its brief, so a brief that restates the + * default ("Port to listen on (default 8969)") shows it twice. Boolean briefs + * are exempt: they describe what happens without the flag. + */ + +import { expect, test } from "vitest"; +import { routes } from "../../src/app.js"; +import { extractAllRoutes, type RouteMap } from "../../src/lib/introspect.js"; + +/** "(default 8969)", "(default: ~/)", "(defaults to …)" */ +const RESTATED_DEFAULT_RE = /\(defaults?[\s:]/i; + +test("flag briefs don't restate a declared default", () => { + const offenders = extractAllRoutes(routes as unknown as RouteMap) + .flatMap((route) => route.commands) + .flatMap((cmd) => + cmd.flags + .filter( + (flag) => + flag.kind !== "boolean" && + flag.default !== undefined && + RESTATED_DEFAULT_RE.test(flag.brief), + ) + .map((flag) => `${cmd.path} --${flag.name}: ${flag.brief}`), + ); + expect(offenders).toEqual([]); +}); From 7cf591e2b49ac5c743e096cbea3a30f5f5860045 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:38:11 +0000 Subject: [PATCH 3/4] docs(cli): Generate and parse doc examples from command metadata Address review feedback on drift prevention: - Find sdk.run() calls with the TypeScript compiler instead of regexes. TypeScript fences in docs and @example JSDoc in src/index.ts are parsed, so multi-line calls, comments, and parentheses in arguments are handled. - Generate the library-usage.md sdk.run() sample from `issue list` docs.examples, so it can't drift from a checked CLI example. - Let docs.examples take an array of steps for multi-step workflows. Every step is checked by check:doc-examples and shown in --help, the generated pages, and the skill. Move the release CI workflow and the Local session lifecycle out of allow-commands fragment blocks into examples, and count workflow steps toward fragment subcommand coverage. --- .../src/content/docs/library-usage.md | 6 +- apps/cli-docs/src/fragments/commands/local.md | 20 +-- .../src/fragments/commands/release.md | 11 +- packages/cli/AGENTS.md | 6 +- .../skills/sentry-cli/references/local.md | 17 +++ .../skills/sentry-cli/references/release.md | 6 + packages/cli/script/check-doc-examples.ts | 89 ++++---------- packages/cli/script/check-fragments.ts | 16 ++- packages/cli/script/fragment-commands.ts | 12 +- packages/cli/script/generate-docs-sections.ts | 68 ++++++++++- packages/cli/script/sdk-run-calls.ts | 115 ++++++++++++++++++ packages/cli/script/shell-words.ts | 45 +++++++ packages/cli/src/commands/local/run.ts | 18 +++ packages/cli/src/commands/local/server.ts | 5 + packages/cli/src/commands/release/create.ts | 10 ++ packages/cli/src/lib/command-example.ts | 24 ++++ packages/cli/src/lib/command.ts | 16 +-- packages/cli/src/lib/introspect.ts | 5 +- packages/cli/test/lib/introspect.test.ts | 16 +++ .../cli/test/script/sdk-run-calls.test.ts | 80 ++++++++++++ 20 files changed, 470 insertions(+), 115 deletions(-) create mode 100644 packages/cli/script/sdk-run-calls.ts create mode 100644 packages/cli/script/shell-words.ts create mode 100644 packages/cli/src/lib/command-example.ts create mode 100644 packages/cli/test/script/sdk-run-calls.test.ts diff --git a/apps/cli-docs/src/content/docs/library-usage.md b/apps/cli-docs/src/content/docs/library-usage.md index 2695193a9..850af07c3 100644 --- a/apps/cli-docs/src/content/docs/library-usage.md +++ b/apps/cli-docs/src/content/docs/library-usage.md @@ -109,12 +109,16 @@ for zero overhead beyond the command's own logic. For commands not easily expressed through the typed API, or when you want to pass raw CLI flags, use `sdk.run()`: + ```typescript // Run any CLI command — returns parsed JSON by default const version = await sdk.run("--version"); -const issues = await sdk.run("issue", "list", "-n", "5"); +// Show only unresolved issues +const issues = await sdk.run("issue", "list", "my-org/frontend", "--query", "is:unresolved"); +// Help text for a command group const help = await sdk.run("help", "issue"); ``` + ## Authentication diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index ab3be0872..ede568626 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -152,22 +152,10 @@ subsequent ingestion. Session commands start the daemon when it isn't running; `sentry local daemon start` starts it ahead of time. Stopping or restarting the daemon (`sentry local daemon restart`) clears all sessions. - -```bash -sentry local run --session web -- npm run dev -sentry local serve --session worker -sentry local session list --json -sentry local session view web --json -sentry local session retention web --max-envelopes 2000 -sentry local event list web --json -sentry local event view web --json -sentry local envelope list web --json -sentry local envelope view web --json -sentry local session reset web -sentry local session close web -sentry local daemon status -sentry local daemon stop -``` +The `sentry local run` examples above walk through a full session lifecycle: +capture, inspect sessions, events, and envelopes, then reset, close, and stop +the daemon. `sentry local serve --session worker` captures into a session the +same way without wrapping a command. `sentry local session create --json` returns separate SDK ingest and viewer read capability URLs. Keep these URLs private. `--clientId ` makes diff --git a/apps/cli-docs/src/fragments/commands/release.md b/apps/cli-docs/src/fragments/commands/release.md index 91c2fd82c..73641e89b 100644 --- a/apps/cli-docs/src/fragments/commands/release.md +++ b/apps/cli-docs/src/fragments/commands/release.md @@ -3,15 +3,8 @@ ## Release workflow A typical CI release creates the release, associates commits, finalizes it, and -records a deploy: - - -```bash -sentry release create my-org/1.0.0 --project my-project -sentry release set-commits my-org/1.0.0 --auto -sentry release finalize my-org/1.0.0 -sentry release deploy my-org/1.0.0 production -``` +records a deploy. The `sentry release create` examples above show the full +sequence. ## Important Notes diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index d350413a6..12d106185 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -515,12 +515,12 @@ All command docs and skill files are generated via `pnpm run generate:docs` (whi - **Examples belong to the command**: add them to `docs.examples` (`{ description, command }[]`, see `src/commands/agent-conversation/list.ts`). They render in `--help`, the generated docs, and the skill references. Don't hand-write examples in `fullDescription` (an `Examples:` block, or indented `sentry …` lines under any label) or an Examples heading in a fragment; the `sentry-cli/no-examples-in-full-description` lint rule and `pnpm run check:fragments` reject both. - **Target syntax belongs to the positional**: describe accepted target forms in the positional `brief` (use `ORG_ALL_TARGET_BRIEF` or `PROJECT_TARGET_BRIEF` from `src/lib/list-command.ts` for `[/][]` targets), not in a `Target patterns:` block. The brief renders in `--help`, the generated docs, and the skill references. - Use fragments in `apps/cli-docs/src/fragments/commands/` only for prose the command metadata can't carry: guides, permissions, caveats. -- `pnpm run check:fragments` also rejects any shell code block in a fragment that runs a `sentry` command, whatever its heading. For a real workflow, sample output, or cross-command guide, put `` on the line directly above the block. +- `pnpm run check:fragments` also rejects any shell code block in a fragment that runs a `sentry` command, whatever its heading. Multi-step workflows belong in `docs.examples` too: pass `command` an array of steps (see `release create` and `local run`), and every step is checked and shown in `--help`, the docs, and the skill. Reserve `` (on the line directly above the block) for sample output and guides that interleave non-`sentry` commands. - `pnpm run check:fragments` validates fragment ↔ route consistency. - Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. - Don't restate a value flag's `default` in its `brief`; help and docs already show it (`test/lib/flag-briefs.test.ts`). -- `pnpm run check:doc-examples` also checks string-literal `sdk.run(…)` examples in the docs, `README.md`, and `src/index.ts`. -- Lists that mirror code (scanned DSN extensions, alias examples, destructive commands, detected agents) are generated sections in `script/generate-docs-sections.ts`; add new mirrors there instead of hand-writing them. +- `pnpm run check:doc-examples` also checks string-literal `sdk.run(…)` calls in TypeScript fences and `@example` JSDoc (`src/index.ts`). They are found by parsing with the TypeScript compiler (`script/sdk-run-calls.ts`); don't add regex-based source parsing. +- Lists that mirror code (scanned DSN extensions, alias examples, destructive commands, detected agents, the `library-usage.md` `sdk.run()` sample built from `issue list` examples) are generated sections in `script/generate-docs-sections.ts`; add new mirrors there instead of hand-writing them. ### Valibot Schemas for Validation diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 1e82bb0b2..35ef9feb1 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -96,6 +96,9 @@ sentry local # Use a custom port sentry local --port 9000 +# Receive into a daemon session that outlives this terminal +sentry local serve --session worker + # Only show errors and logs (filter out transactions) sentry local -f error -f log @@ -206,6 +209,20 @@ sentry local run -- npm run dev # Run a Django dev server with the local server enabled sentry local run -- python manage.py runserver +# Capture into a daemon session, inspect it after the app exits, then clean up +sentry local run --session web -- npm run dev +sentry local session list --json +sentry local session view web --json +sentry local session retention web --max-envelopes 2000 +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local envelope view web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop + # Run your app and open the Sentry Local UI sentry local run --open -- npm run dev diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md index a4a1dc732..37a945cf8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -119,6 +119,12 @@ sentry release create 1.0.0 --finalize # Create a release for specific projects sentry release create 1.0.0 --project proj-a,proj-b +# Typical CI release: create, associate commits, finalize, and record a deploy +sentry release create my-org/1.0.0 --project my-project +sentry release set-commits my-org/1.0.0 --auto +sentry release finalize my-org/1.0.0 +sentry release deploy my-org/1.0.0 production + # Create a release from a proposed version sentry release create "$(sentry release propose-version --json | jq -r .version)" diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index ef8be972b..8b3fb7922 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -3,8 +3,8 @@ * 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: + * every step of 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 @@ -12,7 +12,9 @@ * trailing-slash form (`my-org/`) * * Library examples that pass only string literals to `sdk.run(…)` get the same - * checks, since `run()` takes the same argv as the CLI. + * checks, since `run()` takes the same argv as the CLI. Those calls are found + * by parsing TypeScript fences and `@example` JSDoc with the TypeScript + * compiler (see sdk-run-calls.ts), not by pattern matching. * * Placeholders (`/`, `[args]`, `...`) are skipped. Generated * command pages are not scanned: their examples are checked at the source. @@ -28,6 +30,7 @@ import { mkdirSync, readdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { exampleSteps } from "../src/lib/command-example.js"; import type { Command, PositionalParam, @@ -36,6 +39,12 @@ import type { } from "../src/lib/introspect.js"; import { blankCodeBlocks, markdownCodeBlocks } from "./markdown.js"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; +import { + jsdocExamples, + markdownTsBlocks, + sdkRunCalls, +} from "./sdk-run-calls.js"; +import { tokenize } from "./shell-words.js"; // Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -91,11 +100,6 @@ const CONTINUATION_RE = /\\\n\s*/g; const PLACEHOLDER_RE = /[<>[\]{}]|\.\.\.|…|^\$/; const SHORT_ALIAS_RE = /^-([a-zA-Z])$/; const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/; -/** `sdk.run(…)` calls; arguments are checked only when all are string literals. */ -const SDK_RUN_RE = /\bsdk\.run\(([^)]*)\)/g; -const SDK_RUN_ARGS_RE = - /^\s*(?:"[^"\\]*"|'[^'\\]*')(?:\s*,\s*(?:"[^"\\]*"|'[^'\\]*'))*\s*,?\s*$/; -const STRING_LITERAL_RE = /"([^"\\]*)"|'([^'\\]*)'/g; type Finding = { file: string; example: string; problem: string }; @@ -104,46 +108,6 @@ 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[] = []; @@ -170,21 +134,11 @@ function extractInvocations(markdown: string): string[] { return invocations; } -/** - * Argv arrays from `sdk.run("issue", "list", …)` calls. Calls with any - * non-literal argument (variables, spreads) are skipped. - */ -function extractSdkRunArgs(text: string): string[][] { - const calls: string[][] = []; - for (const [, args = ""] of text.matchAll(SDK_RUN_RE)) { - if (!SDK_RUN_ARGS_RE.test(args)) { - continue; - } - calls.push( - [...args.matchAll(STRING_LITERAL_RE)].map((m) => m[1] ?? m[2] ?? ""), - ); - } - return calls; +/** TypeScript example snippets: fenced blocks in Markdown, `@example` JSDoc in source. */ +function typescriptExamples(file: string, content: string): string[] { + return file.endsWith(".ts") + ? jsdocExamples(file, content) + : markdownTsBlocks(content); } /** Whether `--name` (or the boolean negation `--no-name`) is accepted. */ @@ -341,10 +295,9 @@ function commandExamples( return []; } const source = `\`${["sentry", ...path].join(" ")}\` help`; - return (target.__examples ?? []).map((e) => ({ - source, - invocation: e.command, - })); + return (target.__examples ?? []).flatMap((e) => + exampleSteps(e).map((invocation) => ({ source, invocation })), + ); } const findings: Finding[] = []; @@ -367,7 +320,7 @@ for (const file of [...files, ...SDK_EXAMPLE_FILES]) { findings.push({ file, example: invocation, problem }); } } - for (const argv of extractSdkRunArgs(content)) { + for (const argv of typescriptExamples(file, content).flatMap(sdkRunCalls)) { checked += 1; const example = `sdk.run(${argv.map((a) => JSON.stringify(a)).join(", ")})`; for (const problem of checkWords(["sentry", ...argv])) { diff --git a/packages/cli/script/check-fragments.ts b/packages/cli/script/check-fragments.ts index dc9edc4d0..00b18ade8 100644 --- a/packages/cli/script/check-fragments.ts +++ b/packages/cli/script/check-fragments.ts @@ -8,7 +8,9 @@ * 3. Fragment files don't accidentally contain frontmatter or the generated marker * 4. Top-level fragments (e.g., configuration.md) exist * 5. Fragment content covers all subcommands in a route (errors with - * --strict, which `pnpm run check:fragments` passes) + * --strict, which `pnpm run check:fragments` passes). A command is + * covered by its own docs.examples, a workflow example step that runs + * it, or a mention in the fragment * 6. Fragments don't contain Examples sections (examples belong in docs.examples) * 7. Fragment shell code blocks don't run `sentry` commands unless marked * with `` (see fragment-commands.ts) @@ -274,9 +276,19 @@ for (const [routeName, route] of multiCommandRoutes) { const defaultCmd = getDefaultCommandName(routeName); + // Workflow examples (multi-step `docs.examples`) document every command + // they run, not only the command that owns them. + const exampleSteps = route.commands.flatMap((cmd) => + cmd.examples.flatMap((example) => + example.split("\n").filter((line) => !line.startsWith("#")), + ), + ); + const runByExample = (path: string) => + exampleSteps.some((step) => step === path || step.startsWith(`${path} `)); + const missing: string[] = []; for (const cmd of route.commands) { - if (cmd.examples.length > 0) { + if (cmd.examples.length > 0 || runByExample(cmd.path)) { continue; } const sub = cmd.path.slice(`sentry ${routeName} `.length); diff --git a/packages/cli/script/fragment-commands.ts b/packages/cli/script/fragment-commands.ts index 9af1e2b2b..9c91aba48 100644 --- a/packages/cli/script/fragment-commands.ts +++ b/packages/cli/script/fragment-commands.ts @@ -6,12 +6,14 @@ * code block in a fragment that runs `sentry …` is almost always an example * in disguise, whatever its heading says. * - * Blocks that legitimately need commands (multi-step workflows, sample output, - * cross-command guides) opt out with an HTML comment directly above the block: + * Multi-step workflows belong there too, as an example whose `command` is an + * array of steps. Blocks that legitimately need commands (sample output, guides + * that interleave non-`sentry` commands) opt out with an HTML comment directly + * above the block: * - * + * * ```bash - * sentry release create … + * sentry org list * ``` * * The reason is required so reviewers can judge each exception. @@ -120,7 +122,7 @@ function checkTokens( } else if (!marker && commands.length > 0) { issues.push({ line: line(token), - message: `code block runs \`${commands[0]}\`; move it to the command's docs.examples, or mark a workflow or sample-output block with on the line above`, + message: `code block runs \`${commands[0]}\`; move it to the command's docs.examples (multi-step workflows take an array of steps), or mark a sample-output block with on the line above`, }); } } else if (marker) { diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index 069f2b396..281f792a1 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -18,6 +18,8 @@ * - plugins/README.md: skill file layout (paths checked on disk) * - features.md: DSN-scanned extensions (from TEXT_EXTENSIONS) and project * alias examples (computed by buildOrgAwareAliases) + * - library-usage.md: `sdk.run()` sample (translated from command + * `docs.examples`) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), * this script edits committed files in-place between marker pairs. @@ -31,6 +33,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import peggy from "peggy"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; +import { tokenize } from "./shell-words.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -46,10 +49,12 @@ if (!skillContentExists) { } import type { EnvVarEntry } from "../src/lib/env-registry.js"; -import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; +import { exampleSteps } from "../src/lib/command-example.js"; +import type { Command, RouteInfo, RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); -const { extractAllRoutes } = await import("../src/lib/introspect.js"); +const { extractAllRoutes, isCommand, isRouteMap } = + 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"); @@ -820,6 +825,60 @@ const orgProject = (pair: AliasPair) => `${pair.org}/${pair.project}`; const inOrg = (org: string, projects: string[]): AliasPair[] => projects.map((project) => ({ org, project })); +// --------------------------------------------------------------------------- +// Section: sdk.run() Examples (library-usage.md) +// --------------------------------------------------------------------------- + +/** The command at `path` in the route tree, e.g. `["issue", "list"]`. */ +function commandAt(path: string[]): Command { + let target: unknown = routeMap; + for (const name of path) { + target = isRouteMap(target) + ? target.getAllEntries().find((e) => e.name.original === name)?.target + : undefined; + } + if (!isCommand(target)) { + throw new Error( + `sdk-run-examples: no command \`sentry ${path.join(" ")}\``, + ); + } + return target; +} + +/** + * Translate the first single-step example of `path` that passes a flag into + * an `sdk.run(…)` statement, so the library docs show an invocation the CLI + * already documents and checks. + */ +function sdkRunStatement(path: string[], variable: string): string { + for (const example of commandAt(path).__examples ?? []) { + const steps = exampleSteps(example); + const words = steps.length === 1 ? tokenize(steps[0] ?? "") : []; + if (words[0] === "sentry" && words.some((w) => w.startsWith("-"))) { + const argv = words.slice(1).map((w) => JSON.stringify(w)); + return [ + `// ${example.description}`, + `const ${variable} = await sdk.run(${argv.join(", ")});`, + ].join("\n"); + } + } + throw new Error( + `sdk-run-examples: \`sentry ${path.join(" ")}\` has no single-step example with a flag`, + ); +} + +function generateSdkRunExamples(): string { + return [ + "```typescript", + "// Run any CLI command — returns parsed JSON by default", + 'const version = await sdk.run("--version");', + sdkRunStatement(["issue", "list"], "issues"), + "// Help text for a command group", + 'const help = await sdk.run("help", "issue");', + "```", + ].join("\n"); +} + // --------------------------------------------------------------------------- // Section Definitions // --------------------------------------------------------------------------- @@ -979,6 +1038,11 @@ const sections: SectionDef[] = [ orgProject, ), }, + { + filePath: `${DOCS_CONTENT}/library-usage.md`, + sectionName: "sdk-run-examples", + generate: generateSdkRunExamples, + }, ]; let staleCount = 0; diff --git a/packages/cli/script/sdk-run-calls.ts b/packages/cli/script/sdk-run-calls.ts new file mode 100644 index 000000000..d54d13a89 --- /dev/null +++ b/packages/cli/script/sdk-run-calls.ts @@ -0,0 +1,115 @@ +/** + * Find `sdk.run(…)` calls in TypeScript example code. + * + * `run()` takes the same argv as the CLI, so literal calls can be checked + * against the route tree like shell examples. Code is parsed with the + * TypeScript compiler, so nested parentheses, template literals, comments, + * and multi-line calls are handled the way TypeScript sees them. + */ + +import ts from "typescript"; +import { markdownCodeBlocks } from "./markdown.js"; + +/** Markdown fence languages parsed as TypeScript. */ +export const TS_LANGS = new Set(["ts", "typescript", "js", "javascript"]); + +/** Whether `node` is `sdk.run(…)` (the documented receiver name). */ +function isSdkRun(node: ts.CallExpression): boolean { + const callee = node.expression; + return ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "run" && + ts.isIdentifier(callee.expression) && + callee.expression.text === "sdk" + ); +} + +/** + * Argv arrays of `sdk.run("issue", "list", …)` calls in a TypeScript + * snippet. Calls with any non-literal argument (variables, spreads, + * interpolated templates) are skipped because their argv isn't static. + */ +export function sdkRunCalls(code: string): string[][] { + const source = ts.createSourceFile( + "example.ts", + code, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ); + const calls: string[][] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && isSdkRun(node)) { + const args = node.arguments; + if (args.every(ts.isStringLiteralLike)) { + calls.push(args.map((arg) => arg.text)); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return calls; +} + +/** `@example` tag bodies of one standalone JSDoc comment. */ +function exampleTagBodies(comment: string): string[] { + // Attach the comment to a placeholder statement so the compiler parses it + // as that statement's JSDoc. + const source = ts.createSourceFile( + "jsdoc.ts", + `${comment}\nvoid 0;`, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const statement = source.statements[0]; + if (!statement) { + return []; + } + return ts + .getJSDocTags(statement) + .filter((tag) => tag.tagName.text === "example") + .map((tag) => ts.getTextOfJSDocComment(tag.comment) ?? ""); +} + +/** + * Code from every `@example` JSDoc tag in a TypeScript file. Fenced examples + * yield their fenced code; unfenced examples yield the whole tag body. + * + * Every `/** … *\/` comment is visited, including stacked comments on one + * node (the compiler's JSDoc helpers only report the closest one). + */ +export function jsdocExamples(fileName: string, text: string): string[] { + const source = ts.createSourceFile( + fileName, + text, + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ); + const seen = new Set(); + const examples: string[] = []; + const visit = (node: ts.Node): void => { + for (const range of ts.getLeadingCommentRanges(text, node.pos) ?? []) { + const comment = text.slice(range.pos, range.end); + if (seen.has(range.pos) || !comment.startsWith("/**")) { + continue; + } + seen.add(range.pos); + for (const body of exampleTagBodies(comment)) { + const fenced = markdownTsBlocks(body); + examples.push(...(fenced.length ? fenced : [body])); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return examples; +} + +/** Code of the TypeScript/JavaScript fenced blocks of a Markdown document. */ +export function markdownTsBlocks(markdown: string): string[] { + return markdownCodeBlocks(markdown) + .filter((block) => TS_LANGS.has(block.lang)) + .map((block) => block.code); +} diff --git a/packages/cli/script/shell-words.ts b/packages/cli/script/shell-words.ts new file mode 100644 index 000000000..8b5259715 --- /dev/null +++ b/packages/cli/script/shell-words.ts @@ -0,0 +1,45 @@ +/** + * Minimal shell word splitting for doc examples: whitespace separates words, + * single and double quotes group them, and a leading `#` starts a comment. + * No expansion is performed; `$(…)` and variables stay literal. + */ + +/** Split a shell command into words, honoring single and double quotes. */ +export 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; +} diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 2711b402a..aed8d0bf4 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -404,6 +404,24 @@ export const runCommand = buildCommand({ description: "Run a Django dev server with the local server enabled", command: "sentry local run -- python manage.py runserver", }, + { + description: + "Capture into a daemon session, inspect it after the app exits, then clean up", + command: [ + "sentry local run --session web -- npm run dev", + "sentry local session list --json", + "sentry local session view web --json", + "sentry local session retention web --max-envelopes 2000", + "sentry local event list web --json", + "sentry local event view web --json", + "sentry local envelope list web --json", + "sentry local envelope view web --json", + "sentry local session reset web", + "sentry local session close web", + "sentry local daemon status", + "sentry local daemon stop", + ], + }, { description: "Run your app and open the Sentry Local UI", command: "sentry local run --open -- npm run dev", diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index fa91eb610..4d30ea86d 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -1004,6 +1004,11 @@ export const serverCommand = buildCommand({ command: "sentry local", }, { description: "Use a custom port", command: "sentry local --port 9000" }, + { + description: + "Receive into a daemon session that outlives this terminal", + command: "sentry local serve --session worker", + }, { description: "Only show errors and logs (filter out transactions)", command: "sentry local -f error -f log", diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index 016593333..eb3c0e93c 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -66,6 +66,16 @@ export const createCommand = buildCommand({ description: "Create a release for specific projects", command: "sentry release create 1.0.0 --project proj-a,proj-b", }, + { + description: + "Typical CI release: create, associate commits, finalize, and record a deploy", + command: [ + "sentry release create my-org/1.0.0 --project my-project", + "sentry release set-commits my-org/1.0.0 --auto", + "sentry release finalize my-org/1.0.0", + "sentry release deploy my-org/1.0.0 production", + ], + }, { description: "Create a release from a proposed version", command: diff --git a/packages/cli/src/lib/command-example.ts b/packages/cli/src/lib/command-example.ts new file mode 100644 index 000000000..52eba0b66 --- /dev/null +++ b/packages/cli/src/lib/command-example.ts @@ -0,0 +1,24 @@ +/** + * Command example metadata shared by `buildCommand`, introspection, and the + * doc generators. Kept dependency-free so scripts can import it cheaply. + */ + +/** + * A runnable example stored with its command for generated documentation. + * + * `command` is one invocation, or the ordered steps of a workflow that spans + * several commands (e.g. create → set-commits → finalize). Every step is + * checked against the route tree by `check:doc-examples`, so workflows belong + * here rather than in hand-written fragment code blocks. + */ +export type CommandExample = { + readonly description: string; + readonly command: string | readonly string[]; +}; + +/** The invocations of an example, one per step. */ +export function exampleSteps(example: CommandExample): readonly string[] { + return typeof example.command === "string" + ? [example.command] + : example.command; +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 88535c601..bc073dd4b 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -38,6 +38,7 @@ import { } from "@stricli/core"; import type { Writer } from "../types/index.js"; import { appendCacheHint } from "./cache-hint.js"; +import { type CommandExample, exampleSteps } from "./command-example.js"; import { getAuthConfig, hasUsableStoredAccount } from "./db/auth.js"; import { getEnv } from "./env.js"; import { AuthError, CliError, OutputError } from "./errors.js"; @@ -100,12 +101,6 @@ type BaseArgs = readonly unknown[]; type StricliBuilderArgs = import("@stricli/core").CommandBuilderArguments; -/** A runnable example stored with its command for generated documentation. */ -export type CommandExample = { - readonly description: string; - readonly command: string; -}; - /** * Native Stricli documentation plus canonical examples for generated docs. */ @@ -459,7 +454,14 @@ function prepareNativeDocs( return nativeDocs; } const rendered = examples - .map(({ description, command }) => ` ${command} # ${description}`) + .map((example) => { + const steps = exampleSteps(example); + return steps.length === 1 + ? ` ${steps[0]} # ${example.description}` + : [` # ${example.description}`, ...steps.map((s) => ` ${s}`)].join( + "\n", + ); + }) .join("\n"); return { ...nativeDocs, diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index 3c8e59d12..c77a380c9 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -10,7 +10,7 @@ * for introspection and documentation generation. */ -import type { CommandExample } from "./command.js"; +import { type CommandExample, exampleSteps } from "./command-example.js"; import { COMPLETION_TARGETS, type CompletionTarget, @@ -321,7 +321,8 @@ export function buildCommandInfo(cmd: Command, path: string): CommandInfo { positionals, aliases: cmd.parameters.aliases ?? {}, examples: (cmd.__examples ?? []).map( - ({ description, command }) => `# ${description}\n${command}`, + (example) => + `# ${example.description}\n${exampleSteps(example).join("\n")}`, ), jsonFields: jsonFields?.length ? jsonFields : undefined, ...(cmd.__destructive ? { destructive: true as const } : {}), diff --git a/packages/cli/test/lib/introspect.test.ts b/packages/cli/test/lib/introspect.test.ts index 3d0e942b8..1c60be4f9 100644 --- a/packages/cli/test/lib/introspect.test.ts +++ b/packages/cli/test/lib/introspect.test.ts @@ -277,6 +277,22 @@ describe("buildCommandInfo", () => { ]); }); + test("renders multi-step workflow examples one step per line", () => { + const cmd = makeCommand({ + brief: "Create things", + __examples: [ + { + description: "Create, then finalize", + command: ["sentry thing create a", "sentry thing finalize a"], + }, + ], + }); + const info = buildCommandInfo(cmd, "sentry thing create"); + expect(info.examples).toEqual([ + "# Create, then finalize\nsentry thing create a\nsentry thing finalize a", + ]); + }); + test("handles command with no positional args", () => { const cmd = makeCommand({ brief: "Do something" }); const info = buildCommandInfo(cmd, "sentry do"); diff --git a/packages/cli/test/script/sdk-run-calls.test.ts b/packages/cli/test/script/sdk-run-calls.test.ts new file mode 100644 index 000000000..8fd3f1f32 --- /dev/null +++ b/packages/cli/test/script/sdk-run-calls.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "vitest"; +import { + jsdocExamples, + markdownTsBlocks, + sdkRunCalls, +} from "../../script/sdk-run-calls.js"; + +describe("sdkRunCalls", () => { + test("collects literal argv across lines, comments, and templates", () => { + const code = [ + 'await sdk.run("--version");', + "await sdk.run(", + ' "issue", // the group', + " `list`,", + ' "-n", "5",', + ");", + 'const nested = format(await sdk.run("help", "issue"));', + ].join("\n"); + expect(sdkRunCalls(code)).toEqual([ + ["--version"], + ["issue", "list", "-n", "5"], + ["help", "issue"], + ]); + }); + + test("skips calls whose argv isn't static", () => { + const code = [ + "await sdk.run(...args);", + 'await sdk.run("issue", name);', + 'await sdk.run("issue", `${group}`);', + 'await other.run("issue", "list");', + ].join("\n"); + expect(sdkRunCalls(code)).toEqual([]); + }); + + test("handles parentheses inside string arguments", () => { + expect(sdkRunCalls('sdk.run("issue", "list", "--query", "a:(b)")')).toEqual( + [["issue", "list", "--query", "a:(b)"]], + ); + }); +}); + +describe("jsdocExamples", () => { + test("returns fenced code from every @example tag", () => { + const source = [ + "/**", + " * Module docs.", + " *", + " * @example", + " * ```typescript", + ' * await sdk.run("--version");', + " * ```", + " */", + "", + "/**", + " * @example", + ' * await sdk.run("help");', + " */", + "export function f() {}", + ].join("\n"); + expect( + jsdocExamples("index.ts", source).flatMap((code) => sdkRunCalls(code)), + ).toEqual([["--version"], ["help"]]); + }); +}); + +describe("markdownTsBlocks", () => { + test("returns only TypeScript and JavaScript fences", () => { + const md = [ + "```bash", + "sentry issue list", + "```", + "", + "```ts", + 'sdk.run("issue", "list");', + "```", + ].join("\n"); + expect(markdownTsBlocks(md)).toEqual(['sdk.run("issue", "list");']); + }); +}); From 98b75d233e691b64eebc5b08a9dbbb07b56bc35a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:55:32 +0000 Subject: [PATCH 4/4] docs(cli): Parse doc shell examples with shell-quote Replace the hand-written shell tokenizer and the operator-splitting regex with shell-quote (zero dependencies, already in the lockfile), added as a dev dependency for the doc scripts. Quoting, escapes, control operators, comments, and redirections now follow shell rules, so a `|` or `&&` inside a quoted argument no longer splits a command. `` words are protected before parsing so they aren't read as redirections. --- packages/cli/package.json | 1 + packages/cli/script/check-doc-examples.ts | 36 ++++--- packages/cli/script/generate-docs-sections.ts | 5 +- packages/cli/script/shell-words.ts | 100 +++++++++++------- packages/cli/test/script/shell-words.test.ts | 50 +++++++++ pnpm-lock.yaml | 3 + 6 files changed, 139 insertions(+), 56 deletions(-) create mode 100644 packages/cli/test/script/shell-words.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index b44859f65..6479814ad 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -146,6 +146,7 @@ "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semver": "^7.8.5", + "shell-quote": "^1.10.0", "string-width": "^8.2.1", "tinyglobby": "^0.2.17", "tsx": "^4.22.4", diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 8b3fb7922..ad6689cbd 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -44,7 +44,7 @@ import { markdownTsBlocks, sdkRunCalls, } from "./sdk-run-calls.js"; -import { tokenize } from "./shell-words.js"; +import { shellCommands } from "./shell-words.js"; // Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -94,7 +94,6 @@ const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); const INLINE_CODE_RE = /(?[\]{}]|\.\.\.|…|^\$/; @@ -120,10 +119,8 @@ function extractInvocations(markdown: string): string[] { if (line.startsWith("#")) { continue; } - for (const segment of line.split(COMMAND_SPLIT_RE)) { - if (segment.startsWith("sentry ")) { - invocations.push(segment); - } + if (shellCommands(line).some((words) => sentryArgs(words))) { + invocations.push(line); } } } @@ -203,21 +200,28 @@ function isTargetParam(param: PositionalParam | undefined): boolean { ); } -/** Problems with one `sentry …` invocation. */ -function checkInvocation(invocation: string): string[] { - return checkWords(tokenize(invocation)); -} - -/** Problems with one tokenized `sentry …` invocation. */ -function checkWords(words: string[]): string[] { - // Skip a `sudo` or `NAME=value` prefix, e.g. `SENTRY_DSN=… sentry monitor run` +/** + * Arguments after `sentry` when `words` runs the CLI, skipping a `sudo` or + * `NAME=value` prefix (e.g. `SENTRY_DSN=… sentry monitor run`). + */ +function sentryArgs(words: string[]): string[] | undefined { const start = words.findIndex( (word) => word !== "sudo" && !ENV_ASSIGNMENT_RE.test(word), ); - if (words[start] !== "sentry") { + return words[start] === "sentry" ? words.slice(start + 1) : undefined; +} + +/** Problems with the `sentry …` commands of one shell line. */ +function checkInvocation(line: string): string[] { + return shellCommands(line).flatMap(checkWords); +} + +/** Problems with one tokenized command; other programs are ignored. */ +function checkWords(words: string[]): string[] { + const tokens = sentryArgs(words); + if (!tokens) { return []; } - const tokens = words.slice(start + 1); const resolved = resolve(tokens); if (!resolved) { return []; diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index 281f792a1..5e09c7b15 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -33,7 +33,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; import peggy from "peggy"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; -import { tokenize } from "./shell-words.js"; +import { shellCommands } from "./shell-words.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -853,7 +853,8 @@ function commandAt(path: string[]): Command { function sdkRunStatement(path: string[], variable: string): string { for (const example of commandAt(path).__examples ?? []) { const steps = exampleSteps(example); - const words = steps.length === 1 ? tokenize(steps[0] ?? "") : []; + const commands = steps.length === 1 ? shellCommands(steps[0] ?? "") : []; + const words = commands.length === 1 ? (commands[0] ?? []) : []; if (words[0] === "sentry" && words.some((w) => w.startsWith("-"))) { const argv = words.slice(1).map((w) => JSON.stringify(w)); return [ diff --git a/packages/cli/script/shell-words.ts b/packages/cli/script/shell-words.ts index 8b5259715..edf0899e0 100644 --- a/packages/cli/script/shell-words.ts +++ b/packages/cli/script/shell-words.ts @@ -1,45 +1,69 @@ /** - * Minimal shell word splitting for doc examples: whitespace separates words, - * single and double quotes group them, and a leading `#` starts a comment. - * No expansion is performed; `$(…)` and variables stay literal. + * Split shell lines from doc examples into commands and words. + * + * Parsing is done by `shell-quote`, so quoting, escapes, control operators + * (`&&`, `||`, `|`, `;`), and comments follow shell rules: an operator inside + * quotes doesn't split a command. Nothing is expanded; `$VAR` and `$(…)` stay + * literal. + * + * Docs write placeholders as `/`, which a shell would read as + * redirections. They are protected before parsing and restored in the words. */ -/** Split a shell command into words, honoring single and double quotes. */ -export 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; +import { parse } from "shell-quote"; + +/** A `` doc placeholder. */ +const ANGLE_PLACEHOLDER_RE = /<[^<>\s]*>/g; +/** A protected placeholder: NUL, its index, NUL. */ +const MASKED_PLACEHOLDER_RE = /\0(\d+)\0/g; + +/** Operators that end one command and start the next. */ +const COMMAND_SEPARATORS = new Set([ + "&&", + "||", + "|", + "|&", + ";", + ";;", + "&", + "(", + ")", +]); + +/** + * The commands of one shell line, each as its list of words. Redirections + * (`> out.json`) are dropped with their targets. + */ +export function shellCommands(line: string): string[][] { + const placeholders: string[] = []; + const masked = line.replace( + ANGLE_PLACEHOLDER_RE, + (placeholder) => `\0${placeholders.push(placeholder) - 1}\0`, + ); + const restore = (word: string) => + word.replace( + MASKED_PLACEHOLDER_RE, + (_, index: string) => placeholders[Number(index)] ?? "", + ); + + const commands: string[][] = [[]]; + let redirectTarget = false; + for (const entry of parse(masked, (name) => `$${name}`)) { + const current = commands.at(-1) ?? []; + if (typeof entry === "string") { + if (!redirectTarget) { + current.push(restore(entry)); } - continue; + redirectTarget = false; + } else if ("comment" in entry) { + break; + } else if (entry.op === "glob") { + current.push(restore(entry.pattern)); + } else if (COMMAND_SEPARATORS.has(entry.op)) { + commands.push([]); + } else { + redirectTarget = true; // `>`, `>>`, `<`, `2>&1`, … } - current += ch; - hasToken = true; - } - if (hasToken) { - tokens.push(current); } - return tokens; + return commands.filter((words) => words.length > 0); } diff --git a/packages/cli/test/script/shell-words.test.ts b/packages/cli/test/script/shell-words.test.ts new file mode 100644 index 000000000..8cc0744c4 --- /dev/null +++ b/packages/cli/test/script/shell-words.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { shellCommands } from "../../script/shell-words.js"; + +describe("shellCommands", () => { + test("splits on control operators but not inside quotes", () => { + expect( + shellCommands( + 'sentry issue list --query "a|b && c" --json | jq .data; echo done', + ), + ).toEqual([ + ["sentry", "issue", "list", "--query", "a|b && c", "--json"], + ["jq", ".data"], + ["echo", "done"], + ]); + }); + + test("keeps angle-bracket doc placeholders as words", () => { + expect(shellCommands("sentry issue list / --json")).toEqual([ + ["sentry", "issue", "list", "/", "--json"], + ]); + }); + + test("leaves variables and command substitution unexpanded", () => { + expect( + shellCommands( + 'SENTRY_DSN=$DSN sentry release create "$(sentry release propose-version)"', + ), + ).toEqual([ + [ + "SENTRY_DSN=$DSN", + "sentry", + "release", + "create", + "$(sentry release propose-version)", + ], + ]); + }); + + test("drops comments and redirections", () => { + expect( + shellCommands("sentry issue list --json > issues.json # save"), + ).toEqual([["sentry", "issue", "list", "--json"]]); + }); + + test("keeps glob patterns literal", () => { + expect(shellCommands("sentry sourcemap upload ./dist/*.js")).toEqual([ + ["sentry", "sourcemap", "upload", "./dist/*.js"], + ]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b277e9bc..8d683d590 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,6 +584,9 @@ importers: semver: specifier: ^7.8.5 version: 7.8.5 + shell-quote: + specifier: ^1.10.0 + version: 1.10.0 string-width: specifier: ^8.2.1 version: 8.2.2