diff --git a/.craft.yml b/.craft.yml index ffac3629a8..692ccd3bba 100644 --- a/.craft.yml +++ b/.craft.yml @@ -3,8 +3,8 @@ changelog: policy: auto versioning: policy: auto -preReleaseCommand: node --experimental-strip-types script/bump-version.ts --pre -postReleaseCommand: node --experimental-strip-types script/bump-version.ts --post +preReleaseCommand: bash -c 'cd packages/cli && node --experimental-strip-types script/bump-version.ts --pre' +postReleaseCommand: bash -c 'cd packages/cli && node --experimental-strip-types script/bump-version.ts --post' artifactProvider: name: github config: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8797d3fddf..6f385dea2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,24 +67,24 @@ jobs: with: filters: | skill: - - 'src/**' - - 'docs/**' - - 'package.json' - - 'README.md' - - 'DEVELOPMENT.md' - - 'script/generate-skill.ts' - - 'script/generate-command-docs.ts' - - 'script/generate-docs-sections.ts' - - 'script/eval-skill.ts' - - 'test/skill-eval/**' + - 'packages/cli/src/**' + - 'apps/cli-docs/**' + - 'packages/cli/package.json' + - 'packages/cli/README.md' + - 'packages/cli/DEVELOPMENT.md' + - 'packages/cli/script/generate-skill.ts' + - 'packages/cli/script/generate-command-docs.ts' + - 'packages/cli/script/generate-docs-sections.ts' + - 'packages/cli/script/eval-skill.ts' + - 'packages/cli/test/skill-eval/**' code: - - 'src/**' - - 'test/**' - - 'script/**' - - 'patches/**' - - 'docs/**' - - 'plugins/**' - - 'package.json' + - 'packages/cli/src/**' + - 'packages/cli/test/**' + - 'packages/cli/script/**' + - 'packages/cli/patches/**' + - 'apps/cli-docs/**' + - 'packages/cli/plugins/**' + - 'packages/cli/package.json' - 'pnpm-lock.yaml' - '.github/workflows/ci.yml' codemod: @@ -119,7 +119,7 @@ jobs: if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: | TS=$(date -d "$COMMIT_TIMESTAMP" +%s) - CURRENT=$(jq -r .version package.json) + CURRENT=$(jq -r .version packages/cli/package.json) VERSION=$(echo "$CURRENT" | sed "s/-dev\.[0-9]*$/-dev.${TS}/") echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "Nightly version: ${VERSION}" @@ -169,8 +169,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Generate API Schema @@ -182,7 +185,7 @@ jobs: - name: Check skill files id: check-skill run: | - if git diff --quiet plugins/sentry-cli/skills/sentry-cli/; then + if git diff --quiet packages/cli/plugins/sentry-cli/skills/sentry-cli/; then echo "Skill files are up to date" else echo "stale=true" >> "$GITHUB_OUTPUT" @@ -191,7 +194,7 @@ jobs: - name: Check docs sections id: check-sections run: | - if git diff --quiet README.md DEVELOPMENT.md docs/src/content/docs/contributing.md docs/src/content/docs/self-hosted.md docs/src/content/docs/getting-started.mdx; then + if git diff --quiet packages/cli/README.md packages/cli/DEVELOPMENT.md apps/cli-docs/src/content/docs/contributing.md apps/cli-docs/src/content/docs/self-hosted.md apps/cli-docs/src/content/docs/getting-started.mdx; then echo "Docs sections are up to date" else echo "stale=true" >> "$GITHUB_OUTPUT" @@ -202,7 +205,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add plugins/sentry-cli/skills/sentry-cli/ README.md DEVELOPMENT.md docs/src/content/docs/contributing.md docs/src/content/docs/self-hosted.md docs/src/content/docs/getting-started.mdx + git add packages/cli/plugins/sentry-cli/skills/sentry-cli/ packages/cli/README.md packages/cli/DEVELOPMENT.md apps/cli-docs/src/content/docs/contributing.md apps/cli-docs/src/content/docs/self-hosted.md apps/cli-docs/src/content/docs/getting-started.mdx git diff --cached --quiet || (git commit -m "chore: regenerate docs" && git push) - name: Fail for fork PRs with stale generated files if: (steps.check-skill.outputs.stale == 'true' || steps.check-sections.outputs.stale == 'true') && steps.token.outcome != 'success' @@ -224,8 +227,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - run: pnpm run generate:schema @@ -255,8 +261,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Generate API Schema @@ -290,8 +299,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ matrix.os }}-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ matrix.os }}-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - name: Install dependencies if: steps.cache.outputs.cache-hit != 'true' shell: bash @@ -337,8 +349,8 @@ jobs: if: needs.changes.outputs.nightly-version != '' shell: bash run: | - jq --arg v "${{ needs.changes.outputs.nightly-version }}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json + jq --arg v "${{ needs.changes.outputs.nightly-version }}" '.version = $v' packages/cli/package.json > packages/cli/package.json.tmp + mv packages/cli/package.json.tmp packages/cli/package.json - name: Build env: # Environment-scoped (production) — must be set at step level to @@ -357,9 +369,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.target }}" == "windows-x64" ]]; then - ./dist-bin/sentry-windows-x64.exe --help + ./packages/cli/dist-bin/sentry-windows-x64.exe --help else - ./dist-bin/sentry-${{ matrix.target }} --help + ./packages/cli/dist-bin/sentry-${{ matrix.target }} --help fi - name: Smoke test (deep — SQLite, telemetry, auth DB) if: matrix.can-test @@ -369,9 +381,9 @@ jobs: SENTRY_TOKEN: "" run: | if [[ "${{ matrix.target }}" == "windows-x64" ]]; then - BIN=./dist-bin/sentry-windows-x64.exe + BIN=./packages/cli/dist-bin/sentry-windows-x64.exe else - BIN=./dist-bin/sentry-${{ matrix.target }} + BIN=./packages/cli/dist-bin/sentry-${{ matrix.target }} fi # auth status without a token exercises SQLite init, schema # migrations, telemetry lazy import, and the CJS require chain. @@ -397,7 +409,7 @@ jobs: (github.ref_name == 'main' || startsWith(github.ref_name, 'release/')) shell: bash run: | - BIN=./dist-bin/sentry-${{ matrix.target }} + BIN=./packages/cli/dist-bin/sentry-${{ matrix.target }} echo "Verifying code signature on $BIN..." rcodesign verify "$BIN" - name: Upload binary artifact @@ -405,15 +417,15 @@ jobs: with: name: sentry-${{ matrix.target }} path: | - dist-bin/sentry-* - !dist-bin/*.gz + packages/cli/dist-bin/sentry-* + !packages/cli/dist-bin/*.gz - name: Upload compressed artifact if: github.event_name != 'pull_request' uses: actions/upload-artifact@v7 with: name: sentry-${{ matrix.target }}-gz - path: dist-bin/*.gz + path: packages/cli/dist-bin/*.gz generate-patches: name: Generate Delta Patches @@ -754,8 +766,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Download Linux binary @@ -801,8 +816,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Bundle @@ -831,7 +849,7 @@ jobs: # create and open the database reliably. The runner's ~/.sentry # may not exist under the switched Node 20 runtime. SENTRY_CONFIG_DIR: ${{ runner.temp }}/.sentry-smoke - run: node dist/bin.cjs --help + run: node packages/cli/dist/bin.cjs --help - name: Smoke test (Node.js — deep) shell: bash env: @@ -843,7 +861,7 @@ jobs: # migrations, telemetry lazy import, and the CJS require chain. # On Node 20 this runs entirely through the bundled WASM SQLite # driver. Expected: exit 10 (AUTH_NOT_AUTHENTICATED), NOT a crash. - OUTPUT=$(node dist/bin.cjs auth status 2>&1) && EXIT_CODE=$? || EXIT_CODE=$? + OUTPUT=$(node packages/cli/dist/bin.cjs auth status 2>&1) && EXIT_CODE=$? || EXIT_CODE=$? if [[ $EXIT_CODE -ne 10 ]]; then echo "::error::Expected exit code 10 (not authenticated), got $EXIT_CODE" echo "$OUTPUT" @@ -854,12 +872,14 @@ jobs: echo "$OUTPUT" exit 1 fi + - run: npm pack + working-directory: packages/cli - name: Upload artifact if: matrix.node == '22' uses: actions/upload-artifact@v7 with: name: npm-package - path: "*.tgz" + path: "packages/cli/*.tgz" build-docs: name: Build Docs @@ -885,13 +905,16 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Get CLI version id: version - run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + run: echo "version=$(node -p 'require("./packages/cli/package.json").version')" >> "$GITHUB_OUTPUT" - name: Download compiled CLI binary uses: actions/download-artifact@v8 with: @@ -902,7 +925,7 @@ jobs: - name: Generate docs content run: pnpm run generate:schema && pnpm run generate:docs - name: Build Docs - working-directory: docs + working-directory: apps/cli-docs env: PUBLIC_SENTRY_ENVIRONMENT: production SENTRY_RELEASE: ${{ steps.version.outputs.version }} @@ -919,18 +942,18 @@ jobs: SENTRY_ORG: sentry SENTRY_PROJECT: cli-website run: | - ./dist-bin/sentry-linux-x64 sourcemap inject docs/dist/ - ./dist-bin/sentry-linux-x64 sourcemap upload docs/dist/ \ + ./dist-bin/sentry-linux-x64 sourcemap inject apps/cli-docs/dist/ + ./dist-bin/sentry-linux-x64 sourcemap upload apps/cli-docs/dist/ \ --release "${{ steps.version.outputs.version }}" \ --url-prefix "~/" # Remove .map files — they were uploaded to Sentry but shouldn't # be deployed to production. - name: Remove sourcemaps from output - run: find docs/dist -name '*.map' -delete + run: find apps/cli-docs/dist -name '*.map' -delete - name: Package Docs run: | - cp .nojekyll docs/dist/ - cd docs/dist && zip -r ../../gh-pages.zip . + cp .nojekyll apps/cli-docs/dist/ + cd apps/cli-docs/dist && zip -r "$GITHUB_WORKSPACE/gh-pages.zip" . - name: Upload docs artifact uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 5df7f0cb36..cf8313474d 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -4,11 +4,11 @@ on: push: branches: [main] paths: - - 'docs/**' - - 'src/**' - - 'script/generate-command-docs.ts' - - 'script/generate-skill.ts' - - 'install' + - 'apps/cli-docs/**' + - 'packages/cli/src/**' + - 'packages/cli/script/generate-command-docs.ts' + - 'packages/cli/script/generate-skill.ts' + - 'packages/cli/install' - '.github/workflows/docs-preview.yml' pull_request: # No paths filter here: the 'closed' event must ALWAYS fire so @@ -48,11 +48,11 @@ jobs: with: filters: | docs: - - 'docs/**' - - 'src/**' - - 'script/generate-command-docs.ts' - - 'script/generate-skill.ts' - - 'install' + - 'apps/cli-docs/**' + - 'packages/cli/src/**' + - 'packages/cli/script/generate-command-docs.ts' + - 'packages/cli/script/generate-skill.ts' + - 'packages/cli/install' - '.github/workflows/docs-preview.yml' # Central gate. `build` = produce the site (push, or a docs PR being @@ -93,8 +93,11 @@ jobs: id: cache if: steps.gate.outputs.build == 'true' with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.gate.outputs.build == 'true' && steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile @@ -102,7 +105,7 @@ jobs: - name: Get CLI version id: version if: steps.gate.outputs.build == 'true' - run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT" + run: echo "version=$(node -p 'require("./packages/cli/package.json").version')" >> "$GITHUB_OUTPUT" - name: Generate docs content if: steps.gate.outputs.build == 'true' @@ -110,7 +113,7 @@ jobs: - name: Build Docs for Preview if: steps.gate.outputs.build == 'true' - working-directory: docs + working-directory: apps/cli-docs env: DOCS_BASE_PATH: ${{ github.event_name == 'push' && '/_preview/pr-main' @@ -129,15 +132,15 @@ jobs: SENTRY_ORG: sentry SENTRY_PROJECT: cli-website run: | - pnpm run cli sourcemap inject docs/dist/ - pnpm run cli sourcemap upload docs/dist/ \ + pnpm run cli sourcemap inject "$GITHUB_WORKSPACE/apps/cli-docs/dist/" + pnpm run cli sourcemap upload "$GITHUB_WORKSPACE/apps/cli-docs/dist/" \ --release "${{ steps.version.outputs.version }}" \ --url-prefix "~/" # Remove .map files — uploaded to Sentry but shouldn't be deployed. - name: Remove sourcemaps from output if: steps.gate.outputs.build == 'true' - run: find docs/dist -name '*.map' -delete + run: find apps/cli-docs/dist -name '*.map' -delete - name: Ensure .nojekyll at gh-pages root # Runs whenever we deploy (build or a 'closed' cleanup), but never for @@ -180,7 +183,7 @@ jobs: if: steps.gate.outputs.deploy == 'true' uses: rossjrw/pr-preview-action@v1 with: - source-dir: docs/dist/ + source-dir: apps/cli-docs/dist/ preview-branch: gh-pages umbrella-dir: _preview pages-base-url: cli.sentry.dev diff --git a/.github/workflows/eval-skill-fork.yml b/.github/workflows/eval-skill-fork.yml index 71c0c381ac..a986023874 100644 --- a/.github/workflows/eval-skill-fork.yml +++ b/.github/workflows/eval-skill-fork.yml @@ -54,8 +54,11 @@ jobs: - uses: actions/cache@v5 id: cache with: - path: node_modules - key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'patches/**') }} + path: | + node_modules + packages/*/node_modules + apps/*/node_modules + key: node-modules-${{ hashFiles('pnpm-lock.yaml', '.npmrc', 'packages/cli/patches/**') }} - if: steps.cache.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 858ee3ec52..3f638f09d4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,10 +43,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .turbo .mastra -# docs -docs/dist -docs/node_modules -docs/.astro +# docs (now under apps/cli-docs) +apps/cli-docs/dist +apps/cli-docs/node_modules +apps/cli-docs/.astro # local planning notes (not for version control) .plans @@ -58,20 +58,20 @@ docs/.astro .DS_Store # Generated files (rebuilt at build time) -src/generated/ -src/sdk.generated.ts -src/sdk.generated.d.cts +packages/cli/src/generated/ +packages/cli/src/sdk.generated.ts +packages/cli/src/sdk.generated.d.cts # ...except the sixel banner: it's small, deterministic (baked from the committed # assets/banner-mask.png), and statically imported by src/lib/sixel.ts, so it must # exist on a fresh checkout before any build step regenerates it. -!src/generated/banner-sixel.ts +!packages/cli/src/generated/banner-sixel.ts # Generated docs pages (rebuilt from fragments + CLI introspection / env registry) -docs/src/content/docs/commands/ -docs/src/content/docs/configuration.md +apps/cli-docs/src/content/docs/commands/ +apps/cli-docs/src/content/docs/configuration.md # Generated discovery manifest (rebuilt by generate:skill, served via symlinked skill files) -docs/public/.well-known/skills/index.json +apps/cli-docs/public/.well-known/skills/index.json # OpenCode .opencode/ @@ -79,7 +79,7 @@ opencode.json* # Throwaway documentation audit reports (bot-generated, not real docs) DOCUMENTATION_GAPS.md -docs/DOCUMENTATION_GAPS.md +apps/cli-docs/DOCUMENTATION_GAPS.md DOCUMENTATION_GAP_REPORT.md # Throwaway planning / migration notes (agent- or human-authored scratch docs, diff --git a/.npmrc b/.npmrc index d67f374883..1aa498c4e2 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1 @@ -node-linker=hoisted +node-linker=isolated diff --git a/AGENTS.md b/AGENTS.md index 798b0250b9..3316fa78e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,1004 +1,3 @@ -# AGENTS.md - -Guidelines for AI agents working in this codebase. - -## Project Overview - -**Sentry CLI** is a command-line interface for [Sentry](https://sentry.io), built with [Bun](https://bun.sh) and [Stricli](https://bloomberg.github.io/stricli/). - -### Goals - -- **Zero-config experience** - Auto-detect project context from DSNs in source code and env files -- **AI-powered debugging** - Integrate Seer AI for root cause analysis and fix plans -- **Developer-friendly** - Follow `gh` CLI conventions for intuitive UX -- **Agent-friendly** - JSON output and predictable behavior for AI coding agents -- **Fast** - Native binaries via Bun, SQLite caching for API responses - -### Key Features - -- **DSN Auto-Detection** - Scans `.env` files and source code (JS, Python, Go, Java, Ruby, PHP) 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) -- **Monorepo Support** - Generates short aliases for multiple projects -- **Seer AI Integration** - `issue explain` and `issue plan` commands for AI analysis -- **OAuth Device Flow** - Secure authentication without browser redirects - -## Cursor Rules (Important!) - -Before working on this codebase, read the Cursor rules: - -- **`.cursor/rules/bun-cli.mdc`** - Bun API usage, file I/O, process spawning, testing -- **`.cursor/rules/ultracite.mdc`** - Code style, formatting, linting rules - -## Quick Reference: Commands - -> **Note**: Always check `package.json` for the latest scripts. - -```bash -# Development -bun install # Install dependencies -bun run dev # Run CLI in dev mode -bun run --env-file=.env.local src/bin.ts # Dev with env vars - -# Build -bun run build # Build for current platform -bun run build:all # Build for all platforms - -# Type Checking -bun run typecheck # Check types - -# Linting & Formatting -bun run lint # Check for issues -bun run lint:fix # Auto-fix issues (run before committing) - -# Testing -bun test # Run all tests -bun test path/to/file.test.ts # Run single test file -bun test --watch # Watch mode -bun test --filter "test name" # Run tests matching pattern -bun run test:unit # Run unit tests only -bun run test:e2e # Run e2e tests only -``` - -## Rules: No Runtime Dependencies - -**CRITICAL**: All packages must be in `devDependencies`, never `dependencies`. Everything is bundled at build time via esbuild. CI enforces this with `bun run check:deps`. - -When adding a package, always use `bun add -d ` (the `-d` flag). - -When the `@sentry/api` SDK provides types for an API response, import them directly from `@sentry/api` instead of creating redundant Zod schemas in `src/types/sentry.ts`. - -## Rules: Use Bun APIs - -**CRITICAL**: This project uses Bun as runtime. Always prefer Bun-native APIs over Node.js equivalents. - -Read the full guidelines in `.cursor/rules/bun-cli.mdc`. - -**Bun Documentation**: https://bun.sh/docs - Consult these docs when unsure about Bun APIs. - -### Quick Bun API Reference - -| Task | Use This | NOT This | -|------|----------|----------| -| Read file | `await Bun.file(path).text()` | `fs.readFileSync()` | -| Write file | `await Bun.write(path, content)` | `fs.writeFileSync()` | -| Check file exists | `await Bun.file(path).exists()` | `fs.existsSync()` | -| Spawn process | `Bun.spawn()` | `child_process.spawn()` | -| Shell commands | `Bun.$\`command\`` ⚠️ | `child_process.exec()` | -| Find executable | `Bun.which("git")` | `which` package | -| Glob patterns | `new Bun.Glob()` | `glob` / `fast-glob` packages | -| Sleep | `await Bun.sleep(ms)` | `setTimeout` with Promise | -| Parse JSON file | `await Bun.file(path).json()` | Read + JSON.parse | - -**Exception**: Use `node:fs` for directory creation with permissions: -```typescript -import { mkdirSync } from "node:fs"; -mkdirSync(dir, { recursive: true, mode: 0o700 }); -``` - -**Exception**: `Bun.$` (shell tagged template) has no shim in `script/node-polyfills.ts` and will crash on the npm/node distribution. Until a shim is added, use `execSync` from `node:child_process` for shell commands that must work in both runtimes: -```typescript -import { execSync } from "node:child_process"; -const result = execSync("id -u username", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }); -``` - -## Architecture - -The full project-structure tree — including the live command/subcommand list and the -domain API modules — is generated from the route tree and lives in -[`docs/src/content/docs/contributing.md`](docs/src/content/docs/contributing.md) -(the `project-structure` block produced by `script/generate-docs-sections.ts`). It is -kept in sync automatically, so it is **not** duplicated here to avoid drift. For the -current command list run `ls src/commands/` or `sentry --help`. - -Top-level layout: - -- **`src/bin.ts`** — entry point; **`src/app.ts`** — Stricli application setup; - **`src/context.ts`** — dependency-injection context. -- **`src/commands/`** — one directory per command group (`auth`, `cli`, `dashboard`, - `event`, `issue`, `log`, `org`, `project`, `release`, `replay`, `repo`, `sourcemap`, - `span`, `team`, `trace`, `trial`, `local`, …) plus standalone command files - (`api.ts`, `explore.ts`, `help.ts`, `init.ts`, `schema.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. -- **`src/types/`** — TypeScript types and Zod schemas. -- **`test/`** — tests mirroring `src/` (unit, `*.property.test.ts`, - `*.model-based.test.ts`, `e2e/`, `fixtures/`, `mocks/`). -- **`docs/`** — documentation site (Astro + Starlight); **`script/`** — build/utility - scripts; **`.cursor/rules/`** — Cursor AI rules; **`biome.jsonc`** — lint config. - -## Key Patterns - -### CLI Commands (Stricli) - -Commands use [Stricli](https://bloomberg.github.io/stricli/docs/getting-started/principles) wrapped by `src/lib/command.ts`. - -**CRITICAL**: Import `buildCommand` from `../../lib/command.js`, **NEVER** from `@stricli/core` directly — the wrapper adds telemetry, `--json`/`--fields` injection, and output rendering. - -Pattern: - -```typescript -import { buildCommand } from "../../lib/command.js"; -import type { SentryContext } from "../../context.js"; -import { CommandOutput } from "../../lib/formatters/output.js"; - -export const myCommand = buildCommand({ - docs: { - brief: "Short description", - fullDescription: "Detailed description", - }, - output: { - human: formatMyData, // (data: T) => string - jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => unknown - jsonExclude: ["humanOnlyField"], // optional: strip keys from JSON - }, - parameters: { - flags: { - limit: { kind: "parsed", parse: Number, brief: "Max items", default: 10 }, - }, - }, - async *func(this: SentryContext, flags) { - const data = await fetchData(); - yield new CommandOutput(data); - return { hint: "Tip: use --json for machine-readable output" }; - }, -}); -``` - -**Key rules:** -- Functions are `async *func()` generators — yield `new CommandOutput(data)`, return `{ hint }`. -- `output.human` receives the same data object that gets serialized to JSON — no divergent-data paths. -- The wrapper auto-injects `--json` and `--fields` flags. Do NOT add your own `json` flag. -- Do NOT use `stdout.write()` or `if (flags.json)` branching — the wrapper handles it. - -### Command File Structure - -Command files in `src/commands/` should focus on three concerns: -1. **Argument parsing** — positional args, flags, URL detection -2. **API orchestration** — fetching data, error handling, enrichment -3. **Output dispatch** — `yield new CommandOutput(data)` - -Formatting and rendering logic belongs in `src/lib/formatters/.ts`. If a command file exceeds ~400 lines, extract formatting helpers into a dedicated formatter module. - -Reference: `src/lib/formatters/replay.ts` (extracted from `replay/view.ts`), `src/lib/formatters/trace.ts`, `src/lib/formatters/human.ts`. - -Lint enforcement: `stderr.write()` is banned in command files (GritQL rule). Use `logger` for diagnostics and `CommandOutput` for data output. - -### Route Maps (Stricli) - -Route groups use Stricli's `buildRouteMap` wrapped by `src/lib/route-map.ts`. - -**CRITICAL**: Import `buildRouteMap` from `../../lib/route-map.js`, **NEVER** from `@stricli/core` directly — the wrapper auto-injects standard subcommand aliases based on which route keys exist: - -| Route | Auto-aliases | -|----------|----------------| -| `list` | `ls` | -| `view` | `show` | -| `delete` | `remove`, `rm` | -| `create` | `new` | - -Manually specified aliases in `aliases` are merged with (and take precedence over) auto-generated ones. Do NOT manually add aliases that are already in the standard set above. - -```typescript -import { buildRouteMap } from "../../lib/route-map.js"; - -export const myRoute = buildRouteMap({ - routes: { - list: listCommand, - view: viewCommand, - create: createCommand, - }, - defaultCommand: "view", - // No need for aliases — ls, show, and new are auto-injected. - // Only add aliases for non-standard mappings: - // aliases: { custom: "list" }, - docs: { - brief: "Manage my resources", - }, -}); -``` - -### Positional Arguments - -Use `parseSlashSeparatedArg` from `src/lib/arg-parsing.ts` for the standard `[//]` pattern. Required identifiers (trace IDs, span IDs) should be **positional args**, not flags. - -```typescript -import { parseSlashSeparatedArg, parseOrgProjectArg } from "../../lib/arg-parsing.js"; - -// "my-org/my-project/abc123" → { id: "abc123", targetArg: "my-org/my-project" } -const { id, targetArg } = parseSlashSeparatedArg(first, "Trace ID", USAGE_HINT); -const parsed = parseOrgProjectArg(targetArg); -// parsed.type: "auto-detect" | "explicit" | "project-search" | "org-all" -``` - -Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts` - -### Markdown Rendering - -All non-trivial human output must use the markdown rendering pipeline: - -- Build markdown strings with helpers: `mdKvTable()`, `colorTag()`, `escapeMarkdownCell()`, `renderMarkdown()` -- **NEVER** use raw `muted()` / chalk in output strings — use `colorTag("muted", text)` inside markdown -- Tree-structured output (box-drawing characters) that can't go through `renderMarkdown()` should use the `plainSafeMuted` pattern: `isPlainOutput() ? text : muted(text)` -- `isPlainOutput()` precedence: `SENTRY_PLAIN_OUTPUT` > `NO_COLOR` > `FORCE_COLOR` (TTY only) > `!isTTY` -- `isPlainOutput()` lives in `src/lib/formatters/plain-detect.ts` (re-exported from `markdown.ts` for compat) - -Reference: `formatters/trace.ts` (`formatAncestorChain`), `formatters/human.ts` (`plainSafeMuted`) - -### Create & Delete Command Standards - -Mutation (create/delete) commands use shared infrastructure from `src/lib/mutate-command.ts`, -paralleling `list-command.ts` for list commands. - -**Delete commands** MUST use `buildDeleteCommand()` instead of `buildCommand()`. It: -1. Auto-injects `--yes`, `--force`, `--dry-run` flags with `-y`, `-f`, `-n` aliases -2. Runs a non-interactive safety guard before `func()` — refuses to proceed if - stdin is not a TTY and `--yes`/`--force` was not passed (dry-run bypasses) -3. Options to skip specific injections (`noForceFlag`, `noDryRunFlag`, `noNonInteractiveGuard`) - -```typescript -import { buildDeleteCommand, confirmByTyping, isConfirmationBypassed, requireExplicitTarget } from "../../lib/mutate-command.js"; - -export const deleteCommand = buildDeleteCommand({ - // Same args as buildCommand — flags/aliases auto-injected - async *func(this: SentryContext, flags, target) { - requireExplicitTarget(parsed, "Entity", "sentry entity delete "); - if (flags["dry-run"]) { yield preview; return; } - if (!isConfirmationBypassed(flags)) { - if (!await confirmByTyping(expected, promptMessage)) return; - } - await doDelete(); - }, -}); -``` - -**Create commands** import `DRY_RUN_FLAG` and `DRY_RUN_ALIASES` for consistent dry-run support: - -```typescript -import { DRY_RUN_FLAG, DRY_RUN_ALIASES } from "../../lib/mutate-command.js"; - -// In parameters: -flags: { "dry-run": DRY_RUN_FLAG, team: { ... } }, -aliases: { ...DRY_RUN_ALIASES, t: "team" }, -``` - -**Key utilities** in `mutate-command.ts`: -- `isConfirmationBypassed(flags)` — true if `--yes` or `--force` is set -- `guardNonInteractive(flags)` — throws in non-interactive mode without `--yes` -- `confirmByTyping(expected, message)` — type-out confirmation prompt -- `requireExplicitTarget(parsed, entityType, usage)` — blocks auto-detect for safety -- `DESTRUCTIVE_FLAGS` / `DESTRUCTIVE_ALIASES` — spreadable bundles for manual use - -### List Command Pagination - -All list commands with API pagination MUST use the shared cursor-stack -infrastructure for **bidirectional** pagination (`-c next` / `-c prev`): - -```typescript -import { LIST_CURSOR_FLAG } from "../../lib/list-command.js"; -import { - buildPaginationContextKey, resolveCursor, - advancePaginationState, hasPreviousPage, -} from "../../lib/db/pagination.js"; - -export const PAGINATION_KEY = "my-entity-list"; - -// In buildCommand: -flags: { cursor: LIST_CURSOR_FLAG }, -aliases: { c: "cursor" }, - -// In func(): -const contextKey = buildPaginationContextKey("entity", `${org}/${project}`, { - sort: flags.sort, q: flags.query, -}); -const { cursor, direction } = resolveCursor(flags.cursor, PAGINATION_KEY, contextKey); -const { data, nextCursor } = await listEntities(org, project, { cursor, ... }); -advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); -const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); -const hasMore = !!nextCursor; -``` - -**Cursor stack model:** The DB stores a JSON array of page-start cursors -plus a page index. Each entry is an opaque string — plain API cursors, -compound cursors (issue list), or extended cursors with mid-page bookmarks -(dashboard list). `-c next` increments the index, `-c prev` decrements it, -`-c first` resets to 0. The stack truncates on back-then-forward to avoid -stale entries. `"last"` is a silent alias for `"next"`. - -**Hint rules:** Show `-c prev` when `hasPreviousPage()` returns true. -Show `-c next` when `hasMore` is true. Include both `nextCursor` and -`hasPrev` in the JSON envelope. - -**Navigation hint generation:** Use `paginationHint()` from -`src/lib/list-command.ts` to build bidirectional navigation strings. -Pass it pre-built `prevHint`/`nextHint` command strings and it returns -the combined `"Prev: X | Next: Y"` string (or single-direction, or `""`). -Do NOT assemble `navParts` arrays manually — the shared helper ensures -consistent formatting across all list commands. - -```typescript -import { paginationHint } from "../../lib/list-command.js"; - -const nav = paginationHint({ - hasPrev, - hasMore, - prevHint: `sentry entity list ${org}/ -c prev`, - nextHint: `sentry entity list ${org}/ -c next`, -}); -if (items.length === 0 && nav) { - hint = `No entities on this page. ${nav}`; -} else if (hasMore) { - header = `Showing ${items.length} entities (more available)\n${nav}`; -} else if (nav) { - header = `Showing ${items.length} entities\n${nav}`; -} -``` - -**Three abstraction levels for list commands** (prefer the highest level -that fits your use case): - -1. **`buildOrgListCommand`** (team/repo list) — Fully automatic. Pagination - hints, cursor management, JSON envelope, and human formatting are all - handled internally. New simple org-scoped list commands should use this. - -2. **`dispatchOrgScopedList` with overrides** (project/issue list) — Automatic - for most modes; custom `"org-all"` override calls `resolveCursor` + - `advancePaginationState` + `paginationHint` manually. - -3. **`buildListCommand` with manual pagination** (trace/span/dashboard list) — - Command manages its own pagination loop. Must call `resolveCursor`, - `advancePaginationState`, `hasPreviousPage`, and `paginationHint` directly. - -**Auto-pagination for large limits:** - -When `--limit` exceeds `API_MAX_PER_PAGE` (100), list commands MUST transparently -fetch multiple pages to fill the requested limit. Cap `perPage` at -`Math.min(flags.limit, API_MAX_PER_PAGE)` and loop until `results.length >= limit` -or pages are exhausted. This matches the `listIssuesAllPages` pattern. - -```typescript -const perPage = Math.min(flags.limit, API_MAX_PER_PAGE); -for (let page = 0; page < MAX_PAGINATION_PAGES; page++) { - const { data, nextCursor } = await listPaginated(org, { perPage, cursor }); - results.push(...data); - if (results.length >= flags.limit || !nextCursor) break; - cursor = nextCursor; -} -``` - -Never pass a `per_page` value larger than `API_MAX_PER_PAGE` to the API — the -server silently caps it, causing the command to return fewer items than requested. - -Reference template: `trace/list.ts`, `span/list.ts`, `dashboard/list.ts` - -### ID Validation - -Use shared validators from `src/lib/hex-id.ts`: -- `validateHexId(value, label)` — 32-char hex IDs (trace IDs, log IDs). Auto-strips UUID dashes. -- `validateSpanId(value)` — 16-char hex span IDs. Auto-strips dashes. -- `validateTraceId(value)` — thin wrapper around `validateHexId` in `src/lib/trace-id.ts`. - -All normalize to lowercase. Throw `ValidationError` on invalid input. - -### Sort Convention - -Use `"date"` for timestamp-based sort (not `"time"`). Export sort types from the API layer (e.g., `SpanSortValue` from `api/traces.ts`), import in commands. This matches `issue list`, `trace list`, and `span list`. - -### Generated Docs & Skills - -All command docs and skill files are generated via `bun run generate:docs` (which runs `generate:command-docs` then `generate:skill`). This runs automatically as part of `dev`, `build`, `typecheck`, and `test` scripts. - -- **Command docs** (`docs/src/content/docs/commands/*.md`) are **gitignored** and generated from CLI metadata + hand-written fragments in `docs/src/fragments/commands/`. -- **Skill files** (`plugins/sentry-cli/skills/sentry-cli/`) are **committed** (consumed by external plugin systems) and auto-committed by CI when stale. -- Edit fragments in `docs/src/fragments/commands/` for custom examples and guides. -- `bun run check:fragments` validates fragment ↔ route consistency. -- Positional `placeholder` values must be descriptive: `"org/project/trace-id"` not `"args"`. - -### Zod Schemas for Validation - -All config and API types use Zod schemas: - -```typescript -import { z } from "zod"; - -export const MySchema = z.object({ - field: z.string(), - optional: z.number().optional(), -}); - -export type MyType = z.infer; - -// Validate data -const result = MySchema.safeParse(data); -if (result.success) { - // result.data is typed -} -``` - -### Type Organization - -- Define Zod schemas alongside types in `src/types/*.ts` -- Key type files: `sentry.ts` (API types), `config.ts` (configuration), `oauth.ts` (auth flow), `seer.ts` (Seer AI) -- Re-export from `src/types/index.ts` -- Use `type` imports: `import type { MyType } from "../types/index.js"` - -### SQL Utilities - -Use the `upsert()` helper from `src/lib/db/utils.ts` to reduce SQL boilerplate: - -```typescript -import { upsert, runUpsert } from "../db/utils.js"; - -// Generate UPSERT statement -const { sql, values } = upsert("table", { id: 1, name: "foo" }, ["id"]); -db.query(sql).run(...values); - -// Or use convenience wrapper -runUpsert(db, "table", { id: 1, name: "foo" }, ["id"]); - -// Exclude columns from update -const { sql, values } = upsert( - "users", - { id: 1, name: "Bob", created_at: now }, - ["id"], - { excludeFromUpdate: ["created_at"] } -); -``` - -### Error Handling - -All CLI errors extend the `CliError` base class from `src/lib/errors.ts`: - -```typescript -// Error hierarchy in src/lib/errors.ts -// Exit codes are defined in the EXIT constant object — use EXIT.* constants -// when constructing errors, never hardcode numeric exit codes outside errors.ts. -CliError (base, exitCode=1) -├── HostScopeError (exitCode=13) -├── ApiError (exitCode=30 — HTTP/API failures) -├── AuthError (exitCode=10–12 by reason — 'not_authenticated' | 'expired' | 'invalid') -├── ConfigError (exitCode=20 — configuration/DSN) -├── OutputError (exitCode=60 — data rendered, but operation failed) -├── ContextError (exitCode=22 — missing context) -├── ResolutionError (exitCode=23 — value provided but not found) -├── ValidationError (exitCode=21 — input validation) -├── DeviceFlowError (exitCode=51 — OAuth flow) -├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled') -├── TimeoutError (exitCode=31 — operation timed out) -├── UpgradeError (exitCode=50 — upgrade failures) -└── WizardError (exitCode=61–64 by workflow step — init wizard error) -``` - -> Exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, -> 5x=operations, 6x=command-specific. See `EXIT` in `src/lib/errors.ts` and -> https://cli.sentry.dev/exit-codes/ for the full reference. - -**Choosing between ContextError, ResolutionError, and ValidationError:** - -| Scenario | Error Class | Example | -|----------|-------------|---------| -| User **omitted** a required value | `ContextError` | No org/project provided | -| User **provided** a value that wasn't found | `ResolutionError` | Project 'cli' not found | -| User input is **malformed** | `ValidationError` | Invalid hex ID format | - -**ContextError rules:** -- `command` must be a **single-line** CLI usage example (e.g., `"sentry org view "`) -- Constructor throws if `command` contains `\n` (catches misuse in tests) -- Pass `alternatives: []` when defaults are irrelevant (e.g., for missing Trace ID, Event ID) -- Use `" and "` in `resource` for plural grammar: `"Trace ID and span ID"` → "are required" - -**CI enforcement:** `bun run check:errors` scans for `ContextError` with multiline commands, `CliError` with ad-hoc "Try:" strings, and silent `catch` blocks (advisory). - -```typescript -// Usage examples -throw new ContextError("Organization", "sentry org view "); -throw new ContextError("Trace ID", "sentry trace view ", []); // no alternatives -throw new ResolutionError("Project 'cli'", "not found", "sentry issue list /cli", [ - "No project with this slug found in any accessible organization", -]); -throw new ValidationError("Invalid trace ID format", "traceId"); -``` - -**Fuzzy suggestions in resolution errors:** - -When a user-provided name/title doesn't match any entity, use `fuzzyMatch()` from -`src/lib/fuzzy.ts` to suggest similar candidates instead of listing all entities -(which can be overwhelming). Show at most 5 fuzzy matches. - -Reference: `resolveDashboardId()` in `src/commands/dashboard/resolve.ts`. - -### Catch Block Logging - -Silent `catch` blocks are prohibited in `src/` production code. Biome's `noEmptyBlockStatements` catches syntactically empty `catch {}` blocks, but blocks with only a `return` statement and no logging are equally problematic — errors vanish silently, making debugging impossible. - -Every `catch` block must either: -1. Re-throw the error -2. Log with `log.debug()` or `log.warn()` for diagnostic visibility -3. Return a fallback value **with** a `log.debug()` call explaining the suppression - -```typescript -// WRONG — error vanishes silently -try { data = await fetchOptionalData(); } -catch { return []; } - -// RIGHT — error is visible in debug logs -try { data = await fetchOptionalData(); } -catch (error) { - log.debug("Failed to fetch optional data", error); - return []; -} -``` - -Use `logger.withTag("command-name")` for tagged logging in command files. - -**CI enforcement:** `bun run check:errors` includes a silent-catch scan that flags -`catch` blocks which are empty, comment-only, or return-only without surfacing the -error. It is currently **advisory** (warns, does not fail CI) because of a pre-existing -backlog; run with `SENTRY_STRICT_SILENT_CATCH=1` to enforce. Do not add new silent -catches — they will appear in the scan output during review. - -### Auto-Recovery for Wrong Entity Types - -When a user provides the wrong type of identifier (e.g., an issue short ID -where a trace ID is expected), commands should **auto-recover** when the -user's intent is unambiguous: - -1. **Detect** the actual entity type using helpers like `looksLikeIssueShortId()`, - `SPAN_ID_RE`, `HEX_ID_RE`, or non-hex character checks. -2. **Resolve** the input to the correct type (e.g., issue → latest event → trace ID). -3. **Warn** via `log.warn()` explaining what happened. -4. **Show** the result with a return `hint` nudging toward the correct command. - -When recovery is **ambiguous or impossible**, keep the existing error but add -entity-aware suggestions (e.g., "This looks like a span ID"). - -**Detection helpers:** -- `looksLikeIssueShortId(value)` — uppercase dash-separated (e.g., `CLI-G5`) -- `SPAN_ID_RE.test(value)` — 16-char hex (span ID) -- `HEX_ID_RE.test(value)` — 32-char hex (trace/event/log ID) -- `/[^0-9a-f]/.test(normalized)` — non-hex characters → likely a slug/name - -**Reference implementations:** -- `event/view.ts` — issue short ID → latest event redirect -- `span/view.ts` — `traceId/spanId` slash format → auto-split -- `trace/view.ts` — issue short ID → issue's trace redirect -- `hex-id.ts` — entity-aware error hints in `validateHexId`/`validateSpanId` - -### Async Config Functions - -All config operations are async. Always await: - -```typescript -const token = await getAuthToken(); -const isAuth = await isAuthenticated(); -await setAuthToken(token, expiresIn); -``` - -### Adding New Utility Files - -Before creating a new `src/lib/*.ts` utility file, check whether existing shared modules already cover your use case: - -| If you need... | Check first... | -|----------------|---------------| -| Duration formatting | `src/lib/formatters/time-utils.ts` (`formatDurationCompact`, `formatDurationVerbose`) | -| Hex ID validation/normalization | `src/lib/hex-id.ts` (`validateHexId`, `tryNormalizeHexId`, `normalizeHexId`) | -| Relative time display | `src/lib/formatters/time-utils.ts` (`formatRelativeTime`) | -| Table/markdown output | `src/lib/formatters/` directory | -| Pagination | `src/lib/db/pagination.ts`, `src/lib/list-command.ts` | -| Error classes | `src/lib/errors.ts` (never create ad-hoc error types) | -| Search query building | `src/lib/search-query.ts`, `src/lib/arg-parsing.ts` | - -If an existing module covers ≥80% of what you need, extend it with new exported functions rather than creating a new file. New files are appropriate when the domain is genuinely new (e.g., `replay-search.ts` for replay-specific field resolution). - -Every new `src/lib/**/*.ts` file must start with a module-level JSDoc comment describing the module's purpose. - -### Imports - -- Use `.js` extension for local imports (ESM requirement) -- Group: external packages first, then local imports -- Use `type` keyword for type-only imports - -```typescript -import { z } from "zod"; -import { buildCommand } from "../../lib/command.js"; -import type { SentryContext } from "../../context.js"; -import { getAuthToken } from "../../lib/config.js"; -``` - -### List Command Infrastructure - -Two abstraction levels exist for list commands: - -1. **`src/lib/list-command.ts`** — `buildOrgListCommand` factory + shared Stricli parameter constants (`LIST_TARGET_POSITIONAL`, `LIST_JSON_FLAG`, `LIST_CURSOR_FLAG`, `buildListLimitFlag`). Use this for simple entity lists like `team list` and `repo list`. - -2. **`src/lib/org-list.ts`** — `dispatchOrgScopedList` with `OrgListConfig` and a 4-mode handler map: `auto-detect`, `explicit`, `org-all`, `project-search`. Complex commands (`project list`, `issue list`) call `dispatchOrgScopedList` with an `overrides` map directly instead of using `buildOrgListCommand`. - -Key rules when writing overrides: -- Each mode handler receives a `HandlerContext` with the narrowed `parsed` plus shared I/O (`stdout`, `cwd`, `flags`). Access parsed fields via `ctx.parsed.org`, `ctx.parsed.projectSlug`, etc. — no manual `Extract<>` casts needed. -- Commands with extra fields (e.g., `stderr`, `setContext`) spread the context and add them: `(ctx) => handle({ ...ctx, flags, stderr, setContext })`. Override `ctx.flags` with the command-specific flags type when needed. -- `resolveCursor()` must be called **inside** the `org-all` override closure, not before `dispatchOrgScopedList`, so that `--cursor` validation errors fire correctly for non-org-all modes. -- `handleProjectSearch` errors must use `"Project"` as the `ContextError` resource, not `config.entityName`. -- Always set `orgSlugMatchBehavior` on `dispatchOrgScopedList` to declare how bare-slug org matches are handled. Use `"redirect"` for commands where listing all entities in the org makes sense (e.g., `project list`, `team list`, `issue list`). Use `"error"` for commands where org-all redirect is inappropriate. The pre-check uses cached orgs to avoid N API calls — when the cache is cold, the handler's own org-slug check serves as a safety net (throws `ResolutionError` with a hint). - -3. **Standalone list commands** (e.g., `span list`, `trace list`) that don't use org-scoped dispatch wire pagination directly in `func()`. See the "List Command Pagination" section above for the pattern. - -### Project Filtering in API Calls - -Different Sentry API endpoints use different project filtering mechanisms. Never apply both simultaneously: - -| API Endpoint | Project filter | Helper | -|-------------|---------------|--------| -| Discover/Events (`queryEvents`) | `project:` in query string | `buildProjectQuery()` | -| Replay index (`listReplays`) | `projectSlugs` parameter | Direct parameter | -| Issue index (`listIssuesPaginated`) | `project` parameter or query string | Varies by mode | - -When adding a new dataset to `explore`, verify which filtering mechanism the underlying API expects and handle it in `resolveDatasetConfig`. The `explore` command centralizes dataset-specific behavior (sort, query, fetch, field validation) in `resolveDatasetConfig` — add new datasets there rather than scattering `if (dataset === ...)` checks through the `func` body. - -## Commenting & Documentation (JSDoc-first) - -### Default Rule -- **Prefer JSDoc over inline comments.** -- Code should be readable without narrating what it already says. - -### Required: JSDoc -Add JSDoc comments on: -- **Every exported function, class, and type** (and important internal ones). -- **Types/interfaces**: document each field/property (what it represents, units, allowed values, meaning of `null`, defaults). - -Include in JSDoc: -- What it does -- Key business rules / constraints -- Assumptions and edge cases -- Side effects -- Why it exists (when non-obvious) - -### Inline Comments (rare) -Inline comments are **allowed only** when they add information the code cannot express: -- **"Why"** - business reason, constraint, historical context -- **Non-obvious behavior** - surprising edge cases -- **Workarounds** - bugs in dependencies, platform quirks -- **Hardcoded values** - why hardcoded, what would break if changed - -Inline comments are **NOT allowed** if they just restate the code: -```typescript -// Bad: -if (!person) // if no person -i++ // increment i -return result // return result - -// Good: -// Required by GDPR Article 17 - user requested deletion -await deleteUserData(userId) -``` - -### Prohibited Comment Styles -- **ASCII art section dividers** - Do not use decorative box-drawing characters like `─────────` to create section headers. Use standard JSDoc comments or simple `// Section Name` comments instead. - -### Goal -Minimal comments, maximum clarity. Comments explain **intent and reasoning**, not syntax. - -## Testing (bun:test + fast-check) - -**Prefer property-based and model-based testing** over traditional unit tests. These approaches find edge cases automatically and provide better coverage with less code. - -**fast-check Documentation**: https://fast-check.dev/docs/core-blocks/arbitraries/ - -### Testing Hierarchy (in order of preference) - -1. **Model-Based Tests** - For stateful systems (database, caches, state machines) -2. **Property-Based Tests** - For pure functions, parsing, validation, transformations -3. **Unit Tests** - Only for trivial cases or when properties are hard to express - -### Test File Naming - -| Type | Pattern | Location | -|------|---------|----------| -| Property-based | `*.property.test.ts` | `test/lib/` | -| Model-based | `*.model-based.test.ts` | `test/lib/db/` | -| Unit tests | `*.test.ts` | `test/` (mirrors `src/`) | -| E2E tests | `*.test.ts` | `test/e2e/` | - -### Test Environment Isolation (CRITICAL) - -Tests that need a database or config directory **must** use `useTestConfigDir()` from `test/helpers.ts`. This helper: -- Creates a unique temp directory in `beforeEach` -- Sets `SENTRY_CONFIG_DIR` to point at it -- **Restores** (never deletes) the env var in `afterEach` -- Closes the database and cleans up temp files - -**NEVER** do any of these in test files: -- `delete process.env.SENTRY_CONFIG_DIR` — This pollutes other test files that load after yours -- `const baseDir = process.env[CONFIG_DIR_ENV_VAR]!` at module scope — This captures a value that may be stale -- Manual `beforeEach`/`afterEach` that sets/deletes `SENTRY_CONFIG_DIR` - -**Why**: Bun's test runner uses `--isolate --parallel` (see `test:unit` in `package.json`), so each test file runs in a fresh global environment within a worker process. That bounds most cross-file leaks to a single worker, but `process.env` is still shared within a file's lifecycle — if your `afterEach` deletes the env var, the next describe/test's module-level code (or a beforeEach that re-reads env) gets `undefined`, causing `TypeError: The "paths[0]" property must be of type string`. Also, `TEST_TMP_DIR` is namespaced by `BUN_TEST_WORKER_ID` in `test/constants.ts` so parallel workers don't wipe each other's temp state during preload. - -```typescript -// CORRECT: Use the helper -import { useTestConfigDir } from "../helpers.js"; - -const getConfigDir = useTestConfigDir("my-test-prefix-"); - -// If you need the directory path in a test: -test("example", () => { - const dir = getConfigDir(); -}); - -// WRONG: Manual env var management -beforeEach(() => { process.env.SENTRY_CONFIG_DIR = tmpDir; }); -afterEach(() => { delete process.env.SENTRY_CONFIG_DIR; }); // BUG! -``` - -### Property-Based Testing - -Use property-based tests when verifying invariants that should hold for **any valid input**. - -```typescript -import { describe, expect, test } from "bun:test"; -import { constantFrom, assert as fcAssert, property, tuple } from "fast-check"; -import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; - -// Define arbitraries (random data generators) -const slugArb = array(constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), { - minLength: 1, - maxLength: 15, -}).map((chars) => chars.join("")); - -describe("property: myFunction", () => { - test("is symmetric", () => { - fcAssert( - property(slugArb, slugArb, (a, b) => { - // Properties should always hold regardless of input - expect(myFunction(a, b)).toBe(myFunction(b, a)); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); - - test("round-trip: encode then decode returns original", () => { - fcAssert( - property(validInputArb, (input) => { - const encoded = encode(input); - const decoded = decode(encoded); - expect(decoded).toEqual(input); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); -}); -``` - -**Good candidates for property-based testing:** -- Parsing functions (DSN, issue IDs, aliases) -- Encoding/decoding (round-trip invariant) -- Symmetric operations (a op b = b op a) -- Idempotent operations (f(f(x)) = f(x)) -- Validation functions (valid inputs accepted, invalid rejected) - -**See examples:** `test/lib/dsn.property.test.ts`, `test/lib/alias.property.test.ts`, `test/lib/issue-id.property.test.ts` - -### Model-Based Testing - -Use model-based tests for **stateful systems** where sequences of operations should maintain invariants. - -```typescript -import { describe, expect, test } from "bun:test"; -import { - type AsyncCommand, - asyncModelRun, - asyncProperty, - commands, - assert as fcAssert, -} from "fast-check"; -import { createIsolatedDbContext, DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; - -// Define a simplified model of expected state -type DbModel = { - entries: Map; -}; - -// Define commands that operate on both model and real system -class SetCommand implements AsyncCommand { - constructor(readonly key: string, readonly value: string) {} - - check = () => true; - - async run(model: DbModel, real: RealDb): Promise { - // Apply to real system - await realSet(this.key, this.value); - - // Update model - model.entries.set(this.key, this.value); - } - - toString = () => `set("${this.key}", "${this.value}")`; -} - -class GetCommand implements AsyncCommand { - constructor(readonly key: string) {} - - check = () => true; - - async run(model: DbModel, real: RealDb): Promise { - const realValue = await realGet(this.key); - const expectedValue = model.entries.get(this.key); - - // Verify real system matches model - expect(realValue).toBe(expectedValue); - } - - toString = () => `get("${this.key}")`; -} - -describe("model-based: database", () => { - test("random sequences maintain consistency", () => { - fcAssert( - asyncProperty(commands(allCommandArbs), async (cmds) => { - const cleanup = createIsolatedDbContext(); - try { - await asyncModelRun( - () => ({ model: { entries: new Map() }, real: {} }), - cmds - ); - } finally { - cleanup(); - } - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); -}); -``` - -**Good candidates for model-based testing:** -- Database operations (auth, caches, regions) -- Stateful caches with invalidation -- Systems with cross-cutting invariants (e.g., clearAuth also clears regions) - -**See examples:** `test/lib/db/model-based.test.ts`, `test/lib/db/dsn-cache.model-based.test.ts` - -### Test Helpers - -Use `test/model-based/helpers.ts` for shared utilities: - -```typescript -import { createIsolatedDbContext, DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; - -// Create isolated DB for each test run (prevents interference) -const cleanup = createIsolatedDbContext(); -try { - // ... test code -} finally { - cleanup(); -} - -// Use consistent number of runs across tests -fcAssert(property(...), { numRuns: DEFAULT_NUM_RUNS }); // 50 runs -``` - -### When to Use Unit Tests - -Use traditional unit tests only when: -- Testing trivial logic with obvious expected values -- Properties are difficult to express or would be tautological -- Testing error messages or specific output formatting -- Integration with external systems (E2E tests) - -### Avoiding Unit/Property Test Duplication - -When a `*.property.test.ts` file exists for a module, **do not add unit tests that re-check the same invariants** with hardcoded examples. Before adding a unit test, check whether the companion property file already generates random inputs for that invariant. - -**Unit tests that belong alongside property tests:** -- Edge cases outside the property generator's range (e.g., self-hosted DSNs when the arbitrary only produces SaaS ones) -- Specific output format documentation (exact strings, column layouts, rendered vs plain mode) -- Concurrency/timing behavior that property tests cannot express -- Integration tests exercising multiple functions together (e.g., `writeJsonList` envelope shape) - -**Unit tests to avoid when property tests exist:** -- "returns true for valid input" / "returns false for invalid input" — the property test already covers this with random inputs -- Basic round-trip assertions — property tests check `decode(encode(x)) === x` for all `x` -- Hardcoded examples of invariants like idempotency, symmetry, or subset relationships - -When adding property tests for a function that already has unit tests, **remove the unit tests that become redundant**. Add a header comment to the unit test file noting which invariants live in the property file: - -```typescript -/** - * Note: Core invariants (round-trips, validation, ordering) are tested via - * property-based tests in foo.property.test.ts. These tests focus on edge - * cases and specific output formatting not covered by property generators. - */ -``` - -```typescript -import { describe, expect, test, mock } from "bun:test"; - -describe("feature", () => { - test("should return specific value", async () => { - expect(await someFunction("input")).toBe("expected output"); - }); -}); - -// Mock modules when needed -mock.module("./some-module", () => ({ - default: () => "mocked", -})); -``` - -## File Locations - -| What | Where | -|------|-------| -| Add new command | `src/commands//` | -| Add API types | `src/types/sentry.ts` | -| 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 DB operations | `src/lib/db/` | -| Build scripts | `script/` | -| Add property tests | `test/lib/.property.test.ts` | -| Add model-based tests | `test/lib/db/.model-based.test.ts` | -| Add unit tests | `test/` (mirror `src/` structure) | -| Add E2E tests | `test/e2e/` | -| Test helpers | `test/model-based/helpers.ts` | -| Add documentation | `docs/src/content/docs/` | -| Hand-written command doc content | `docs/src/fragments/commands/` | - -## Automated Fix PRs (BugBot / agents) - -Automated bug-fix PRs (e.g. Cursor BugBot) must follow these rules to avoid the -duplication and staleness that caused five overlapping PRs to pile up: - -1. **Check for existing work first.** Before opening a PR, search open PRs and - recently-closed PRs/issues for the same file + symbol: - ```bash - gh pr list --state open --search "in:title " - gh issue list --state all --search "" - ``` - If an open PR already touches the target function, **comment on it** or extend - it instead of opening a duplicate. Multiple BugBot PRs independently re-fixed - the same `JSON.parse` guard, `withTTY` helper, and pagination code. - -2. **Rebase before review.** A PR that is many commits behind `main` may fail CI - on unrelated drift (e.g. a lint error in a file the PR never touched) and its - fix may already be superseded. Rebase onto `main` and re-verify the bug still - exists before requesting review. Verify against current `main`, not the - snapshot the PR was generated from. - -3. **Separate correctness fixes from opinion.** A real bug (wrong output, crash, - skipped data) is in scope. A subjective UX change (different hint wording, - different default) is **not** a bug — `main`'s current behavior is often - deliberate. Do not bundle UX opinions into bug-fix PRs; they waste review - cycles and are usually dropped. - -4. **Prefer shared helpers over re-deriving fixes.** If a correct implementation - already exists (e.g. `autoPaginate()` for pagination, `safeParseJson()` for - cached JSON), use it rather than hand-rolling a one-off fix. The recurring - pagination-overshoot and parse-crash bugs were classes solved once centrally. - ## Long-term Knowledge diff --git a/README.md b/README.md index c4c800ac6c..a201255c5b 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,27 @@ -

- Sentry CLI -

+# Sentry Toolkit (pre-shape) -

- The command-line interface for Sentry. Built for developers and AI agents. -

+This repository is being reshaped into a pnpm-workspace monorepo ahead of merging +the Sentry CLI and Sentry MCP server into a single `getsentry/toolkit` monorepo. -

- Documentation | - Getting Started | - Commands -

+## Workspace layout ---- - -## Installation - -### Install Script (Recommended) - -```bash -curl https://cli.sentry.dev/install -fsS | bash -``` - -### Homebrew - -```bash -brew install getsentry/tools/sentry -``` - -### Package Managers - -```bash -npm install -g sentry -pnpm add -g sentry -yarn global add sentry -bun add -g sentry -``` - -> The npm/pnpm/yarn packages require Node.js 18+. On Node.js 22.15+ the CLI uses the built-in `node:sqlite`; on Node.js 18–22.14 it transparently falls back to a bundled WASM SQLite driver. - -### Run Without Installing - -```bash -npx sentry@latest -pnpm dlx sentry --help -yarn dlx sentry --help -bunx sentry --help -``` - -## Quick Start - -```bash -# Authenticate with Sentry -sentry auth login - -# List issues (auto-detects project from your codebase) -sentry issue list - -# Get AI-powered root cause analysis -sentry issue explain PROJ-ABC - -# Generate a fix plan -sentry issue plan PROJ-ABC -``` - -## Features - -- **DSN Auto-Detection** - Automatically detects your project from `.env` files or source code. No flags needed. -- **Seer AI Integration** - Get root cause analysis and fix plans directly in your terminal. -- **Monorepo Support** - Works with multiple projects, generates short aliases for easy navigation. -- **JSON Output** - All commands support `--json` for scripting and pipelines. -- **Open in Browser** - Use `-w` flag to open any resource in your browser. - -## Commands - -Run `sentry --help` to see all available commands, or browse the [command reference](https://cli.sentry.dev/commands/). - -## Configuration - -Credentials are stored in `~/.sentry/` with restricted permissions (mode 600). - -## Library Usage - - -Use Sentry CLI programmatically in Node.js (≥18.0) without spawning a subprocess: - - -```typescript -import createSentrySDK from "sentry"; - -const sdk = createSentrySDK({ token: "sntrys_..." }); - -// Typed methods for every CLI command -const orgs = await sdk.org.list(); -const issues = await sdk.issue.list({ orgProject: "acme/frontend", limit: 5 }); -const issue = await sdk.issue.view({ issue: "ACME-123" }); - -// Nested commands -await sdk.dashboard.widget.add({ display: "line", query: "count" }, "my-org/my-dashboard"); - -// Escape hatch for any CLI command -const version = await sdk.run("--version"); -const text = await sdk.run("issue", "list", "-l", "5"); -``` - -Options (all optional): -- `token` — Auth token. Falls back to `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` env vars. -- `url` — Sentry instance URL for self-hosted (e.g., `"sentry.example.com"`). -- `org` — Default organization slug (avoids passing it on every call). -- `project` — Default project slug. -- `text` — Return human-readable string instead of parsed JSON (affects `run()` only). -- `cwd` — Working directory for DSN auto-detection. Defaults to `process.cwd()`. -- `signal` — `AbortSignal` to cancel streaming commands (`--follow`, `--refresh`). - -Streaming commands return `AsyncIterable` — use `for await...of` and `break` to stop. - -Errors are thrown as `SentryError` with `.exitCode` and `.stderr`. - ---- +- [`packages/cli/`](./packages/cli) — the Sentry CLI (npm package `sentry`, + binary `sentry`). See [packages/cli/README.md](./packages/cli/README.md). +- [`apps/cli-docs/`](./apps/cli-docs) — the CLI documentation site + (Astro + Starlight, published to `cli.sentry.dev`). ## Development -### Prerequisites - - -- [Node.js](https://nodejs.org) v22.15+ and [pnpm](https://pnpm.io) v10.11+ - - -### Setup - -```bash -git clone https://github.com/getsentry/cli.git -cd cli -pnpm install -``` - -### Running Locally +This is a [pnpm workspace](https://pnpm.io/workspaces). Common tasks are exposed +as delegating scripts at the root and forwarded to the relevant package: -```bash -# Run CLI in development mode -pnpm run cli -- --help - -# With environment variables (create .env.local first, see DEVELOPMENT.md) -pnpm run cli -- --help -``` - -### Scripts - - -```bash -pnpm run build # Build for current platform -pnpm run typecheck # Type checking -pnpm run lint # Check for issues -pnpm run lint:fix # Auto-fix issues -pnpm run test:unit # Run unit tests -pnpm run test:e2e # Run end-to-end tests -pnpm run generate:docs # Regenerate command docs and skills +```sh +pnpm install # install all workspace packages +pnpm run build # build the CLI package +pnpm run typecheck # typecheck the CLI package +pnpm run lint # lint +pnpm run test # run the CLI unit tests ``` - - -See [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup and [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. - -## License -[FSL-1.1-Apache-2.0](LICENSE.md) +To work directly within a package, use pnpm filters, e.g. +`pnpm --filter sentry run