From 9e78c1b8f9c9d9ad5f3a93181a63fa5e163f699e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Sat, 28 Feb 2026 01:21:40 +0000 Subject: [PATCH] fix(api): use limit param for issues endpoint page size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sentry /organizations/{org}/issues/ endpoint uses `limit` to control page size, not `per_page`. Sending `per_page` is silently ignored and the API returns its default of 100 items regardless of the requested size. This caused `listIssuesAllPages` to receive 100 items when only 25 were requested, slice to the limit, and drop the cursor — making `hasMore` always false and suppressing the 'more available' pagination hint entirely. --- AGENTS.md | 632 +++++++++++++++++++++++++++++++++--- src/lib/api-client.ts | 3 +- test/lib/api-client.test.ts | 2 +- 3 files changed, 586 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 82c8777e..709dcebf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -626,74 +626,608 @@ mock.module("./some-module", () => ({ ## Long-term Knowledge +### Architecture + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Issue list multi-target mode has no cursor pagination — only org-all mode supports --cursor**: In the Sentry CLI \`issue list\` command, cursor-based pagination (\`--cursor\` / \`-c last\`) is supported in BOTH org-all mode (\`sentry issue list \/\`) and multi-target mode (auto-detect, explicit, project-search). Org-all uses a standard single cursor from the Sentry API. Multi-target mode uses a \*\*compound cursor\*\* — a pipe-separated string where each position corresponds to a project in the sorted target fingerprint order. Empty segments mean the project is exhausted (no more pages). The compound cursor is built by \`encodeCompoundCursor()\` and decoded by \`decodeCompoundCursor()\` in list.ts. The context key for multi-target cursors includes a fingerprint of the sorted target list (\`buildMultiTargetContextKey\`) so that cursor stored for one set of detected projects is not accidentally reused for a different set. When resuming with a compound cursor, \`handleResolvedTargets\` skips Phase 1/Phase 2 budget logic and instead fetches one page per project using each project's stored cursor. Projects with exhausted cursors (empty segments) are filtered into \`exhaustedTargets\` and excluded from \`activeTargets\` to prevent re-fetching from scratch. The 'more results available' hint now suggests both \`-n \\` and \`-c last\` in multi-target mode, but only when \`hasAnyCursor\` is true (at least one project has a next cursor stored). + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. + +### Decision + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + +### Gotcha + + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **Homebrew post-install runs as installing user — permission errors crash setup**: When Sentry CLI is installed via Homebrew, the formula runs \`sentry cli setup --method brew --no-modify-path\` as a post-install step. This can fail with SQLiteError (SQLITE\_CANTOPEN on ~/.sentry/cli.db) or EPERM on ~/.local/share/zsh/site-functions/\_sentry. Root cause is Unix permission issues from \`sudo brew install\` creating root-owned files (NOT macOS TCC). Fixed in PR #288 with three changes: 1. \*\*setup.ts\*\*: \`bestEffort()\` wrapper makes every post-install step non-fatal — Homebrew post\_install no longer aborts with scary errors. 2. \*\*telemetry.ts\*\*: \`tryRepairReadonly()\` detects root-owned files via \`statSync().uid === 0\` and prints actionable \`sudo chown -R \ \\` message using real username from SUDO\_USER/USER/USERNAME/os.userInfo(). Has \`win32\` platform guard since uid checks are meaningless on Windows. 3. \*\*fix.ts\*\*: \`sentry cli fix\` has full ownership detection — checks config files for wrong uid, prints chown instructions when not root, performs actual \`chown\` when run via \`sudo sentry cli fix\`. Uses \`resolveUid()\` via \`execFileSync('id', \['-u', username])\` (not \`Bun.$\` — no node shim). Guards against chown-to-root (targetUid === 0) and bails early when real UID can't be resolved. The \`getRealUsername()\` helper lives in \`src/lib/utils.ts\` (shared between telemetry.ts and fix.ts). It checks SUDO\_USER → USER → USERNAME → os.userInfo().username → '$(whoami)', with userInfo() wrapped in try-catch since it can throw on some systems. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. + +### Pattern + + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Sentry CLI plural aliases use route maps with defaultCommand, not direct command refs**: In the Sentry CLI, plural command aliases (issues, projects, orgs, repos, teams, logs, traces) in app.ts point directly to list commands as leaf routes. To prevent \`sentry projects list\` from misinterpreting 'list' as a project slug, all list commands use \`buildListCommand(routeName, config)\` from \`src/lib/list-command.ts\` instead of \`buildCommand(config)\`. \`buildListCommand\` is a transparent wrapper around \`buildCommand\` that intercepts the first positional arg (target) before calling the original func. It checks if the target matches a known subcommand of the singular route (e.g. "list", "view" for "project") and replaces it with \`undefined\` (auto-detect mode) + prints a gentle stderr hint. Subcommand names are extracted dynamically per-route via \`getSubcommandsForRoute(routeName)\`, which lazy-loads the Stricli route map with \`require('../app.js')\` (breaks circular dependency, cached after first call). Three abstraction levels: - \`buildListCommand(routeName, config)\` — any list command gets interception (all 6 commands) - \`buildOrgListCommand(config, docs, routeName)\` — full factory for simple org-scoped lists (team, repo), uses buildListCommand internally - Manual \`buildListCommand\` + custom func — complex commands keep their logic (project, issue, trace, log) Migration per command is one line: \`buildCommand({...})\` → \`buildListCommand("project", {...})\`. Known trade-off: a project literally named 'list' or 'view' can't be targeted via plural alias path. Users can use \`sentry project list list\` or \`sentry projects /list\` for this edge case. PR #281. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require + +### Preference + + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + + +## Long-term Knowledge + +### Architecture + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. + +### Decision + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + ### Gotcha - -* **getsentry/codecov-action enables JUnit XML test reporting by default**: The \`getsentry/codecov-action@main\` has \`enable-tests: true\` by default, which searches for JUnit XML files matching \`\*\*/\*.junit.xml\`. If the test framework doesn't produce JUnit XML, the action emits 3 warnings on every CI run: "No files found matching pattern", "No JUnit XML files found", and "Please ensure your test framework is generating JUnit XML output". Fix: either set \`enable-tests: false\` in the action inputs, or configure the test runner to output JUnit XML. For Bun, add \`\[test.reporter] junit = "test-results.junit.xml"\` to \`bunfig.toml\` and add \`\*.junit.xml\` to \`.gitignore\`. - + * **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components - + * **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling - -* **Cherry-picking GHCR tests requires updating mocks from version.json to GHCR manifest flow**: When the nightly distribution was first implemented, tests mocked GitHub's \`version.json\` for nightly version fetching. After migrating to GHCR, all nightly test mocks must use the 3-step GHCR flow: (1) token exchange at \`ghcr.io/token\`, (2) manifest fetch at \`/manifests/nightly\` returning JSON with \`annotations.version\` and \`layers\[].annotations\["org.opencontainers.image.title"]\`, (3) blob download returning \`Response.redirect()\` to Azure. The \`mockNightlyVersion()\` helper in command tests and \`mockGhcrNightlyVersion()\` must handle all three URLs. Tests that still mock \`version.json\` will fail because \`fetchLatestNightlyVersion()\` now calls \`getAnonymousToken()\` + \`fetchNightlyManifest()\` + \`getNightlyVersion()\` instead of a single GitHub fetch. Platform-specific filenames in manifest layers (e.g., \`sentry-linux-x64.gz\`) must use \`if/else\` blocks (not nested ternaries, which Biome forbids). - -* **Biome lint: Response.redirect() required, nested ternaries forbidden**: The Biome linter in this codebase enforces two rules that frequently trip up test code: 1. \*\*\`useResponseRedirect\`\*\*: When creating redirect responses in tests, use \`Response.redirect(url, status)\` instead of \`new Response(null, { status: 307, headers: { location: url } })\`. Exception: when testing a redirect \*without\* a Location header (e.g., testing error handling for missing Location), you must use \`new Response(null, { status: 307 })\` since \`Response.redirect()\` always includes a location. 2. \*\*\`noNestedTernary\`\*\*: Nested ternary expressions are forbidden. Replace with \`if/else if/else\` blocks assigning to a \`let\` variable. Common case: mapping \`process.platform\` to OS strings (\`darwin\`/\`windows\`/\`linux\`). Also: \`noComputedPropertyAccess\` — use \`obj.property\` instead of \`obj\["property"]\` for string literal keys. - -* **Sentry API silently caps limit parameter at 100**: The Sentry list issues API silently caps the \`limit\` query parameter at 100 — no error, no warning, just returns at most 100 results regardless of the requested limit. Any command requesting more than 100 items must implement client-side auto-pagination using Link header parsing. This applies to issues and likely other list endpoints. The \`orgScopedRequestPaginated()\` function already parses RFC 5988 Link headers for cursor-based pagination. - -* **Biome complexity limit of 15 — extract helpers to stay under**: The Biome linter enforces a maximum cognitive complexity of 15 per function. When adding branching logic (e.g., nightly vs stable download paths) to an existing function that's already near the limit, extract each branch into a separate helper function rather than inlining. Examples: In \`upgrade.ts\`, \`downloadBinaryToTemp\` hit complexity 17 after adding nightly GHCR support — fixed by extracting \`downloadNightlyToPath(tempPath)\` and \`downloadStableToPath(version, tempPath)\` helpers. In \`upgrade.ts\` command, \`resolveTargetVersion\` hit complexity 16 after adding nightly detection — fixed by extracting \`fetchLatest(method, version)\` helper. Pattern: keep the dispatch function thin (condition check + delegate), put the logic in helpers. - -* **Bot review false positives recur across rounds — dismiss consistently**: Cursor BugBot and Sentry Seer Code Review bots re-raise the same false positives across multiple review rounds with slightly different wording. Known recurring false positives on this codebase: 1. \*\*"spinner contaminates JSON output"\*\* — flagged 6+ times across 3 review rounds. The spinner writes to stderr, JSON to stdout. Always dismiss. 2. \*\*"999E silently caps extreme values"\*\* — flagged 3+ times. The cap is unreachable for real Sentry counts (needs >= 10^21 events). Always dismiss. 3. \*\*"abbreviateCount breaks on huge numbers"\*\* — variant of #2. 4. \*\*"pagination progress exceeds limit"\*\* — flagged once, was a legitimate bug (fixed by capping onPage at limit). 5. \*\*"count abbreviation skips exactly 10k"\*\* — flagged when using \`raw.length <= COL\_COUNT\` check. This is intentional: numbers that fit in the column don't need abbreviation. 6. \*\*"string passed where number expected for issue\_id"\*\* — Seer flags \`getIssueInOrg\` for not using \`Number(issueId)\` like other functions. False positive: the \`RetrieveAnIssueData\` SDK type defines \`issue\_id\` as \`string\`, unlike \`RetrieveAnIssueEventData\`/\`StartSeerIssueFixData\`/\`RetrieveSeerIssueFixStateData\` which use \`number\`. When dismissing: reply with a clear technical explanation, then resolve the thread via GraphQL \`resolveReviewThread\` (use \`resolveReviewThread(input: {threadId: $id})\` with the \`pullRequestReviewThread\` node ID). The bots don't learn from previous resolutions. Expect to dismiss the same issue again in the next round. - -* **abbreviateCount toFixed(1) rounding at tier boundaries**: In \`abbreviateCount\` (src/lib/formatters/human.ts), multiple rounding boundary bugs were fixed across several review rounds: 1. \*\*toFixed(1) rounds past threshold\*\*: \`scaled = 99.95\` passes \`scaled < 100\` but \`toFixed(1)\` produces \`"100.0"\` → \`"100.0K"\` (6 chars). Fix: pre-compute \`rounded1dp = Number(scaled.toFixed(1))\` and compare \*that\* against \`< 100\`. 2. \*\*Math.round produces >= 1000\*\*: e.g. \`999.95\` → \`Math.round\` = \`1000\` → \`"1000K"\`. Fix: when \`rounded >= 1000\` and a higher tier exists, promote (divide by 1000, use next suffix). At max tier, cap at \`Math.min(rounded, 999)\`. 3. \*\*NaN input overflows column\*\*: \`padStart\` never truncates. Fix: return \`"?".padStart(COL\_COUNT)\` and report via \`Sentry.logger.warn()\`. 4. \*\*Hardcoded 10\_000 threshold\*\*: Reviewer requires using \`raw.length <= COL\_COUNT\` digit comparison rather than hardcoding \`10\_000\`, so it adapts if \`COL\_COUNT\` changes. 5. \*\*Negative numbers\*\*: \`n < 0\` also needs the early-return path since Sentry counts are non-negative. 6. \*\*Sentry reporting\*\*: Use \`Sentry.logger.warn()\` (structured logs) for the NaN case — \`captureMessage\` with warning level was rejected, \`captureException\` is for unexpected errors. - -* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer may flag null-checks or defensive guards as "dead code" when the current implementation can't trigger them. If removing the check would require a non-null assertion (which lint rules may ban, e.g., \`noNonNullAssertion\`), keep the defensive guard and add a JSDoc comment explaining: (1) why it's currently unreachable, and (2) why it's kept as a guard against future changes. Similarly, both bots repeatedly flag stderr spinner output during --json mode as a bug — this is always a false positive since progress goes to stderr not stdout. When bots raise known false positives, reply explaining the rationale and resolve. + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. + +### Pattern + + +* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require ### Preference - + * **General coding preference**: Prefer explicit error handling over silent failures - + * **Code style**: User prefers no backwards-compat shims, fix callers directly - + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + + +## Long-term Knowledge + +### Architecture + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. + +### Decision + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + +### Gotcha + + +* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components + +* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. + +### Pattern + + +* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require + +### Preference + + * **General coding preference**: Prefer explicit error handling over silent failures - + * **Code style**: User prefers no backwards-compat shims, fix callers directly - -* **Use captureException (not captureMessage) for unexpected states, or Sentry logs**: When reporting unexpected/defensive-guard situations to Sentry (e.g., non-numeric input where a number was expected), the reviewer prefers \`Sentry.captureException(new Error(...))\` over \`Sentry.captureMessage(...)\`. \`captureMessage\` with 'warning' level was rejected in PR review. Alternatively, use the Sentry structured logger (\`Sentry.logger.warn(...)\`) for less severe diagnostic cases — this was accepted in the abbreviateCount NaN handler. - -* **Avoid hardcoded magic numbers — derive from constants**: Reviewer prefers deriving thresholds from existing constants rather than hardcoding magic numbers. Example: in \`abbreviateCount\`, the threshold for bypassing abbreviation was changed from \`n < 10\_000\` to \`raw.length <= COL\_COUNT\` — a digit-count comparison that adapts if the column width changes. The hardcoded \`10\_000\` only worked for COL\_COUNT=5. This pattern applies broadly: any threshold tied to a configurable constant should be derived from it. + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + + +## Long-term Knowledge + +### Architecture + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. + +### Decision + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + +### Gotcha + + +* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components + +* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. ### Pattern - + * **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits - -* **CLI stderr progress is safe alongside JSON stdout — reject bot false positives**: Bot reviewers (Cursor BugBot, Sentry Seer) repeatedly flag \`withProgress()\` spinner output during \`--json\` mode as a bug. This is a false positive: the spinner writes exclusively to \*\*stderr\*\*, while JSON goes to \*\*stdout\*\*. These are independent streams — stderr progress never contaminates JSON output. Consumers that merge stdout+stderr are doing so incorrectly for a CLI that emits structured JSON on stdout. When bots raise this, reply explaining the stderr/stdout separation and resolve. - -* **Make Bun.which testable by accepting optional PATH parameter**: When wrapping \`Bun.which()\` in a helper function, accept an optional \`pathEnv?: string\` parameter and pass it as \`{ PATH: pathEnv }\` to \`Bun.which\`. This makes the function deterministically testable without mocking — tests can pass a controlled PATH (e.g., \`/nonexistent\` for false, \`dirname(Bun.which('bash'))\` for true). Pattern: \`const opts = pathEnv !== undefined ? { PATH: pathEnv } : undefined; return Bun.which(name, opts) !== null;\` - -* **Pagination contextKey must include all query-varying parameters with escaping**: The \`contextKey\` used for storing/retrieving pagination cursors must encode every parameter that changes the result set — not just \`sort\` and \`query\`, but also \`period\` and any future filter parameters. User-controlled values must be wrapped with \`escapeContextKeyValue()\` (which replaces \`|\` with \`%7C\`) to prevent key corruption via injected delimiters. Use the optional-chaining pattern: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`. Important: \`flags.period\` may be \`undefined\` in test contexts (even though it has a default in the flag definition), so always provide a fallback before passing to \`escapeContextKeyValue()\` which calls \`.replaceAll()\` and will throw on \`undefined\`. This was caught in two review rounds — first the period was missing entirely, then it was added without escaping. - -* **Multi-target concurrent progress needs per-target delta tracking**: When multiple targets fetch concurrently via \`Promise.all\` and each reports cumulative progress via \`onPage(fetched)\`, a shared \`setMessage\` callback causes the display to jump between individual target values. Fix: maintain a \`prevFetched\` array and a \`totalFetched\` running sum. Each callback computes \`delta = fetched - prevFetched\[i]\`, adds it to the running total, and updates the message. This gives monotonically-increasing combined progress. The array is unavoidable because \`onPage\` reports cumulative counts per target, not deltas — a simple running tally without per-target tracking would double-count. Use \`totalFetched += delta\` instead of \`reduce()\` on every callback for O(1) updates. The reviewer explicitly rejected the \`reduce()\` approach and asked for an inline comment explaining why the per-target array is still needed. - -* **Extract shared startSpinner helper to avoid poll/withProgress duplication**: Both \`poll()\` and \`withProgress()\` in src/lib/polling.ts shared identical spinner logic. This was consolidated into a \`startSpinner(stderr, initialMessage)\` helper that returns \`{ setMessage, stop }\`. Both callers now delegate to it. Key details: - \`stop()\` sets the stopped flag unconditionally (not guarded by a \`json\` check) — only the stderr cleanup (newline for poll vs \`\r\x1b\[K\` for withProgress) differs between callers. - In \`poll()\`, \`stopped = true\` must be set unconditionally in the finally block even in JSON mode (reviewer caught that it was incorrectly guarded by \`!json\`). - When adding new spinner use cases, use \`startSpinner()\` rather than reimplementing the animation loop. - The reviewer explicitly flagged the duplication and the \`stopped\` flag guard — both were addressed in the same commit. - -* **PR review workflow: reply, resolve, amend, force-push**: When addressing PR review comments on this project: (1) Read unresolved threads via GraphQL API, (2) Make code changes addressing all feedback, (3) Run lint+typecheck+tests to verify, (4) Create a SEPARATE commit for each review round (not amend) — this enables incremental review, (5) Push normally (not force-push), (6) Reply to each review comment via REST API explaining what changed, (7) Resolve threads via GraphQL \`resolveReviewThread\` mutation using thread node IDs. Only amend+force-push when: (a) user explicitly asks, or (b) pre-commit hook auto-modified files that need including in the same commit. - -* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\\` or \`fix/\-\\` (e.g., \`feat/ghcr-nightly-distribution\`, \`fix/268-limit-auto-pagination\`). Commit message format: \`type(scope): description (#issue)\` (e.g., \`fix(issue-list): auto-paginate --limit beyond 100 (#268)\`, \`feat(nightly): distribute via GHCR instead of GitHub Releases\`). Types seen: fix, refactor, meta, release, feat. PRs are created as drafts via \`gh pr create --draft\`. Implementation plans are attached to commits via \`git notes add\` rather than in PR body or commit message. + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require + +### Preference + + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + + +## Long-term Knowledge + +### Architecture + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. ### Decision - -* **Use -t (not -p) as shortcut alias for --period flag**: The --period flag on issue list uses -t (for 'time period') as its short alias, not -p. The rationale: -p could be confused with --platform from other CLI tools/contexts. -t maps naturally to 'time period' and avoids collision. This was a deliberate choice after initial implementation used -p. - -* **CLI spinner animation interval: 50ms (20fps) matching ora/inquirer standard**: Terminal spinner animation interval set to 50ms (20fps), matching the standard used by ora, inquirer, and most popular CLI spinner libraries. With 10 braille frames, this gives a full cycle of 500ms. Alternatives considered: 16ms/60fps (too frantic for terminal), 33ms/30fps (smooth but unnecessary), 80ms/12.5fps (sluggish). The 50ms interval is fast enough to look smooth but not so fast it wastes CPU. This applies to the shared ANIMATION\_INTERVAL\_MS constant in polling.ts used by both the Seer polling spinner and pagination progress spinner. + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + +### Gotcha + + +* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components + +* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. + +### Pattern + + +* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require + +### Preference + + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + + +## Long-term Knowledge + +### Architecture + + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. + +### Decision + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + +### Gotcha + + +* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components + +* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. + +### Pattern + + +* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require + +### Preference + + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. + + + +## Long-term Knowledge ### Architecture - -* **GHCR nightly distribution: version in OCI manifest annotations, manual redirect for blobs**: Nightly CLI binaries are distributed via ghcr.io using OCI artifacts pushed by ORAS. The \`:nightly\` tag is overwritten on each push (unlike immutable GitHub Releases). Key design: 1. \*\*Version discovery via manifest annotation\*\* — the OCI manifest has \`annotations.version\` so checking the latest nightly needs only token exchange + manifest fetch (2 requests). No separate version.json blob download needed. 2. \*\*Nightly version format\*\*: \`0.0.0-nightly.\\` where timestamp comes from \`git log -1 --format='%ct'\` for determinism across CI jobs. Detection: \`version.includes('-nightly.')\`. 3. \*\*src/lib/ghcr.ts\*\* encapsulates the OCI download protocol: \`getAnonymousToken()\`, \`fetchNightlyManifest(token)\`, \`getNightlyVersion(manifest)\`, \`findLayerByFilename(manifest, filename)\`, \`downloadNightlyBlob(token, digest)\` with manual 307 redirect handling. 4. \*\*Routing\*\*: \`isNightlyVersion()\` in upgrade.ts gates whether to use GHCR or GitHub Releases. Both \`version-check.ts\` and the upgrade command check this. The install script has a \`--nightly\` flag. 5. \*\*CI\*\*: \`publish-nightly\` job runs on main only, uses \`oras push\` with \`--annotation "version=$VERSION"\`. All binaries are .gz compressed as OCI layers. 6. \*\*Channel architecture\*\*: Main branch uses a \`ReleaseChannel\` type ('stable' | 'nightly') with \`getReleaseChannel()\`/\`setReleaseChannel()\` persisted in DB. \`fetchLatestVersion(method, channel)\` dispatches to \`fetchLatestNightlyVersion()\` (GHCR) or \`fetchLatestFromGitHub()\`/\`fetchLatestFromNpm()\` based on channel. The \`migrateToStandaloneForNightly\` flow handles switching npm installs to standalone curl binaries when switching to nightly channel. - -* **Issue list auto-pagination beyond API's 100-item cap**: The Sentry API silently caps \`limit\` at 100 per request with no error. \`listIssuesAllPages()\` in api-client.ts provides auto-pagination: uses Math.min(limit, API\_MAX\_PER\_PAGE) as page size, loops over paginated responses using Link headers, bounded by MAX\_PAGINATION\_PAGES (50 pages = up to 5000 items safety limit), trims with .slice(0, limit). All modes now use auto-pagination consistently — \`--limit\` means "total results" everywhere (max 1000). Org-all mode auto-paginates from the start using \`fetchOrgAllIssues()\` helper, with single-page fetch when \`--cursor\` is explicitly provided to keep the cursor chain intact. A single exported \`API\_MAX\_PER\_PAGE\` constant (renamed from \`ISSUES\_MAX\_PER\_PAGE\`) in \`api-client.ts\` near the pagination infrastructure section is shared across all consumers — it replaces all hardcoded \`100\` page-size defaults in orgScopedPaginateAll, listProjectsPaginated, listIssuesAllPages, and listLogs. Default limit is 25. + +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry's event-fetching API endpoint is \`GET /api/0/projects/{org}/{project}/events/{event\_id}/\` — requires both org and project in the URL path. There is NO equivalent of the legacy \`/api/0/issues/{id}/\` endpoint for events. Event IDs (UUIDs) are project-scoped in Sentry's storage layer (ClickHouse/Snuba). Contrast with issues: \`getIssue()\` uses \`/api/0/issues/{id}/\` which works WITHOUT org context (issues have global numeric IDs). The issue response includes \`project.slug\` and \`organization.slug\`, enabling a two-step lookup: fetch issue → extract org/project → fetch event. For traces: \`getDetailedTrace()\` uses \`/organizations/{org}/trace/{traceId}/\` — needs only org, not project. Possible workaround for event view without org/project: use the Discover endpoint \`/organizations/{org}/events/\` with \`query=id:{eventId}\` and \`dataset=errors\` to search across all projects in an org. + +* **Sentry CLI has two distribution channels with different runtimes**: The Sentry CLI ships via two completely independent build pipelines: 1. \*\*Standalone binary\*\* (GitHub Releases): Built with \`Bun.build()\` + \`compile: true\` via \`script/build.ts\`. Produces native executables (\`sentry-{platform}-{arch}\`) with Bun runtime embedded. Runs under Bun. 2. \*\*npm package\*\*: Built with esbuild via \`script/bundle.ts\`. Produces a single minified CJS file (\`dist/bin.cjs\`) with \`#!/usr/bin/env node\` shebang. Requires Node.js 22+ (for \`node:sqlite\`). Key esbuild settings: \`platform: 'node'\`, \`target: 'node22'\`, \`format: 'cjs'\`. Aliases \`@sentry/bun\` → \`@sentry/node\`. Injects Bun API polyfills from \`script/node-polyfills.ts\`. Bun API polyfills cover: \`Bun.file()\`, \`Bun.write()\`, \`Bun.which()\`, \`Bun.spawn()\`, \`Bun.sleep()\`, \`Bun.Glob\`, \`Bun.randomUUIDv7()\`, \`Bun.semver.order()\`, and \`bun:sqlite\` (→ \`node:sqlite\` DatabaseSync). The npm bundle is CJS, so \`require()\` calls in source are native and resolved at bundle time by esbuild — no ESM/CJS conflict. CI smoke-tests with \`node dist/bin.cjs --help\` on Node 22 and 24. + +* **gh CLI config directory convention and XDG compliance**: The \`gh\` CLI (GitHub CLI), which is the explicit UX model for Sentry CLI per AGENTS.md, stores config at: \`$GH\_CONFIG\_DIR\` > \`$XDG\_CONFIG\_HOME/gh\` > \`$HOME/.config/gh\` (follows XDG on all platforms including macOS). Most other major CLIs (docker, aws, kubectl, cargo) use \`~/.toolname/\` rather than XDG. macOS \`~/Library/Application Support/\` is Apple-blessed for app data but uncommon for CLI tools and surprising to developers. The Sentry CLI currently uses \`~/.sentry/\` with \`SENTRY\_CONFIG\_DIR\` as an override env var. + +* **API client wraps all errors as CliError subclasses — no raw exceptions escape**: The Sentry CLI API client (src/lib/api-client.ts) guarantees that all errors thrown by API functions like getCurrentUser() are CliError subclasses (ApiError or AuthError). In unwrapResult(), known error types (AuthError, ApiError) are re-thrown directly, and everything else — including raw network TypeErrors from ky — goes through throwApiError() which wraps them as ApiError. This means command implementations do NOT need their own try-catch for error display: the central error handler in app.ts exceptionWhileRunningCommand catches CliError and displays a clean message without stack trace. Only add try-catch when the command needs to handle the error specially (e.g., login needs to continue without user info on failure, not crash). The Seer bot flagged whoami.ts for lacking try-catch around getCurrentUser() — this was a false positive because of this guarantee. + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: The resolve-target module (src/lib/resolve-target.ts) resolves org/project context through a strict 5-level priority cascade: 1. Explicit CLI flags (both org and project must be provided together) 2. SENTRY\_ORG / SENTRY\_PROJECT environment variables 3. Config defaults (SQLite defaults table) 4. DSN auto-detection (source code, .env files, SENTRY\_DSN env var) 5. Directory name inference (matches project slugs with word boundaries) SENTRY\_PROJECT supports combo notation: \`SENTRY\_PROJECT=org/project\` (slash presence auto-splits). When combo form is used, SENTRY\_ORG is ignored. If SENTRY\_PROJECT contains a slash but the combo parse fails (e.g. \`org/\` or \`/project\`), the entire SENTRY\_PROJECT value is discarded — it does NOT fall through to be used as a plain project slug alongside SENTRY\_ORG. Only SENTRY\_ORG (if set) provides the org in this case. The resolveFromEnvVars() helper is injected into all four resolution functions: resolveAllTargets, resolveOrgAndProject, resolveOrg, and resolveOrgsForListing. This matches the convention used by legacy sentry-cli and Sentry Webpack plugin. Added in PR #280. + +### Decision + + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. + +* **Issue list global limit with fair per-project distribution and representation guarantees**: The \`issue list\` command's \`--limit\` flag specifies a global total across all detected projects, not per-project. The fetch strategy uses two phases in \`fetchWithBudget\`: Phase 1 divides the limit evenly (\`ceil(limit / numTargets)\`) and fetches in parallel. Phase 2 (\`runPhase2\`) redistributes surplus budget to targets that hit their quota and have more results (via cursor resume using \`startCursor\` param added to \`listIssuesAllPages\`). After fetching, \`trimWithProjectGuarantee\` ensures at least 1 issue per project is shown before filling remaining slots from the globally-sorted list. This prevents high-volume projects from completely hiding quiet ones. When more projects exist than the limit, the projects with the highest-ranked first issues get representation. JSON output for multi-target mode wraps in \`{ data: \[...], hasMore: bool }\` (with optional \`errors\` array) to align with org-all mode's existing \`data\` wrapper. A compound cursor is stored so \`-c last\` can resume multi-target pagination. + +* **Sentry CLI config dir should stay at ~/.sentry/, not move to XDG**: Decision: Don't move the Sentry CLI config directory from ~/.sentry/ to ~/.config/sentry/ or ~/Library/Application Support/. The readonly database errors seen in telemetry (100% macOS) are caused by Unix permission issues from \`sudo brew install\` creating root-owned files, not by the directory location. Moving to any other path would have identical permission problems if created by root. The SENTRY\_CONFIG\_DIR env var already exists as an escape hatch. All three fixes have been implemented in PR #288: 1. Setup steps are non-fatal with bestEffort() try-catch wrapper 2. tryRepairReadonly() detects root-owned files and prints actionable \`sudo chown -R \ ~/.sentry\` message 3. \`sentry cli fix\` command handles ownership detection and repair (chown when run as root via sudo) + +### Gotcha + + +* **React useState async pitfall**: React useState setter is async — reading state immediately after setState returns stale value in dashboard components + +* **TypeScript strict mode caveat**: TypeScript strict null checks require explicit undefined handling + +* **brew is not in VALID\_METHODS but Homebrew formula passes --method brew**: Homebrew install support for Sentry CLI was merged to main via PR #277. The implementation includes: 'brew' as a valid installation method in \`parseInstallationMethod()\` (src/lib/upgrade.ts), \`isHomebrewInstall()\` detection via Cellar realpath check (always checked first before stored install info), version pinning errors with dedicated 'unsupported\_operation' error reason, architecture validation, and post\_install setup that skips redundant work on \`brew upgrade\`. The upgrade command includes a 'brew' case that tells users to run \`brew upgrade getsentry/tools/sentry\`. Uses .gz compressed artifacts. The Homebrew formula lives in a tap at getsentry/tools. + +* **Stricli defaultCommand blends default command flags into route completions**: When a Stricli route map has \`defaultCommand\` set, requesting completions for that route (e.g. \`\["issues", ""]\`) returns both the subcommand names AND the default command's flags/positional completions. This means completion tests that compare against \`extractCommandTree()\` subcommand lists will fail for groups with defaultCommand, since the actual completions include extra entries like \`--limit\`, \`--query\`, etc. Solution: track \`hasDefaultCommand\` in the command tree and skip strict subcommand-matching assertions for those groups. + +* **Codecov patch coverage requires --coverage flag on ALL test invocations**: In the Sentry CLI, the test script runs \`bun run test:unit && bun run test:isolated\`. Only \`test:unit\` has \`--coverage --coverage-reporter=lcov\`. The \`test:isolated\` run (for tests using \`mock.module()\`) does NOT generate coverage. This means code paths exercised only in isolated tests won't count toward Codecov patch coverage. To boost patch coverage, either: (1) add \`--coverage\` to the isolated test script, or (2) write additional unit tests that call the real (non-mocked) functions where possible. For env var resolution, since env vars short-circuit at step 2 before any DB/API calls, unit tests can call the real resolve functions without mocking dependencies. + +* **Multiregion mock must include all control silo API routes**: When changing which Sentry API endpoint a function uses (e.g., switching getCurrentUser() from /users/me/ to /auth/), the mock route must be updated in BOTH test/mocks/routes.ts (single-region) AND test/mocks/multiregion.ts createControlSiloRoutes() (multi-region). Missing the multiregion mock causes 404s in multi-region test scenarios. The multiregion control silo mock serves auth, user info, and region discovery routes. Cursor Bugbot caught this gap when /api/0/auth/ was added to routes.ts but not multiregion.ts. + +* **Sentry /users/me/ endpoint returns 403 for OAuth tokens — use /auth/ instead**: The Sentry \`/users/me/\` endpoint returns 403 for OAuth tokens (including OAuth App tokens). The \`/auth/\` endpoint works with ALL token types (OAuth, API tokens, OAuth App tokens) and returns the authenticated user's information. \`/auth/\` lives on the control silo (sentry.io for SaaS, not regional endpoints). The sentry-mcp project uses this pattern: always route \`/auth/\` to the main sentry.io host for SaaS, bypassing regional endpoints. In the Sentry CLI, \`getControlSiloUrl()\` already handles this routing correctly. The \`getCurrentUser()\` function in \`src/lib/api-client.ts\` should use \`/auth/\` instead of \`/users/me/\`. The \`SentryUserSchema\` (with \`.passthrough()\`) handles the \`/auth/\` response since it only requires \`id\` and makes \`email\`, \`username\`, \`name\` optional. + +* **Bun mock.module() leaks globally across test files in same process**: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks state across test files run in the same bun test process. If test file A mocks 'src/lib/api-client.js' to stub listOrganizations, ALL subsequent test files in that process see the mock instead of the real module. This caused ~100 test failures when test/isolated/resolve-target.test.ts ran alongside unit tests. Solution: tests using mock.module() must run in a separate bun test invocation (separate process). In package.json, the 'test' script uses 'bun run test:unit && bun run test:isolated' instead of 'bun test' to ensure process isolation. The test/isolated/ directory exists specifically for tests that use mock.module(). The file even documents this with a comment about Bun leaking mock.module() state (referencing getsentry/cli#258). + +* **Test suite has 131 pre-existing failures from DB schema drift and mock issues**: The Sentry CLI test suite had 131 pre-existing failures (1902 pass) caused by two root issues: (1) Bun's mock.module() in test/isolated/resolve-target.test.ts leaked globally, poisoning api-client.js (listOrganizations → undefined), db/defaults.js, db/project-cache.js, db/dsn-cache.js, and dsn/index.js for all subsequent test files — this caused ~80% of failures. (2) Minor issues: project root path resolution in temp dirs (11), DSN Detector module tests (17), E2E timeouts (2). Fix: changed package.json 'test' script from 'bun test' to 'bun run test:unit && bun run test:isolated' so isolated tests with mock.module() run in a separate Bun process. Result: 1931 tests, 0 failures. Key lesson: Bun's mock.module() replaces the ENTIRE barrel module globally and leaks across test files in the same process — tests using mock.module() must be isolated in separate bun test invocations. + +* **pagination\_cursors table schema mismatch requires repair migration**: The pagination\_cursors SQLite table could be created with a single-column PK (command\_key TEXT PRIMARY KEY) by earlier code versions, instead of the expected composite PK (PRIMARY KEY (command\_key, context)). This caused 'SQLiteError: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint' at runtime. Fixed by: (1) migration 5→6 that detects wrong PK via hasCompositePrimaryKey() and drops/recreates the table, (2) repairWrongPrimaryKeys() in repairSchema() for auto-repair, (3) isSchemaError() now catches 'on conflict clause does not match' to trigger auto-repair, (4) getSchemaIssues() reports 'wrong\_primary\_key' diagnostic. CURRENT\_SCHEMA\_VERSION bumped to 6. Data loss is acceptable since pagination cursors are ephemeral (5-min TTL). The hasCompositePrimaryKey() helper inspects sqlite\_master DDL for the expected PRIMARY KEY clause. + +* **resolveCursor must be called inside org-all closure, not before dispatch**: In list commands using dispatchOrgScopedList with cursor pagination (e.g., project/list.ts), resolveCursor() must be called inside the 'org-all' override closure, not before dispatchOrgScopedList. If called before, it throws a ContextError before dispatch can throw the correct ValidationError for --cursor being used in non-org-all modes. + +* **Bun.$ (shell tagged template) has no Node.js polyfill in Sentry CLI**: The Sentry CLI's node-polyfills.ts (used for the npm/Node.js distribution) provides shims for Bun.file(), Bun.write(), Bun.which(), Bun.spawn(), Bun.sleep(), Bun.Glob, Bun.randomUUIDv7(), Bun.semver.order(), and bun:sqlite — but NOT for Bun.$ (the tagged template shell). Any source code using Bun.$\`command\` will crash when running via the npm distribution (node dist/bin.cjs). Use execSync from node:child\_process instead for shell commands that need to work in both runtimes. The Bun.which polyfill already uses this pattern. As of PR #288, there are zero Bun.$ usages in the source code. AGENTS.md has been updated to document this: the Bun.$ row in the Quick Bun API Reference table has a ⚠️ warning, and a new exception block shows the execSync workaround. The phrasing 'Until a shim is added' signals that adding a Bun.$ polyfill is a desired future improvement. When someone adds the shim, remove the exception note and the ⚠️ from the table. + +* **handleProjectSearch ContextError resource must be "Project" not config.entityName**: In src/lib/org-list.ts handleProjectSearch, the first argument to ContextError is the resource name rendered as "${resource} is required.". Always pass "Project" (not config.entityName like "team" or "repository") since the error is about a missing project slug, not a missing entity of the command's type. A code comment documents the rationale inline. + +### Pattern + + +* **Kubernetes deployment pattern**: Use helm charts for Kubernetes deployments with resource limits + +* **Ownership check before permission check in CLI fix commands**: When a CLI fix/repair command checks filesystem health, ownership issues must be diagnosed BEFORE permission issues. If files are root-owned, chmod will fail with EPERM anyway — no point attempting it. The ordering in \`sentry cli fix\` is: (1) ownership check (stat().uid vs process.getuid()), (2) permission check (only if ownership is OK), (3) schema check (only if filesystem is accessible). When ownership repair succeeds (via sudo), skip the permission check since chown doesn't change mode bits — permissions may still need fixing on a subsequent non-sudo run. + +* **Sentry CLI Pattern A error: 133 events from missing context, mostly AI agents**: The #1 user error pattern in Sentry CLI (CLI-17, 110+ events, 50 users) is 'Organization and project is required' — users running commands without any org/project context. Breakdown by command: issue list (54), event view (41), issues alias (9), trace view (4). 24/110 events have --json flag (AI agent/CI usage). Many event view calls pass just an event ID with no org/project positional arg. Users are on CI (Ubuntu on Azure) or macOS with --json. The fix is multi-pronged: (1) SENTRY\_ORG/SENTRY\_PROJECT env var support (PR #280), (2) improved error messages mentioning env vars, (3) future: event view cross-project search. + +* **Store analysis/plan files in gitignored .plans/ directory**: For the Sentry CLI project, analysis and planning documents (like bug triage notes from automated review tools) are stored in .plans/ directory which is added to .gitignore. This keeps detailed analysis accessible locally without cluttering the repository. Previously such analysis was being added to AGENTS.md which is committed. + +* **Sentry CLI issue resolution wraps getIssue 404 in ContextError with ID hint**: In resolveIssue() (src/commands/issue/utils.ts), bare getIssue() calls for numeric and explicit-org-numeric cases should catch ApiError with status 404 and re-throw as ContextError that includes: (1) the ID the user provided, (2) a hint about access/deletion, (3) a suggestion to use short-ID format (\-\) if the user confused numeric group IDs with short-ID suffixes. Without this, the user gets a generic 'Issue not found' without knowing which ID failed or what to try instead. + +* **Sentry CLI setFlagContext redacts sensitive flags before telemetry**: The setFlagContext() function in src/lib/telemetry.ts must redact sensitive flag values (like --token) before setting Sentry tags. A SENSITIVE\_FLAGS set contains flag names that should have their values replaced with '\[REDACTED]' instead of the actual value. This prevents secrets from leaking into telemetry. The scrub happens at the source (in setFlagContext itself) rather than in beforeSend, so the sensitive value never reaches the Sentry SDK. + +* **Non-essential DB cache writes should be guarded with try-catch**: In the Sentry CLI, commands that write to the local SQLite cache as a side effect (e.g., setUserInfo() to update cached user identity) should wrap those writes in try-catch when the write is not essential to the command's primary purpose. If the DB is in a bad state (read-only filesystem, corrupted, schema mismatch), the cache write would throw and crash the command even though the primary operation (e.g., displaying user identity, completing login) already succeeded. Pattern: wrap non-essential setUserInfo() calls in try-catch, silently swallow errors. Applied in both whoami.ts and login.ts. Cursor Bugbot flagged the whoami.ts case — the cache update is a nice-to-have side effect that shouldn't prevent showing the fetched user data. + +* **Login --token flow: getCurrentUser failure must not block authentication**: In src/commands/auth/login.ts --token flow, the token is saved via setAuthToken() before fetching user info via getCurrentUser(). If getCurrentUser() fails after the token is saved, the user would be in an inconsistent state (isAuthenticated() true, getUserInfo() undefined). The fix: wrap getCurrentUser()+setUserInfo() in try-catch, log warning to stderr on failure but let login succeed. The 'Logged in as' line is conditional on user info being available. This differs from getUserRegions() failure which should clearAuth() and fail hard (indicates invalid token). Both Sentry Seer and Cursor Bugbot flagged this as a real bug and both suggested the same fix pattern. + +* **Sentry CLI api command: normalizeFields auto-corrects colon separators with stderr warning**: The \`sentry api\` command's \`--field\` flag requires \`key=value\` format with \`=\` separator. Users frequently confuse this with Sentry search syntax (\`key:value\`) or pass timestamps containing colons (e.g., \`since:2026-02-25T11:20:00\`). The \`normalizeFields()\` function in \`src/commands/api.ts\` auto-corrects \`:\` to \`=\` (splitting on first colon) and prints a warning to stderr, rather than crashing. This is safe because the correction only triggers when no \`=\` exists at all (the field would fail anyway). The normalization runs at the command level in \`func()\` before \`prepareRequestOptions()\`, keeping the parsing functions pure. Fields with \`=\` or ending in \`\[]\` pass through unchanged. The three downstream parsing functions (\`processField\`, \`buildQueryParams\`, \`buildRawQueryParams\`) use \`ValidationError\` instead of raw \`Error\` for truly uncorrectable fields, ensuring clean formatting through the central error handler. PR #302, fixes CLI-9H and CLI-93. + +* **Bun supports require() in ESM modules natively — ignore ESM-only linter warnings**: Bun natively supports \`require()\` in ESM modules (files with \`"type": "module"\` in package.json). This is different from Node.js where require() is not available in ESM. AI code reviewers (BugBot, Seer) may flag \`require()\` in ESM as high-severity bugs — these are false positives when the runtime is Bun. The Sentry CLI uses \`require()\` for lazy dynamic imports to break circular dependencies (e.g. \`require('../app.js')\` in list-command.ts to extract route subcommand names). For the npm distribution, this is also safe because esbuild bundles everything into a single CJS file (\`dist/bin.cjs\`) where \`require()\` is native — esbuild resolves and inlines all requires at bundle time. Bun docs: https://bun.sh/docs/runtime/modules#using-require + +### Preference + + +* **General coding preference**: Prefer explicit error handling over silent failures + +* **Code style**: User prefers no backwards-compat shims, fix callers directly + +* **Progress message format: 'N and counting (up to M)...' pattern**: User prefers progress messages that frame the limit as a ceiling rather than an expected total. Format: \`Fetching issues, 30 and counting (up to 50)...\` — not \`Fetching issues... 30/50\`. The 'up to' framing makes it clear the denominator is a max, not an expected count, avoiding confusion when fewer items exist than the limit. For multi-target fetches, include target count: \`Fetching issues from 10 projects, 30 and counting (up to 50)...\`. Initial message before any results: \`Fetching issues (up to 50)...\` or \`Fetching issues from 10 projects (up to 50)...\`. diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 07a6dd93..fb5f7b3f 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1031,7 +1031,8 @@ export function listIssuesPaginated( // omitting the parameter. query: fullQuery || undefined, cursor: options.cursor, - per_page: options.perPage ?? 25, + // The issues endpoint uses `limit` (not `per_page`) to control page size. + limit: options.perPage ?? 25, sort: options.sort, statsPeriod: options.statsPeriod, }, diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index 9f90e38f..64538d64 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -1262,7 +1262,7 @@ describe("listIssuesPaginated", () => { const url = new URL(capturedUrl); expect(url.searchParams.get("cursor")).toBe("0:3:0"); - expect(url.searchParams.get("per_page")).toBe("20"); + expect(url.searchParams.get("limit")).toBe("20"); expect(url.searchParams.get("sort")).toBe("freq"); }); });