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..3a18a699b 100644 --- a/apps/cli-docs/src/content/docs/features.md +++ b/apps/cli-docs/src/content/docs/features.md @@ -31,8 +31,9 @@ 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 | |----------------|-----------------| | JavaScript/TypeScript | `.js`, `.ts`, `.jsx`, `.tsx`, `.mjs`, `.cjs`, `.astro`, `.vue`, `.svelte` | @@ -46,7 +47,9 @@ 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 +72,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 @@ -92,36 +90,34 @@ When you run `sentry issue list`, the CLI: Aliases are the shortest unique prefix of each project slug. For example: + | Project Slug | Alias | -|--------------|-------| -| `frontend` | `f` | +|---|---| +| `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 -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 +131,14 @@ 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..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", "-l", "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 2a918146b..ede568626 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -146,24 +146,18 @@ 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. - - -```bash -sentry local run --session web -- npm run dev -sentry local serve --session worker -sentry local session list --json -sentry local event list web --json -sentry local event view web --json -sentry local envelope list 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. +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. + +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 +retried session creation idempotent. Use the session ID when multiple sessions +share a label. 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 ac6537f4d..12d106185 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/`). @@ -514,9 +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(…)` 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 @@ -1097,7 +1101,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/package.json b/packages/cli/package.json index 086505ce3..6479814ad 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", @@ -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/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/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/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index a83885f26..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 @@ -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")` @@ -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 @@ -186,8 +189,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")` @@ -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/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..ad6689cbd 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -3,14 +3,19 @@ * 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 * 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. 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. * @@ -25,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, @@ -33,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 { 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"; @@ -64,6 +76,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"]); @@ -79,7 +94,6 @@ const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); const INLINE_CODE_RE = /(?[\]{}]|\.\.\.|…|^\$/; @@ -93,46 +107,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[] = []; @@ -145,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); } } } @@ -159,6 +131,13 @@ function extractInvocations(markdown: string): string[] { return invocations; } +/** 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. */ function hasFlag(cmd: Command, name: string): boolean { const flags = cmd.parameters.flags ?? {}; @@ -221,17 +200,28 @@ function isTargetParam(param: PositionalParam | undefined): boolean { ); } -/** 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` +/** + * 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 []; @@ -309,10 +299,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[] = []; @@ -326,19 +315,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 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])) { + 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..00b18ade8 100644 --- a/packages/cli/script/check-fragments.ts +++ b/packages/cli/script/check-fragments.ts @@ -7,7 +7,10 @@ * 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). 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) @@ -273,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 099d77fc3..5e09c7b15 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -16,6 +16,10 @@ * - 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) + * - 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. @@ -29,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 { 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"; @@ -44,13 +49,17 @@ 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"); +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 +740,146 @@ 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: 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 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 [ + `// ${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 // --------------------------------------------------------------------------- @@ -848,6 +997,53 @@ 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, + ), + }, + { + 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..edf0899e0 --- /dev/null +++ b/packages/cli/script/shell-words.ts @@ -0,0 +1,69 @@ +/** + * 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. + */ + +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)); + } + 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`, … + } + } + return commands.filter((words) => words.length > 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/local/run.ts b/packages/cli/src/commands/local/run.ts index c3a138106..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", @@ -429,13 +447,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..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", @@ -1031,13 +1036,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/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/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/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( 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/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([]); +}); 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");']); + }); +}); 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