From defd9c7a10f41df2132e46afa0165bd42136fabd Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 1 Jun 2026 13:30:14 -0400 Subject: [PATCH 1/5] fix(upgrade): Resolve symlinks before self-copy guard in installBinary When the install dir is reached through a symlink (e.g. macOS /tmp -> /private/tmp), the source path (canonicalized by process.execPath) and the .download temp path point at the same file but differ as strings. The resolve()-based guard then failed to detect this, unlinked the source, and copyFile() crashed with ENOENT. Compare realpath-canonicalized paths instead, falling back to resolve() when the path does not exist yet. Co-Authored-By: Claude Opus 4.8 --- src/lib/binary.ts | 19 ++++++++++++++++++- test/lib/binary.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/lib/binary.ts b/src/lib/binary.ts index 214a30912..debbecc55 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -9,6 +9,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, + realpathSync, renameSync, unlinkSync, writeFileSync, @@ -443,7 +444,23 @@ export async function installBinary( // When upgrade spawns setup --install, the child's execPath IS the // .download file (sourcePath === tempPath). In that case skip the // unlink+copy — the file is already where we need it. - if (resolve(sourcePath) !== resolve(tempPath)) { + // + // Compare canonical (symlink-resolved) paths, not just absolute ones: + // process.execPath is canonicalized by the OS, while installDir may + // reach the same location through a symlink (e.g. macOS /tmp -> + // /private/tmp). Plain resolve() would see these as different and we'd + // unlink the very file we're about to copy. realpathSync requires the + // path to exist, so fall back to resolve() for the normal case where + // tempPath hasn't been created yet. + const canonical = (p: string): string => { + try { + return realpathSync(p); + } catch { + return resolve(p); + } + }; + + if (canonical(sourcePath) !== canonical(tempPath)) { // Clean up any leftover temp file from interrupted operation try { await unlink(tempPath); diff --git a/test/lib/binary.test.ts b/test/lib/binary.test.ts index 31bccbf61..3acded5b0 100644 --- a/test/lib/binary.test.ts +++ b/test/lib/binary.test.ts @@ -9,7 +9,9 @@ import { chmodSync, mkdirSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; @@ -424,6 +426,33 @@ describe("installBinary", () => { const content = await readFile(result, "utf-8"); expect(content).toBe("upgraded binary"); }); + + test("handles sourcePath === tempPath reached through a symlinked install dir", async () => { + if (process.platform === "win32") return; + + // Reproduces the macOS /tmp -> /private/tmp case: installDir is given via a + // symlink, but the source binary's path is canonicalized (as process.execPath + // would be). The two paths point at the same file but differ as strings, so a + // naive resolve() comparison would unlink the source before copying it. + const realDir = join(testDir, "real-install"); + mkdirSync(realDir, { recursive: true }); + const symlinkedDir = join(testDir, "symlinked-install"); + symlinkSync(realDir, symlinkedDir); + + // The .download file lives in the real dir; sourcePath uses the canonical path. + const tempName = `${getBinaryFilename()}.download`; + const sourcePath = join(realpathSync(realDir), tempName); + await writeFile(sourcePath, "upgraded binary"); + chmodSync(sourcePath, 0o755); + + // installBinary is called with the symlinked dir, so tempPath resolves to the + // same file as sourcePath via the symlink. + const result = await installBinary(sourcePath, symlinkedDir); + + expect(result).toBe(join(symlinkedDir, getBinaryFilename())); + const content = await readFile(result, "utf-8"); + expect(content).toBe("upgraded binary"); + }); }); describe("acquireLock", () => { From 732775320971bb87df99ab3d48762436042b8175 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 1 Jun 2026 13:57:20 -0400 Subject: [PATCH 2/5] ref(upgrade): Log unexpected realpathSync failures in canonical helper Per AGENTS.md, catch blocks in src/ must not silently swallow errors. Guard the expected "path does not exist yet" case with existsSync (no log, since it fires on every normal install) and log.debug() only the genuinely unexpected realpathSync failures in the catch. Co-Authored-By: Claude Opus 4.8 --- src/lib/binary.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/binary.ts b/src/lib/binary.ts index debbecc55..099630fa5 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -24,6 +24,7 @@ import { isTlsCertError, } from "./custom-ca.js"; import { stringifyUnknown, UpgradeError } from "./errors.js"; +import { logger } from "./logger.js"; /** Known directories where the curl installer may place the binary */ export const KNOWN_CURL_DIRS = [".local/bin", "bin", ".sentry/bin"]; @@ -453,9 +454,17 @@ export async function installBinary( // path to exist, so fall back to resolve() for the normal case where // tempPath hasn't been created yet. const canonical = (p: string): string => { + // realpathSync throws when the path does not exist yet — the normal + // case for tempPath before the download lands — so short-circuit that + // without logging. A throw for any other reason (e.g. permissions) is + // unexpected and worth surfacing in debug logs. + if (!existsSync(p)) { + return resolve(p); + } try { return realpathSync(p); - } catch { + } catch (error) { + logger.debug("realpathSync failed, falling back to resolve()", error); return resolve(p); } }; From 6c79c338e4c53b6dfc5e6d6f2330db2574e7af38 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Mon, 1 Jun 2026 14:07:08 -0400 Subject: [PATCH 3/5] test(upgrade): Cover canonical() realpathSync fallback in installBinary Adds a mocked test exercising the catch branch where realpathSync throws on an existing path (permission error or TOCTOU race), asserting installBinary falls back to resolve() and logs. Kept in a sibling .mocked.test.ts so the node:fs mock does not leak into binary.test.ts. Brings patch coverage above target. Co-Authored-By: Claude Opus 4.8 --- test/lib/binary.mocked.test.ts | 74 ++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 test/lib/binary.mocked.test.ts diff --git a/test/lib/binary.mocked.test.ts b/test/lib/binary.mocked.test.ts new file mode 100644 index 000000000..1382cdc32 --- /dev/null +++ b/test/lib/binary.mocked.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for installBinary's canonical() fallback when realpathSync throws on a + * path that exists. + * + * realpathSync only throws for an existing path under rare conditions (e.g. a + * permission error, or a TOCTOU race where the file is removed between the + * existsSync check and the realpathSync call). We mock realpathSync to throw so + * the catch branch — which logs and falls back to resolve() — is exercised. + * + * Kept in a sibling `.mocked.test.ts` file so the node:fs mock doesn't leak + * into binary.test.ts, which relies on the real filesystem. + */ + +import { chmodSync, mkdirSync, rmSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock only realpathSync to throw; everything else stays real via importOriginal. +vi.mock("node:fs", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + realpathSync: () => { + throw new Error("EACCES: permission denied (simulated)"); + }, + }; +}); + +// Import AFTER the mock so binary.ts picks up the throwing realpathSync. +import { getBinaryFilename, installBinary } from "../../src/lib/binary.js"; +import { logger } from "../../src/lib/logger.js"; + +describe("installBinary canonical() fallback when realpathSync throws", () => { + let testDir: string; + let installDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `binary-mocked-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + installDir = join(testDir, "install"); + mkdirSync(installDir, { recursive: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(testDir, { recursive: true, force: true }); + }); + + test("falls back to resolve() and logs when realpathSync throws on an existing path", async () => { + if (process.platform === "win32") return; + + // Mirror the upgrade-spawn case: sourcePath IS the .download file, so the + // guard must recognize them as the same file. With realpathSync throwing, + // canonical() must fall back to resolve() (which still matches here) rather + // than unlinking the source and crashing. + const tempPath = join(installDir, `${getBinaryFilename()}.download`); + await writeFile(tempPath, "upgraded binary"); + chmodSync(tempPath, 0o755); + + const debugSpy = vi.spyOn(logger, "debug"); + + const result = await installBinary(tempPath, installDir); + + expect(result).toBe(join(installDir, getBinaryFilename())); + expect(await readFile(result, "utf-8")).toBe("upgraded binary"); + expect(debugSpy).toHaveBeenCalledWith( + "realpathSync failed, falling back to resolve()", + expect.any(Error) + ); + }); +}); From 8b95f314b80833028e220c936c174b4310f389f3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 1 Jun 2026 20:43:50 +0000 Subject: [PATCH 4/5] ref: use async realpath, eliminate existsSync TOCTOU, trim comments - Replace realpathSync with async realpath from node:fs/promises for consistency with the surrounding async function - Replace existsSync guard with try/catch ENOENT pattern to avoid TOCTOU race condition - Trim excessive comments on canonical helper and mocked test header - Fix silent catch block in temp file cleanup (add logger.debug) - Update mocked test to mock node:fs/promises realpath instead of node:fs realpathSync --- .lore.md | 370 ++------------------------------- src/lib/binary.ts | 36 ++-- test/lib/binary.mocked.test.ts | 37 ++-- 3 files changed, 44 insertions(+), 399 deletions(-) diff --git a/.lore.md b/.lore.md index 141eada8e..f10334e04 100644 --- a/.lore.md +++ b/.lore.md @@ -8,118 +8,25 @@ * **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth token precedence in \`src/lib/db/auth.ts\`: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. \`runInteractiveLogin\` catches OAuth flow errors internally and returns falsy on failure; login command sets \`process.exitCode = 1\` and returns normally (does NOT reject). Tests expecting \`rejects.toThrow()\` will fail — assert via fetch-call inspection instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var. -* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24 target] → dist-build/bin.js → \[fossilize --no-bundle --hole-punch] → Node SEA binary → gzip\`. CRITICAL ORDER: hole-punch MUST happen BEFORE signing (issue #1033 — hole-punching after signing invalidates macOS code signature, causing AMFI SIGKILL). As of fossilize 0.8.0, hole-punch runs inside fossilize via \`--hole-punch\` flag (uses binpunch internally), between chmod and sign+notarize. Strip debug symbols also handled INSIDE fossilize (as of fossilize 0.7.0). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails (caught). Windows: skipped. NODE\_VERSION='lts'. ALL\_TARGETS: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. UPX RULED OUT — destroys ELF notes. \`FOSSILIZE\_SIGN=y\` on push to main/release. Gzip only when \`RELEASE\_BUILD=1\`. binpunch is opt-in via \`--hole-punch\` flag / \`FOSSILIZE\_HOLE\_PUNCH\` env var — NOT default-on (removes non-English ICU locale data, breaking i18n for consumers). Cache stays pristine; all mutations (strip, inject, hole-punch, sign) happen on per-build output copy. - - -* **Binary size breakdown: 94.5% is Node.js runtime — bundled code is ~6.3 MiB**: Binary composition (linux-x64, Node 24 LTS): Node.js runtime=121 MiB (ships with debug symbols). \`strip --strip-unneeded\` → 99 MiB (-17 MiB raw, -4 MiB compressed). Strip built into fossilize 0.7.0 — happens on the copied binary BEFORE postject injection. After strip+SEA+binpunch: ~108 MiB raw, ~30 MiB gzip (vs 125 MiB / 34 MiB unstripped). .rodata=52.5 MB: V8 snapshot ~12 MB, ICU full-icu data ~28 MB. UPX compresses to 25 MiB but DESTROYS ELF notes — ruled out. \`--with-intl=small-icu\` saves ~26-28 MiB (biggest win from custom build); \`--without-lief\` BREAKS SEA; \`--without-sqlite\` BREAKS CLI; \`--disable-single-executable-application\` BREAKS EVERYTHING. Custom build deferred — poor cost/benefit (~3.5h build vs 5min fossilize). Final vs Bun: download 30 MiB (Bun: 32 MiB), \`--version\` ~1.0s (Bun: ~1.9s), completions ~150ms (Bun: ~180ms). - - -* **check-fragments.ts: validates fragment files against actual route names**: \`script/check-fragments.ts\`: validates fragment files against actual route names (Check 1-4) AND validates subcommand coverage within fragments (Check 5). Check 5: for each route with >1 command, verifies fragment mentions each subcommand via a heading (outside fenced code blocks) or \`sentry \ \\` code reference. Default commands handled: if fragment contains bare \`sentry \\`, the default command is covered. Default commands detected from route map (\`defaultCommand\` field in route index files). Fenced code block content stripped before heading scan to avoid false positives from bash comments. Warnings by default; \`--strict\` makes them errors. Run via \`pnpm run check:fragments\`. CI \`check-generated\` job triggers when \`changes.outputs.skill == 'true'\`. - - -* **check:stale-refs: generic toolchain consistency scanner derived from package.json**: \`script/check-stale-references.ts\`: reads \`packageManager\` from \`package.json\` (e.g., \`pnpm@10.11.0\`), derives stale PMs dynamically, and scans dev-facing docs/scripts for stale \`\ run\`, \`\ remove\`, \`\ add -d\` commands and \`requires \\`/\`\ installed\` prerequisite prose. Excludes: user-facing install instructions (fenced code blocks with \`install -g\`/\`add -g\`), the check script itself, and \`node\_modules/\`. Added to CI lint job. \*\*Generic\*\*: if project migrates from pnpm to yarn, changing \`packageManager\` in \`package.json\` auto-flags all \`pnpm run\` references in dev docs — no manual pattern updates needed. Trap: script must exclude itself from scanning or its own JSDoc examples trigger false positives. - - -* **CI build-binary matrix: PR=2 targets, main/release=7 targets**: \`ci.yml\` \`build-binary\` job: PRs build only \`linux-x64\` (can-test:true) + \`linux-x64-musl\` (can-test:false). Main/release/workflow\_call builds all 7: darwin-arm64, linux-x64, linux-x64-musl, windows-x64, darwin-x64, linux-arm64, linux-arm64-musl. Build command: \`bun run build --target ${{ matrix.target }}\`. \`test-e2e\` downloads \`sentry-linux-x64\` artifact and sets \`SENTRY\_CLI\_BINARY\`. \`build-npm\` matrix: Node 22+24; smoke test is only \`node dist/bin.cjs --help\`. \`SENTRY\_CLIENT\_ID\` defaults to \`ci-fork-pr-dummy\` for fork PRs (can't read repo vars). Gzip artifacts only on non-PR runs. \`FOSSILIZE\_SIGN=y\` on push to main/release. - - -* **CLI mode never calls setEnv() — getEnv() returns process.env directly**: CLI mode NEVER calls \`setEnv()\` — \`getEnv()\` returns \`process.env\` directly. Only library mode calls \`setEnv()\` with a merged env copy to avoid mutating the consumer's \`process.env\`. This prevents unexpected side effects when the CLI is embedded as a library. - - -* **collapse=lifetime in issue list: LIFETIME\_FIELDS, buildListApiOptions, and API gotcha**: \`src/commands/issue/list.ts\` \`LIFETIME\_FIELDS = new Set(\['count','userCount','firstSeen','lastSeen'])\` — fields stripped by \`collapse=lifetime\` on the list endpoint. \`buildListApiOptions(json, fields)\`: \`collapseLifetime\` only true when \`json && fields !== undefined && fields.length > 0 && !fields.some(f => LIFETIME\_FIELDS.has(f))\`. Human output NEVER collapses lifetime. \`buildIssueListCollapse()\` always starts with \`\['filtered','unhandled']\`, conditionally adds \`'lifetime'\` then \`'stats'\`. \`ISSUE\_DETAIL\_COLLAPSE\` safely includes \`'lifetime'\` — detail endpoint preserves top-level fields regardless. \`IssueViewOutputSchema\` in \`src/types/sentry.ts\` extends \`SentryIssueSchema\` with enrichment fields (\`event\`, \`org\`, \`replayIds\`, \`trace\`) added by \`jsonTransformIssueView\`. Wired via \`schema: IssueViewOutputSchema\` on output config in \`view.ts\`. NOTE: \`count\`/\`userCount\`/\`firstSeen\`/\`lastSeen\` always present on \`issue view\` (detail endpoint) — only potentially absent on \`issue list\` when collapse=lifetime is active. +* **Binary build pipeline: esbuild → fossilize → Node SEA (replacing Bun.build compile)**: Binary build pipeline: \`src/bin.ts → \[esbuild CJS, node24 target] → dist-build/bin.js → \[fossilize --no-bundle --hole-punch] → Node SEA binary → gzip\`. CRITICAL ORDER: hole-punch MUST happen BEFORE signing (issue #1033 — hole-punching after signing invalidates macOS code signature, causing AMFI SIGKILL). As of fossilize 0.8.0, hole-punch runs inside fossilize via \`--hole-punch\` flag (uses binpunch internally), between chmod and sign+notarize. Strip debug symbols handled INSIDE fossilize (as of fossilize 0.7.0). macOS: \`strip -x\` on unsigned copy; cross-strip from Linux silently fails (caught). Windows: skipped. NODE\_VERSION='lts'. ALL\_TARGETS: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64 + musl variants. Post-process: rename \`sentry-win-x64.exe\`→\`sentry-windows-x64.exe\`. UPX RULED OUT — destroys ELF notes. \`FOSSILIZE\_SIGN=y\` on push to main/release. CI: \`rcodesign verify\` step on darwin targets after signing. \`spawnWithRetry()\` detects SIGKILL from child and throws \`UpgradeError\` (AMFI kills binaries with invalid signatures). fossilize 0.8.1 fixes cross-compile strip crash. * **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. Telemetry opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` in \`bin.ts\` before imports. Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE ... RETURNING\`. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add install-script-only vars with \`installOnly: true\`. - -* **Custom CA loading: priority, caching, TLS error detection, and SaaS warning**: Custom CA in \`src/lib/custom-ca.ts\`: Priority: (1) \`sentry cli defaults ca-cert\` (SQLite), (2) \`NODE\_EXTRA\_CA\_CERTS\`. Cached per-process via module-level vars (\`hasResolved\` flag). \`resolve()\` concatenates custom PEM with \`rootCertificates\` (additive — Bun replaces Mozilla bundle otherwise). \`tryReadPem()\` NEVER throws — missing CA file logs warn and returns \`undefined\`. \`injectIntoNodeTls()\` uses \`tls.setDefaultCACertificates()\` (Node 24+ only; no-op on Node 22). \`TLS\_ERROR\_PATTERNS\`: 5 patterns (local issuer, verify first cert, UNABLE\_TO\_VERIFY\_LEAF\_SIGNATURE, DEPTH\_ZERO\_SELF\_SIGNED\_CERT, SELF\_SIGNED\_CERT\_IN\_CHAIN) — explicitly excludes \`CERT\_HAS\_EXPIRED\` and \`ERR\_TLS\_CERT\_ALTNAME\_INVALID\`. \`getTlsCertErrorMessage()\` walks \`error.cause\` chain with cycle detection. SaaS target + env-sourced CA → one-time warning; stored default silences it. \`\_\_resetForTests()\` resets all cached state. - - -* **DSN cache invalidation uses two-level mtime tracking (sourceMtimes + dirMtimes)**: DSN cache invalidation — two-level mtime tracking: \`sourceMtimes\` (DSN-bearing files, catches in-place edits) + \`dirMtimes\` (every walked dir, catches new files) + root mtime fast-path + 24h TTL. Dropping either map is a correctness regression. Walker emits mtimes via \`onDirectoryVisit\` hook + \`recordMtimes\` option; DSN scanner uses \`grepFiles({pattern: DSN\_PATTERN, recordMtimes: true, onDirectoryVisit})\`. \`scanCodeForFirstDsn\` stays on direct walker loop (worker init ~20ms dominates). Invariants: \`processMatch\` must record mtime for EVERY file with host-validated DSN via \`fileHadValidDsn\` flag independent of \`seen.has(raw)\`. \`scanDirectory\` catch MUST return empty \`dirMtimes: {}\`, NOT partial map; \`ConfigError\` re-throws. - -* **E2E test infrastructure: fixture.ts, helpers.ts, mocks/, and test:e2e script**: \`test/fixture.ts\`: \`getCliCommand()\` returns \`\[SENTRY\_CLI\_BINARY]\` or \`\[process.execPath, 'run', 'src/bin.ts']\`. \`createE2EContext(configDir, serverUrl)\` sets env \`SENTRY\_AUTH\_TOKEN: ''\`, \`SENTRY\_TOKEN: ''\`, \`SENTRY\_CLI\_NO\_TELEMETRY: '1'\`, \`SENTRY\_URL: serverUrl\`. \`setAuthToken(token)\` calls \`dbSetAuthToken(token, undefined, undefined, { host: serverUrl })\` then \`closeDatabase()\`, scoped to mock server URL for host-scoping fetch-layer guard. \`test/mocks/server.ts\`: \`createMockServer(routes, options?)\` uses Node \`http.createServer\` (migrated from \`Bun.serve\`). \`test/mocks/multiregion.ts\`: \`createMultiRegionMockServer()\` — US+EU regions + control silo; \`selfHostedMode\`, \`singleRegionMode\`. \`test:e2e\` runs WITHOUT \`--isolate --parallel\`. \`test:unit\` runs WITH \`--isolate --parallel\`. \`telemetry-exit.test.ts\` verifies \`@sentry/core\` patch adds \`.unref()\` to flush timers. Project uses vitest (migrated from bun:test); \`vitest.config.ts\` at repo root. - - -* **generate-command-docs.ts: fragment system, markers, and gitignored output**: \`script/generate-command-docs.ts\` (463 lines): bootstraps stub \`src/generated/skill-content.ts\` if missing, then dynamically imports \`src/app.js\`, \`src/lib/introspect.js\`, \`src/lib/env-registry.js\`. Generates per-route pages to \`docs/src/content/docs/commands/{name}.md\` (gitignored). Each page = auto-generated content + \`\\` marker + optional fragment from \`docs/src/fragments/commands/{name}.md\` (committed). \`GLOBAL\_FLAG\_NAMES = \["json","fields","help","helpAll","log-level"]\` excluded from per-command docs. \`SKIP\_ROUTES = \["help"]\`. Also generates \`index.md\` (commands table) and \`configuration.md\` (env vars + \`docs/src/fragments/configuration.md\`). Cleans up legacy \`docs/src/content/docs/commands/cli/\` subdirectory on each run. - - -* **generate-docs-sections.ts: in-place marker injection into committed files**: \`script/generate-docs-sections.ts\` (555+ lines): injects auto-generated content into committed files between named marker pairs. Marker styles: HTML \`\\` (\`.md\`); MDX \`{/\* GENERATED:START name \*/}\` (\`.mdx\`). \`--check\` flag: dry-run, exits 1 if stale. 13 sections across 5 files: \`contributing.md\` (project-structure, dev-prereq, build-commands), \`DEVELOPMENT.md\` (oauth-scopes, dev-env-vars, dev-prereq, build-toolchain), \`self-hosted.md\` (oauth-scopes, self-hosted-env-vars), \`README.md\` (dev-prereq, library-prereq, dev-scripts), \`getting-started.mdx\` (platform-support). Version extractors (\`extractPnpmVersion\`, \`extractNodeVersion\`) \*\*throw on mismatch\*\* — no silent fallbacks. No Bun references remain. CI \`check-generated\` job runs with \`--check\` flag. - - -* **generate-docs-sections.ts: project-structure tree rendering invariant**: In \`generateProjectStructure()\` (line 178 comment): groups (route directories) always use \`├──\` prefix regardless of position — because standalones always follow groups. Standalone entries include \`help.ts\` (added manually before sort); last standalone uses \`└──\`, others use \`├──\`. Both groups and standalones sorted alphabetically within their sections. Output is a fenced code block with \`cli/\` tree. - - -* **generate:docs pipeline: 4-script sequence, prerequisites, and output ownership**: Master orchestrator: \`generate:docs\` runs 4 scripts in sequence: (1) \`generate:parser\` → \`script/generate-parser.ts\`, (2) \`generate:command-docs\` → \`script/generate-command-docs.ts\`, (3) \`generate:skill\` → \`script/generate-skill.ts\`, (4) \`generate:docs-sections\` → \`script/generate-docs-sections.ts\`. Prerequisite for: \`dev\`, \`build\`, \`build:all\`, \`bundle\`, \`typecheck\`, \`test:unit\`, \`test:changed\`, \`test:e2e\`. Output ownership: \`docs/src/content/docs/commands/\` and \`docs/src/content/docs/configuration.md\` are gitignored (fully generated). \`docs/src/fragments/\` files are committed source of truth (hand-written custom content). \`DEVELOPMENT.md\`, \`README.md\`, \`contributing.md\`, \`self-hosted.md\`, \`getting-started.mdx\` are committed but have in-place injected sections between named markers. +* **E2E test infrastructure: fixture.ts, helpers.ts, mocks/, and test:e2e script**: E2E test infrastructure: \`test/fixture.ts\`: \`getCliCommand()\` returns \`\[SENTRY\_CLI\_BINARY]\` or \`\[process.execPath, 'run', 'src/bin.ts']\`. \`createE2EContext(configDir, serverUrl)\` sets env \`SENTRY\_AUTH\_TOKEN: ''\`, \`SENTRY\_TOKEN: ''\`, \`SENTRY\_CLI\_NO\_TELEMETRY: '1'\`, \`SENTRY\_URL: serverUrl\`. \`setAuthToken(token)\` calls \`dbSetAuthToken(token, undefined, undefined, { host: serverUrl })\` then \`closeDatabase()\`, scoped to mock server URL for host-scoping fetch-layer guard. \`test/mocks/server.ts\`: \`createMockServer(routes, options?)\` uses Node \`http.createServer\`. \`test/mocks/multiregion.ts\`: \`createMultiRegionMockServer()\` — US+EU regions + control silo; \`selfHostedMode\`, \`singleRegionMode\`. \`telemetry-exit.test.ts\` verifies \`@sentry/core\` patch adds \`.unref()\` to flush timers. * **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (schema v16): every token bound to issuing host via \`auth.host\` column, lazy-migrated from boot-env. Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source. Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch (auth login/logout bypass via \`skipUrlTrustCheck\`); (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor (breaks IAP re-auth). \`HostScopeError\` has overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`. Test helpers: \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\`. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; \`SENTRY\_URL\` alone doesn't anchor. Multi-region tests need \`registerTrustedRegionUrls\`. - -* **InkUI teardown order — 6 steps, all try/catch, torndown guard prevents double-unmount**: \`InkUI.tearDown()\` must follow this order: (1) stop tip-rotation interval; (2) detach SIGINT listener + \`store.setRequestCancel(undefined)\`; (3) \`instance.clear()\`; (4) \`instance.unmount()\`; (5) restore alternate screen \`\x1b\[?1049l\`; (6) \`freshStdin.setRawMode(false)\` + \`.pause()\` + \`.destroy()\`. \`torndown: boolean\` guard prevents double-unmount (throws on some platforms). \`cancelRequested\` guard: second Ctrl+C → \`process.exit(130)\`. Every step wrapped in try/catch. - - -* **InkUI vs OpenTUI decision — pure JS wins over native binary cost**: Chose Ink over OpenTUI because OpenTUI added ~10.7 MB to the binary (libopentui.so + ~12k-line FFI bindings) and required alternate-screen buffer + post-dispose stderr replay. Ink is pure JS, writes incrementally to stdout so log lines land in scrollback. UI evolution: ClackUI → OpenTuiUI (PR 4) → InkUI (current). \`exitOnCtrlC: false\` routes Ctrl+C through prompt cancellation; \`patchConsole: false\` keeps \`console.\*\` flowing to real stdout (Sentry SDK breadcrumbs not swallowed). - - -* **isSentrySaasUrl vs isSaaSTrustOrigin: two intentional SaaS checks**: \`src/lib/sentry-urls.ts\` exports two SaaS-detection helpers with intentional split: (1) \`isSentrySaasUrl(url)\` — hostname-only check (\`sentry.io\` or \`\*.sentry.io\`), accepts any protocol/port. Used for routing/UX: custom-headers warning, \`getSentryBaseUrl\`/\`isSelfHosted\`, region resolution skip, telemetry \`is\_self\_hosted\` tag. (2) \`isSaaSTrustOrigin(url)\` — stricter: additionally requires \`https:\` and default port. Used for security decisions: token-host trust comparison, sentryclirc URL trust check, URL-arg trust, login refusal. Rule: hostname-only for routing/UX (don't break users behind TLS-terminating proxies with \`http://sentry.io\`); strict for credential scoping. JSDoc on \`isSentrySaasUrl\` points callers to \`isSaaSTrustOrigin\` for security contexts. Keep both implementations in sync re: hostname matching. - - -* **Issue resolve --in grammar: release + @next + @commit sentinels**: \*\*Issue resolve --in grammar + repo\_cache SQLite table\*\*: \`sentry issue resolve --in\` grammar: (a) omitted→immediate, (b) \`\\`→\`inRelease\`, (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD via \`src/lib/git.ts\`, (e) \`@commit:\@\\`→explicit. \`parseResolveSpec\` splits on LAST \`@\` for scoped names. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. Repo matching uses \`listRepositoriesCached(org)\` (7-day SQLite cache in \`repo\_cache\` table, schema v14). Always use \`listAllRepositories\` (paginated via \`API\_MAX\_PER\_PAGE\`) — never \`listRepositories\` (silently caps ~25). \`setCachedRepos\` wrapped in try/catch so read-only DBs (macOS \`sudo brew install\`) don't crash commands. - - -* **Node SEA ink sidecar: node:sea.getAsset() replaces Bun /$bunfs/ virtual FS**: (architecture) Node SEA ink sidecar: \`node:sea.getAsset()\` replaces Bun \`/$bunfs/\` virtual FS. Ink UI sidecar embedded via \`fossilize --assets dist-build/ink-app.js\`; asset key = raw CLI arg. At runtime: \`sea.getRawAsset('dist-build/ink-app.js')\`. Main bundle never calls \`import('ink')\` — sidecar pre-bundled by text-import-plugin. Dual-mode: detect SEA via \`createRequire(import.meta.url)('node:sea')\` with try/catch fallback. \`useSnapshot: true\` BROKEN. \`useCodeCache: true\` ~15% startup improvement but platform-specific V8 blob. Suppress \`ExperimentalWarning: SQLite\`: \`process.on('warning', ...)\` at very top of \`src/bin.ts\` BEFORE any imports. fossilize asset manifest key = \`basename(manifestPath)\`; entry keys = \`entry.file\`. \`new Worker(new URL(...))\` HANGS in SEA — use Blob+URL.createObjectURL. - - -* **npm bundle (script/bundle.ts): esbuild CJS with bun:sqlite polyfill, external Ink, debug ID injection**: Entry: \`src/index.ts\` → \`dist/index.cjs\` (CJS, minified, Node 22). External: \`node:\*\`, \`ink\`, \`react\`, \`react-reconciler\`, \`yoga-layout\` (use top-level await, can't emit CJS). \`bunSqlitePlugin\` redirects \`bun:sqlite\` → \`globalThis.\_\_bun\_sqlite\_polyfill\` (injected by \`node-polyfills.ts\`). \`sentrySourcemapPlugin\` (onEnd): injects debug IDs, replaces \`PLACEHOLDER\_DEBUG\_ID\` with real UUID (same length → sourcemap positions valid), uploads only if \`SENTRY\_AUTH\_TOKEN\` set. \`textImportPlugin\` handles \`with { type: 'file' }\`. \`dist/bin.cjs\`: shebang, Node 22.12 floor check, warning suppression, calls \`require('./index.cjs').\_cli()\`. Build script: \`pnpm run bundle\`. Exits with code 1 if \`SENTRY\_CLIENT\_ID\` missing. - - -* **Response cache hit invisibility — synthetic Response carries no marker**: Response cache hit invisibility — synthetic Response from \`getCachedResponse()\` in \`src/lib/response-cache.ts\` is indistinguishable from network. Solved via module-level \`lastCacheHitAgeMs\`: set on hit, cleared at top of \`authenticatedFetch()\` per-call (single-process CLI = race-free). \`src/lib/cache-hint.ts\` provides \`formatCacheHint()\` (\`"cached · 3m ago · use -f to refresh"\`) and \`appendCacheHint(existingHint)\` (joins with \` | \`). Wired in \`buildCommand\` (\`src/lib/command.ts\`): \`appendCacheHint(returned?.hint)\` runs only when generator returns a \`CommandReturn\` — bare \`return;\` paths (e.g. \`--web\`) skip the hint. Same chokepoint can host future cross-cutting hint decorators. Test-only \`\_setLastCacheHitAgeForTesting(ms)\` exposes state. - - -* **safe-read.ts wraps isRegularFile + Bun.file().text() for FIFO-safe user-path reads**: \`src/lib/safe-read.ts\` \`safeReadFile(path, operation)\` combines \`isRegularFile()\` + file read + broad error swallow (FIFO/ENOENT/EACCES/EPERM/EISDIR/ENOTDIR). Do NOT use for committed config loads — swallows EPERM/EISDIR, making \`chmod 000 .sentryclirc\` manifest as confusing 'no auth token'. For loud permission surfacing, call \`fs.promises.stat\` directly, gate on \`isFile()\`, catch only ENOENT/EACCES. General rule: bare \`catch {}\` swallows \`EACCES\`/\`EPERM\`/\`EIO\` — always check \`(err as NodeJS.ErrnoException).code === 'ENOENT'\` and re-throw anything else. \`read-files.ts\`/\`workflow-inputs.ts\` use direct stat to reuse one stat for size-gating. Test with real \`mkfifo\` + short timeout as hang detector. - - -* **SDK invoke path bypasses Stricli parsing — no defaults, no parsePeriod**: SDK invoke path bypasses Stricli parsing — no defaults, no parsePeriod: \`src/lib/sdk-invoke.ts\` \`buildInvoker()\` calls command \`func()\` directly with pre-built flags, skipping Stricli's \`parseInputsForFlag\`. Fix: \`resolveCommand()\` returns \`{ handler, flagDefs }\` (type \`ResolvedCommand\`); \`applyFlagDefaults(flags, flagDefs)\` applies parsed defaults — for \`kind:'parsed'\` + string default + \`parse\` fn, calls \`flag.parse(flag.default)\` (catches errors → undefined + log.debug); for other kinds returns raw \`def.default\`; skips already-set flags. Both streaming and capture call sites call \`applyFlagDefaults\` before handler invocation. \`json: true\` applied AFTER via spread — cannot be overridden. \`commandCache\` stores \`{ loader, flagDefs }\`. Flag defs from \`command.parameters?.flags ?? {}\` (includes injected globals — harmless, stripped by \`cleanRawFlags\`). \`buildRunner\` does NOT need this fix — routes through Stricli's \`run()\`. Variadic parsed array defaults not handled (no current flags trigger this). Parse functions never use \`this\` so missing context binding is safe. Silent catch blocks MUST include \`log.debug()\` per AGENTS.md. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. - -* **Sentry API: events require org+project, issues have legacy global endpoint**: (architecture) Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` via \`getControlSiloUrl()\`. (3) Chunk upload endpoint returns camelCase (\`chunkSize\`) — exception to snake\_case. (4) 204/205 responses throw \`ApiError\` not \`TypeError\` from \`rawApiRequest\`. (5) Magic \`@\` selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\`; \`SELECTOR\_MAP\` case-insensitive; \`resolveSelector\` calls \`listIssuesPaginated\` with \`perPage: 1\`; supports org-prefixed \`sentry/@latest\`. (6) \`issue resolve --in\` grammar: omitted→immediate, \`\\`→\`inRelease\`, \`@next\`→\`inNextRelease\`, \`@commit\`→auto-detect git HEAD. \`parseResolveSpec\` splits on LAST \`@\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. - - -* **Sentry CLI authenticated fetch architecture with response caching**: Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415); fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty). Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — GET response cache checked BEFORE fetch, so prior test cache hits produce 0 calls. - - -* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: Resolve-target cascade: (1) CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite defaults, (4) DSN auto-detection, (5) directory name inference. SENTRY\_PROJECT supports \`org/project\` combo — SENTRY\_ORG ignored if set. Schema v13 merged \`defaults\` table into \`metadata\` KV with keys \`defaults.{org,project,telemetry,url}\`; getters/setters in \`src/lib/db/defaults.ts\`. Prefer dedicated SQLite tables + migrations over \`metadata\` KV for non-trivial caches. Hidden global \`--org\`/\`--project\` flags: \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes, \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. No short aliases (\`-p\` conflicts). \`@sentry/api\` SDK: wrap types at \`src/lib/api/\*.ts\` with \`as unknown as SentryX\` casts; never leak to commands. \`unwrapResult\`/\`unwrapPaginatedResult\` must stay CLI-owned. \`apiRequestToRegion\` auto-sets JSON Content-Type; \`rawApiRequest\` preserves strings. +* **Sentry API: events require org+project, issues have legacy global endpoint**: Sentry API quirks: (1) Events need org+project (\`/projects/{org}/{project}/events/{id}/\`); issues use legacy global \`/api/0/issues/{id}/\`; traces need org only. (2) \`/users/me/\` returns 403 for OAuth — use \`/auth/\` via \`getControlSiloUrl()\`. (3) Chunk upload returns camelCase (\`chunkSize\`) — exception to snake\_case. (4) 204/205 responses throw \`ApiError\` not \`TypeError\`. (5) Magic \`@\` selectors: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\`; \`SELECTOR\_MAP\` case-insensitive. (6) \`issue resolve --in\`: omitted→immediate, \`\\`→inRelease, \`@next\`→inNextRelease, \`@commit\`→auto-detect git HEAD. \`parseResolveSpec\` splits on LAST \`@\`. API requires \`statusDetails.inCommit: {commit, repository}\`. Repo matching uses \`listRepositoriesCached\` (7-day SQLite cache, schema v14); always use \`listAllRepositories\` (paginated) — never \`listRepositories\` (caps ~25). * **sentry local command: Hono+Spotlight SDK server with SSE tail output**: \`sentry local\` (default: \`serve\`) and \`sentry local run\` — both \`auth: false\`. Default port 8969. Uses \`@spotlightjs/spotlight/sdk\` (\`createSpotlightBuffer\`/\`pushToSpotlightBuffer\`) for envelope buffering; custom Hono HTTP server for ingest. Endpoints: \`POST /stream\`, \`POST /api/:projectId/envelope\[/]\`, \`GET /stream\` (SSE), \`GET /health\`. CORS restricted to localhost origins only. Browser SDK \`sendBeacon\` workaround: overrides \`text/plain\` → \`application/x-sentry-envelope\` when \`sentry\_client\` query param starts with \`sentry.javascript.browser\`. \`sentry local run\` injects \`SENTRY\_SPOTLIGHT\`, \`NEXT\_PUBLIC\_SENTRY\_SPOTLIGHT\`, \`SENTRY\_TRACES\_SAMPLE\_RATE=1\` into child env. Attach mode: if server already running, connects as SSE consumer (manual SSE parser — no \`EventSource\`). Formatters in \`src/lib/formatters/local.ts\`: \`sanitize()\` strips ANSI/control/bidi chars; source inferred from \`sdk.name\` → \`\[SERVER]\`/\`\[BROWSER]\`/\`\[MOBILE]\`. - -* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 — enables deterministic retention checks. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`) live in \`hex-id.ts\`. Three Sentry span APIs: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\` — list/search. \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\` list via \`orderFieldNames()\` in \`explore.ts\`. - - -* **src/cli.ts: middleware chain, completion optimization, sensitive argv redaction**: \`src/cli.ts\` exports \`startCli()\`, \`runCli()\`, \`runCompletion()\`. Middleware chain (innermost-first): \`\[seerTrialMiddleware, autoAuthMiddleware]\` — auth is outermost. \`autoAuthMiddleware\` uses \`isatty(0)\` not \`process.stdin.isTTY\` (Bun returns undefined). \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\` to skip \`@sentry/node-core\` lazy-require (~280ms). \`redactArgv()\` handles \`--flag=value\` and \`--flag \\` forms; \`SENSITIVE\_ARGV\_FLAGS\` includes \`token\` and \`auth-token\`. \`reportUnknownCommand()\` wrapped in try/catch — telemetry must never crash CLI. \`preloadProjectContext()\` calls \`captureEnvTokenHost()\` BEFORE any env mutation. - - -* **stdin-reopen.ts: forwardFreshTtyToStdin() idempotency and isTTY backfill pattern**: \`src/lib/init/stdin-reopen.ts\` exports \`forwardFreshTtyToStdin(deps?)\` returning a \`Disposable\` (\`TtyForwardingHandle\`) — always non-null so callers use \`using tty = forwardFreshTtyToStdin()\` without null-checking. Idempotency: repeated calls return \`NOOP\_HANDLE\` (secondary callers don't tear down primary's install). isTTY backfill: captures \`previousIsTty\` before touching; if \`undefined\`, uses \`Object.defineProperty\` to set \`isTTY: true, writable: true, configurable: true\` — required because Ink/clack gates \`setRawMode(true)\` on \`input.isTTY\`, so without backfill the fresh fd stays in canonical mode. \`pause\`/\`resume\` replaced with noops to prevent Bun kqueue EINVAL on fd-0 transitions. \`TtyDeps\` allows injection of \`openTty\` and \`isTty\` for test isolation. - - -* **Zod schema on OutputConfig enables self-documenting JSON fields in help and SKILL.md**: Zod schema on OutputConfig enables self-documenting JSON fields: List commands register \`schema?: ZodType\` on \`OutputConfig\\`. \`extractSchemaFields()\` produces \`SchemaFieldInfo\[]\` from Zod shapes. \`buildFieldsFlag()\` enriches \`--fields\` brief; \`enrichDocsWithSchema()\` appends fields to \`fullDescription\`. Schema exposed as \`\_\_jsonSchema\` on built commands — \`introspect.ts\` reads it into \`CommandInfo.jsonFields\`, \`help.ts\` and \`generate-skill.ts\` render it. For \`buildOrgListCommand\`/\`dispatchOrgScopedList\`, pass \`schema\` via \`OrgListConfig\`. - ### Decision - -* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands use \`\ \\` positional pattern (Intent-First Correction UX): target is optional \`org/project\`. Use opportunistic arg swapping with \`log.warn()\` when args are wrong order — when intent is unambiguous, do what they meant. Normalize at command level, keep parsers pure. Model after \`gh\` CLI. Exception: \`auth\` uses \`defaultCommand: "status"\` (no viewable entity). Routes without defaults: \`cli\`, \`sourcemap\`, \`repo\`, \`team\`, \`trial\`, \`release\`, \`dashboard/widget\`. - - -* **Migrated to node**: Migrated to Node.js (from Bun). Migration complete as of fossilize 0.7.0 update. Stack: pnpm, vitest, tsx, Node SEA via fossilize. PRs #1017, #1018, #1019 on getsentry/cli. Benchmarks vs v0.34.0 (Bun): download 32MB→30MB, startup ~1s both, shell completions 180ms→150ms. All macOS binaries signed+notarized. fossilize handles SEA builds with V8 code cache on linux+macOS, strips debug symbols automatically. \`bun.lock\` deleted, \`vitest.config.ts\` added, all test files migrated to vitest. \`script/build.ts\` uses fossilize (\`--no-bundle\`) with esbuild for bundling — does NOT use \`Bun.build({ compile: true })\`. - - -* **Node.js slim build flags for SEA binary size reduction**: Node.js configure.py size-reduction flags for SEA builds: \`--with-intl=small-icu\` (English-only ICU, saves ~26-28 MiB — biggest win; CLI uses hardcoded en-US/sv-SE locales, safe); \`--with-intl=none\` (saves ~28-30 MiB but breaks \`Intl.NumberFormat\`/\`String.normalize()\` — NOT safe for this CLI); \`--without-inspector\` saves ~2-4 MiB; \`--without-amaro\` saves ~0.5 MiB; \`--v8-disable-maglev\` saves ~1-2 MiB; \`--enable-lto\` saves ~3-5 MiB. AVOID: \`--without-ssl\` (breaks HTTPS), \`--without-lief\` (BREAKS SEA), \`--without-sqlite\` (BREAKS CLI — uses node:sqlite), \`--disable-single-executable-application\` (BREAKS EVERYTHING), \`--v8-lite-mode\` (10x slower). Custom build deferred indefinitely — requires 5 native CI runners, ~3.5h cold build vs 5min fossilize. Cross-compilation from Linux to darwin NOT officially supported. - * **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. @@ -128,294 +35,51 @@ * **--json schema stability: collapse=organization drops nested org fields**: --json schema + response cache gotchas: (1) \`?collapse=organization\` shrinks \`organization\` to \`{id, slug}\` — silent --json regression. \`jsonTransform\` re-hydrates \`organization.name\` via \`resolveOrgDisplayName\` against \`org\_regions\` cache. (2) \`buildCacheKey()\` normalizes URL with sorted query params, so \`invalidateCachedResponse(baseUrl)\` misses entries with query suffixes. Use \`invalidateCachedResponsesMatching(prefix)\` (raw \`startsWith()\`); \`buildApiUrl()\` always emits trailing slash → safe prefix. (3) When \`jsonTransform\` is set, \`jsonExclude\` and \`filterFields\` are NOT applied — transform must call \`filterFields(result, fields)\` and omit excluded keys itself. - -* **API tests must use useTestConfigDir to isolate disk response cache**: \*\*API tests must use useTestConfigDir to isolate disk response cache\*\*: Tests mocking \`globalThis.fetch\` MUST call \`useTestConfigDir()\` + \`setAuthToken()\`. \`authenticatedFetch\` checks a filesystem response cache (\`~/.sentry/cache/responses/\`) BEFORE calling fetch — without per-test dirs, test N's response is served to test N+1. TTL tiers in \`classifyUrl()\`: stable=5min, volatile=60s (issues/logs), immutable=24h (events/traces by ID). Also: \`@sentry/api\` SDK calls \`\_fetch(request)\` with no init — fall back to \`input.headers\` when \`init\` is undefined (prevents HTTP 415). SDK returns \`data={}\` for empty/204 responses — always guard with \`Array.isArray(data)\` before \`.map()\`. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) for Link header pagination. - - -* **batch-queue.ts: 404 from upstream treated as transient — provider never disabled**: Trap: \`BatchProvider.submit()\` returns \`null\` for any non-401/403 HTTP error, including 404. \`submitBatch()\` treats \`null\` as transient and falls back — no disable happens. For providers that don't implement \`/v1/messages/batches\` (e.g. MiniMax), this causes a wasted HTTP round-trip every 30s forever. Fix: add \`"not-found"\` return value for 404 in both Anthropic and OpenAI submit methods. In \`submitBatch()\`, handle \`"not-found"\` with provider-level disable: add \`disabledBatchProviders: Set\\` (keyed by provider name), persist to \`kv\_meta\` via \`setKV()\`, restore on startup. Add fast-path bypass in both \`flush()\` and \`prompt()\`. Provider-level (not per-session) because the URL is baked in at construction — one provider per process. \`groupKey()\` = \`authFingerprint(cred)|providerID\`; per-credential disable was removed in favor of per-session historically. - - -* **Bun --isolate coverage inflates LF count for files with verbose comments/JSDoc**: Bun --isolate coverage inflates LF count: under \`bun test --isolate --parallel\` (CI's \`test:unit\`), Bun's coverage instrumentation counts comments, blank lines, type annotations, and closing braces as 'executable'. E.g. \`zstd-transport.ts\` LF=165 locally → 210 under --isolate, dropping coverage 99%→78%. Workaround: trim verbose inline comments inside function bodies; move rationale to JSDoc above the function. Statement coverage stays 100% — 'missing' lines are non-executable. - - -* **Bun /$bunfs/ virtual FS uses JS parser — embedded .tsx files fail on TS syntax**: \*\*Bun \`/$bunfs/\` virtual FS + Ink TUI sidecar embedding\*\*: Files embedded via \`with { type: "file" }\` run from \`/$bunfs/root/\` using a JS parser (not TypeScript) — raw \`.tsx\` crashes on \`import { type Foo }\`. Fix: pre-bundle \`.tsx\` → \`.js\` via esbuild before embedding (\`script/text-import-plugin.ts\`). \`/$bunfs/\` has no \`node\_modules\` — inline all deps; use \`createRequire\` banner for CJS deps. Only \`node:\*\` builtins external. Query strings in \`/$bunfs/\` paths cause ENOENT. Related: Ink TUI sidecar (\`ink-app.tsx\`) must be fully self-contained — main bundle must NOT import \`ink\`/\`react\` separately; call \`app.mountApp()\` from the sidecar only to avoid dual-React "Invalid hook call" errors. - - -* **Bun 1.3.11 tty.ReadStream leaks libuv handle — process.stdin.unref is undefined**: Bun 1.3.11 macOS TTY bug: \`process.stdin\` via kqueue \`EVFILT\_READ\` fails to deliver keystrokes when fd 0 is inherited via \`exec bin \ -* **check:fragments only validates file existence — not subcommand coverage depth**: RESOLVED in PR #1024. \`script/check-fragments.ts\` now has Check 5: for each route with >1 command, verifies the fragment mentions each subcommand via a heading (outside fenced code blocks) or \`sentry \ \\` code reference. Default commands (e.g., \`local serve\`) are handled — if the fragment contains \`sentry \\` bare, the default command is considered covered. Default commands detected from route map \`defaultCommand\` field. Fenced code block content stripped before heading scan to avoid bash-comment false positives. Warnings (not errors) by default; \`--strict\` flag makes them errors. - - -* **dashboard revisions/restore and issue events subcommands are undocumented in fragment files**: RESOLVED in PR #1024. \`docs/src/fragments/commands/dashboard.md\` now documents \`revisions\` and \`restore\`. \`docs/src/fragments/commands/issue.md\` now documents \`events\` and \`@latest\`/\`@most\_frequent\` selectors. \`docs/src/fragments/commands/cli.md\` now documents \`defaults\` and \`import\`. \`check:fragments\` (Check 5) now validates subcommand coverage within fragment files — not just file existence. When adding new subcommands, always update the corresponding fragment in \`docs/src/fragments/commands/\` AND run \`pnpm run check:fragments\` to verify coverage. - - -* **DEVELOPMENT.md hand-written prose is not covered by any staleness check**: RESOLVED in PR #1024. \`DEVELOPMENT.md\` hand-written prose is now wrapped in \`GENERATED:START/END\` markers: \`dev-prereq\` (lines 4-7) and \`build-toolchain\` (lines 91-97). These sections are now auto-generated from \`package.json\` by \`generate-docs-sections.ts\` and validated by \`check:docs-sections --check\`. The only remaining non-generated prose in \`DEVELOPMENT.md\` is the OAuth app setup instructions and architecture description — these don't reference toolchain versions. \`generate-docs-sections.ts\` no longer contains any Bun references; \`extractPnpmVersion()\` and \`extractNodeVersion()\` throw on mismatch. - - -* **gateway-smoke.test.ts: dynamic import('@loreai/gateway') requires dist/ to be built first**: Trap: \`packages/opencode/test/gateway-smoke.test.ts\` uses \`await import('@loreai/gateway')\` dynamically. The gateway package only exports from \`./dist/\` (no source entry point). Without a prior build step, the test fails with 'Cannot find module @loreai/gateway'. This is a pre-existing issue — the test requires \`bun run build\` in the gateway package before running. The 2 failures in \`gateway-smoke.test.ts\` are pre-existing and unrelated to application changes. - - -* **generate-docs-sections.ts still references Bun — extractBunVersion() silently falls back to hardcoded '1.3'**: RESOLVED in PR #1024. \`generate-docs-sections.ts\` previously had \`BUN\_VERSION\_RE\` and \`extractBunVersion()\` that silently returned hardcoded \`'1.3'\` when \`packageManager\` was \`pnpm@10.11.0\`. Fixed: replaced with \`extractPnpmVersion()\` and \`extractNodeVersion()\` that \*\*throw on mismatch\*\* instead of silently falling back. \`generateDevPrereq()\`, \`generateDevPrereqContributing()\`, \`generateLibraryPrereq()\` now reference Node.js + pnpm. \`DEVELOPMENT.md\` lines 5 and 91 are now wrapped in \`GENERATED:START/END\` markers so they can't drift again. No Bun references remain in the script. - - -* **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, Bun's fetch dispatcher keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`. + +* **existsSync+realpathSync TOCTOU: catch ENOENT instead**: Trap: \`if (!existsSync(p)) return resolve(p); return realpathSync(p)\` looks safe but has a TOCTOU race — the file can be created/deleted between the check and the call. Also: using \`realpathSync\` inside an async function is inconsistent — prefer \`await realpath(p)\` from \`node:fs/promises\`. Fix: call \`await realpath(p)\` directly; catch \`ENOENT\` to fall back to \`resolve(p)\`; log non-ENOENT errors via \`logger.debug(msg, error)\` before falling back. When mocking in vitest, mock \`node:fs/promises\` not \`node:fs\` when the production code uses async \`realpath\`. * **Multi-region fan-out: distinguish all-403 from empty orgs with hasSuccessfulRegion flag**: In \`listOrganizationsUncached\` (\`src/lib/api/organizations.ts\`), \`Promise.allSettled\` collects multi-region results. Don't use \`flatResults.length === 0\` to detect all-regions-failed — a region returning 200 OK with zero orgs pushes nothing into \`flatResults\`. Track a \`hasSuccessfulRegion\` boolean on any \`"fulfilled"\` settlement. Only re-throw 403 \`ApiError\` when \`!hasSuccessfulRegion && lastScopeError\`. - -* **pnpm nested script invocation loses TTY — inline tsx to fix**: Trap: \`"cli": "pnpm tsx src/bin.ts"\` creates nested pnpm invocations (pnpm → /bin/sh → pnpm → /bin/sh → tsx → node). Each inner pnpm layer pipes stdio, so \`process.stdin.isTTY\` and \`process.stdout.isTTY\` are \`undefined\` in the final Node process. This breaks \`sentry init\` at three gates: \`isNonInteractiveContext()\` (init.ts:182), \`isInteractiveTerminal()\` (factory.ts:62), and the wizard preamble (wizard-runner.ts:376). Fix: inline tsx directly — \`"cli": "tsx --import ./script/require-shim.mjs src/bin.ts"\` and same for \`dev\`. Single-layer \`pnpm run\` uses \`stdio: 'inherit'\`; nested pnpm does not. Approach B (\`node --import tsx/esm ...\`) rejected as fragile (tsx internal API). Approach C (shell wrapper) rejected as non-portable. Keep the \`tsx\` alias for non-interactive scripts. - - -* **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: \`process.stdin.isTTY\` unreliable — use \`isatty(0)\` from \`node:tty\`. Bun's single-file binary can leave \`process.stdin.isTTY === undefined\` on TTY fds. \`@clack/core\` gates \`setRawMode(true)\` on \`input.isTTY\`, silently disabling raw mode. Fix: backfill \`process.stdin.isTTY = true\` when \`isatty(0)\` confirms. Debugging: \`src/lib/init/tty-diagnostics.ts\` \`dumpTtyDiagnostics(label)\` — no-op unless \`SENTRY\_INIT\_DIAGNOSTICS=1\`. - - -* **SQLite transaction() ROLLBACK can throw, discarding original error**: (gotcha) SQLite transaction ROLLBACK error-swallowing trap: In \`src/lib/db/sqlite.ts\`, \`transaction()\` catches errors and runs \`this.db.exec('ROLLBACK')\`. If ROLLBACK itself throws, the original error is lost. Fix: \`const origErr = e; try { this.db.exec('ROLLBACK'); } catch (rbErr) { log.debug(...); } throw origErr;\` - - -* **strip fails on Node SEA binaries — must strip BEFORE fossilize injection**: Strip debug symbols must happen BEFORE fossilize SEA injection. Trap: \`strip --strip-unneeded\` on a plain Node binary saves ~17 MiB and still runs — looks like it should work on the final SEA binary too. But after postject injects the SEA blob, \`strip\` fails: 'section .text can't be allocated in segment 2'. Fix: as of fossilize 0.7.0, stripping is built into fossilize itself — it strips the copied binary (already unsigned for macOS/Windows) BEFORE calling postject. Cross-strip from Linux to macOS silently fails (caught); native macOS runners strip correctly with \`strip -x\`. Windows skipped (no debug symbols). \`stripCachedNodeBinaries()\` was removed from \`script/build.ts\` in fossilize 0.7.0 update — fossilize handles it natively. - - -* **UPX destroys ELF notes — incompatible with Node SEA binaries**: Trap: UPX compresses Node binaries from 99 MiB to 25 MiB and the compressed binary still runs — looks like a huge win. But UPX rewrites the entire ELF structure: original binary has 2 ELF notes (NT\_GNU\_BUILD\_ID + NT\_GNU\_ABI\_TAG), UPX'd binary has 0 notes and 0 sections. NODE\_SEA\_BLOB is stored as an ELF note — UPX destroys it. Fix: use \`strip --strip-unneeded\` instead, BUT only on the plain Node binary BEFORE fossilize SEA injection. After injection, \`strip\` fails with 'section .text can't be allocated in segment 2' — the SEA blob corrupts the ELF section-to-segment mapping. Strip the \`.node-cache/\` binaries before calling fossilize. Saves ~17 MB raw / ~4 MB compressed. Strip is idempotent — already-stripped binaries are unchanged. Recommended order: strip cached Node → fossilize (inject) → binpunch → gzip. - * **useTestConfigDir afterEach: never delete CONFIG\_DIR\_ENV\_VAR — always restore previous value**: Trap: deleting \`process.env.SENTRY\_CONFIG\_DIR\` in \`afterEach\` looks like proper cleanup. But \`preload.ts\` always sets \`SENTRY\_CONFIG\_DIR\`, so \`savedConfigDir\` is always defined — deleting it causes subsequent test files' module-level code or \`beforeEach\` hooks to read \`undefined\`. Fix: always restore the previous value, never delete. The \`else { delete process.env\[CONFIG\_DIR\_ENV\_VAR] }\` branch is intentionally omitted in \`test/helpers.ts\` \`useTestConfigDir\`. Same principle applies in \`test/fixture.ts\` \`setAuthToken()\` finally block — the delete there is acceptable only because it's a scoped try/finally restore, not a test lifecycle hook. -* **Vitest worker pool requires pool:forks + UV\_USE\_IO\_URING=0 on GitHub Actions**: Vitest/CI gotchas: (1) GitHub Actions io\_uring crashes Node.js workers (exit 134/SIGABRT) — fix: \`pool: 'forks'\` in \`vitest.config.ts\` AND \`UV\_USE\_IO\_URING=0\` in CI. (2) Vitest 4: options must be second arg: \`test(name, { timeout }, fn)\`. (3) \`http.createServer(async ...)\` — unhandled rejections crash test server; wrap body in try/catch. (4) \`dorny/paths-filter\` diffs against base — empty commits produce all-false outputs. (5) \`node:sqlite\` requires \`--experimental-sqlite\` on Node 22. (6) Lazy \`require()\` in test fixtures bypasses Vite's \`.js→.ts\` resolver — use top-level \`import\`. (7) \`spawn(process.execPath, \[workerScript.ts])\` fails under vitest/Node — use \`spawn('tsx', \[workerScript.ts])\`. (8) ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. Project migrated from bun:test to vitest; \`bun:test\` imports will fail silently or crash CI. \`vitest.config.ts\` at repo root. Trap: new test files written during PR review may accidentally use \`bun:test\` — always grep new test files for \`bun:test\` before merging. +* **Vitest worker pool requires pool:forks + UV\_USE\_IO\_URING=0 on GitHub Actions**: Vitest/CI gotchas: (1) GitHub Actions io\_uring crashes Node.js workers (exit 134/SIGABRT) — fix: \`pool: 'forks'\` in \`vitest.config.ts\` AND \`UV\_USE\_IO\_URING=0\` in CI. (2) Vitest 4: options must be second arg: \`test(name, { timeout }, fn)\`. (3) \`http.createServer(async ...)\` — unhandled rejections crash test server; wrap body in try/catch. (4) \`node:sqlite\` requires \`--experimental-sqlite\` on Node 22. (5) Lazy \`require()\` in test fixtures bypasses Vite's \`.js→.ts\` resolver — use top-level \`import\`. (6) \`spawn(process.execPath, \[workerScript.ts])\` fails under vitest/Node — use \`spawn('tsx', \[workerScript.ts])\`. (7) ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. \`vitest.config.ts\` at repo root. \`test:e2e\` runs WITHOUT \`--isolate --parallel\`; \`test:unit\` runs WITH. -* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Whole-buffer \`regex.exec\` 12× faster per-file but ~1.6× SLOWER over 10k files — early-exit at \`maxResults\` via \`mapFilesConcurrent.onResult\` wins. (2) Literal prefilter is FILE-LEVEL gate (\`indexOf\`→skip); per-line verify breaks cross-newline patterns and Unicode length-changing \`toLowerCase\`. (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\`. (4) Wake-latch race: use latched \`pendingWake\` flag, not \`let notify=null; await new Promise(r=>notify=r)\`. (5) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (6) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator; drain uncapped, set \`truncated=true\`. Worker pool: lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads transferred via \`postMessage\` (~40% faster). \`new Worker(new URL(...))\` HANGS in SEA binaries — use Blob+URL.createObjectURL. FIFO \`pending\` queue per worker. \`ref()\`/\`unref()\` idempotent — only unref when \`inflight\` drops to 0. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. +* **Whole-buffer matchAll slower than split+test when aggregated over many files**: Grep/scan traps in \`src/lib/scan/\`: (1) Literal prefilter is FILE-LEVEL gate; per-line verify breaks cross-newline patterns. (2) \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\`. (3) Wake-latch race: use latched \`pendingWake\` flag. (4) \`mapFilesConcurrent\` filters \`null\` but NOT \`\[]\` — return \`null\` for no-op files. (5) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator. Worker pool: lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads (~40% faster). \`new Worker(new URL(...))\` HANGS in SEA binaries — use Blob+URL.createObjectURL. \`ref()\`/\`unref()\` idempotent — only unref when \`inflight\` drops to 0. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. ### Pattern - -* **clack-utils.ts filename preserved intentionally — rename deferred to next cleanup PR**: \`src/lib/init/clack-utils.ts\` filename kept (not renamed to \`wizard-utils.ts\`) to keep PR 4 diff focused on clack removal. No clack references remain in the file. \`WizardCancelledError\` lives here. \`abortIfCancelled\()\` return type uses \`Exclude\\` to narrow union types. \`FEATURE\_DISPLAY\_ORDER\` and \`CANONICAL\_STEP\_ORDER\` (12 steps) also defined here. Rename is intentionally deferred. - - -* **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race: \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600ms). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path. \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` catches both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free tests: writer must poll until bad state exists, then overwrite. - - -* **Cross-compile sentry-cli with patched Bun: drop compile.target to use selfExePath**: Cross-compile sentry-cli with patched Bun: \`Bun.build({compile})\` downloads stock Bun from npm when \`compile.target\` is set. Workaround in \`script/build.ts\`: omit \`target\` entirely so Bun uses \`selfExePath()\` as embed runtime. Only works when host OS/arch matches desired output. Escape hatch: place \`bun-\-\-v\\` in \`$CWD\`. Build requires \`SENTRY\_CLIENT\_ID\` env var. - - -* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (\`src/commands/issue/merge.ts\`): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\` — users may pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, or \`123\`. Throw \`ValidationError\` if \`new Set(ids).size < 2\`. (2) Reject \`undefined\` orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`. (3) Pass \`--into\` through \`resolveIssue()\`; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs. - - -* **findProjectsByPattern as fuzzy fallback for exact slug misses**: When \`findProjectsBySlug\` returns empty (no exact match), use \`findProjectsByPattern\` as a fallback to suggest similar projects. \`findProjectsByPattern\` does bidirectional word-boundary matching (\`matchesWordBoundary\`) against all projects in all orgs — the same logic used for directory name inference. In the \`project-search\` handler, call it after the exact miss, format matches as \`\/\\` suggestions in the \`ResolutionError\`. This avoids a dead-end error for typos like 'patagonai' when 'patagon-ai' exists. Note: \`findProjectsByPattern\` makes additional API calls (lists all projects per org), so only call it on the failure path. - -* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. - - -* **Hidden --org/--project compat flags via mergeGlobalFlags**: Hidden global \`--org\`/\`--project\` flags accept old \`sentry-cli\` syntax. Defined in \`GLOBAL\_FLAGS\` (global-flags.ts) so argv-hoist relocates them. \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes (skip if command owns the flag — e.g. \`release create --project -p\`) and returns \`stripKeys\` set used by \`cleanRawFlags\`. \`applyOrgProjectFlags()\` writes values to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` via \`getEnv()\` before auth guard, overwriting existing env vars (explicit CLI > env var). Resolution chain in resolve-target.ts picks them up at priority #2. No short aliases (\`-p\` conflicts). The helper extraction was needed to keep \`buildCommand\` under Biome's cognitive complexity limit of 15. - - -* **idle.ts eviction: upstream uses per-function cleanup in idle.ts, not centralized evictSession in pipeline.ts**: Upstream (main branch) puts session eviction logic directly in \`idle.ts\` rather than a centralized \`evictSession()\` in \`pipeline.ts\`. \`idle.ts\` imports cleanup functions individually: \`evictSession as evictGradientSession\` from \`@loreai/core\`; also \`deleteSessionAuth\`, \`clearAuthStale\` from \`./auth\`; \`deleteSessionCosts\` from \`./cost-tracker\`; \`deleteBillingPrefix\` from \`./cch\`; \`clearWarmupAuthDisabled\` from \`./cache-warmer\`. The \`startIdleScheduler\` signature uses \`onEvict?: (sessionID: string) => void\` (upstream) vs \`onEvictSession?: (sessionID: string) => boolean\` (branch). Upstream inline \`onEvict\` in \`pipeline.ts\` cleans 5 Maps: \`headerSessionIndex\`, \`ltmSessionCache\`, \`ltmPinnedText\`, \`stableLtmCache\`, \`cwdWarned\`. When merging, adopt upstream's per-function approach and add any missing cleanup calls. - - -* **InkUI ink-app.js sidecar loading — three runtime contexts**: \`createInkUI()\` resolves \`inkAppPath\` differently per runtime: (1) Node SEA binary — \`sea.getAsset('dist-build/ink-app.js', 'utf-8')\`, write to \`mkdtempSync\`, import via \`pathToFileURL\`, then \`rmSync\` temp dir (best-effort); (2) Node/npm bundle — \`inkAppPath\` starts with \`'./'\`, resolve via \`new URL(inkAppPath, import.meta.url).href\`; (3) Dev mode — absolute filesystem path. Imported via \`with { type: 'file' }\` from \`./ink-app.tsx\`. See also \[\[019e4fe7-dbf1-7ed6-8b39-473e2e4ea29e]] for SEA temp file cleanup pattern. - - -* **IssueViewOutputSchema: extends SentryIssueSchema with enrichment fields for --fields discoverability**: \`IssueViewOutputSchema\` in \`src/types/sentry.ts\` extends \`SentryIssueSchema\` with 4 enrichment fields added by \`jsonTransformIssueView\`: \`event\` (z.unknown().nullable().optional() — too complex to enumerate; documenting all sub-fields would bloat the table without adding useful \`--fields\` discoverability), \`org\` (string | null), \`replayIds\` (array), \`trace\` (object with \`traceId\`+\`spans\`, nullable). Wired via \`schema: IssueViewOutputSchema\` on the \`output\` config in \`view.ts\`. Pattern mirrors \`ReplayViewOutputSchema\` in \`src/types/replay.ts:356-367\`. Exported from \`src/types/index.ts\`. Generates a JSON Fields table in \`references/issue.md\` and \`--help\` output. \`event\` uses \`z.unknown()\` because documenting all sub-fields (stacktraces, breadcrumbs, contexts) would bloat the table without adding useful \`--fields\` discoverability. +* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Hidden global \`--org\`/\`--project\` flags accept old sentry-cli syntax. Defined in \`GLOBAL\_FLAGS\` so argv-hoist relocates them. \`mergeGlobalFlags()\` injects hidden flag shapes (skip if command owns the flag) and returns \`stripKeys\` for \`cleanRawFlags\`. \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` via \`getEnv()\` before auth guard, overwriting env vars (explicit CLI > env var). No short aliases (\`-p\` conflicts). \`applyGroupLimitAutoDefault\` helper keeps \`buildCommand\` under Biome's cognitive complexity limit of 15. -* **Preserve ApiError type so classifySilenced can silence 4xx errors**: Preserve ApiError type for classifySilenced: \`classifySilenced\` (src/lib/error-reporting.ts) only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\` and causes 403s to be captured. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\` with terse message (\`ApiError.format()\` appends detail/endpoint). \`ValidationError\` without \`field\` collapses unfielded errors into one fingerprint; always pass \`field\`. Fingerprint rule changes don't retroactively re-fingerprint — manually merge new groups into canonical old parents. \`ApiError\` rule keys by \`api\_status + command\`. - - -* **sensitive argv flags must never reach telemetry — redactArgv() in cli.ts**: \`SENSITIVE\_ARGV\_FLAGS = new Set(\['token', 'auth-token'])\` in \`src/cli.ts\`. \`redactArgv()\` replaces values of these flags with \`\[REDACTED]\` before any telemetry call. This is an absolute invariant — never pass raw \`process.argv\` to telemetry without running through \`redactArgv()\` first. +* **Preserve ApiError type so classifySilenced can silence 4xx errors**: Preserve ApiError type for classifySilenced: \`classifySilenced\` only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\` and causes 403s to be captured. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\`. \`ValidationError\` without \`field\` collapses unfielded errors into one fingerprint; always pass \`field\`. Fingerprint rule changes don't retroactively re-fingerprint. \`ApiError\` rule keys by \`api\_status + command\`. For graceful-fallback operations, use \`withTracingSpan\` (\`onlyIfParent: true\` — no-op without active transaction) and \`captureException\` with \`level: 'warning'\` for non-fatal errors. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\`. * **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via bun patch: \`patchedDependencies\` in \`package.json\` strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Non-light root of \`@sentry/node-core\` pulls uninstalled \`@opentelemetry/instrumentation\` — \*\*always import from \`@sentry/node-core/light\`\*\* (subpaths: \`.\`, \`./light\`, \`./light/otlp\`, \`./init\`, \`./loader\`, \`./import\`). No supported import for \`HttpsProxyAgent\`. Bumping SDK: remove old patches, \`rm -rf ~/.bun/install/cache/@sentry\`, \`bun install\`, \`bun patch @sentry/core\`, edit, \`bun patch --commit\`; repeat for node-core. Preserved: \`\_INTERNAL\_safeUnref\`, \`\_INTERNAL\_safeDateNow\`, \`nodeRuntimeMetricsIntegration\`. Before stripping any core export, grep \`node-core/build/{cjs,esm}/light/sdk.js\` for runtime usage (e.g. \`spanStreamingIntegration\` when \`traceLifecycle === 'stream'\`). Remove \`.bun-tag-\*\` hunks from generated patches. Manual \`git diff\` patches fail. - -* **setup.ts bestEffort() wrapper: post-install steps must never crash setup**: \`src/commands/cli/setup.ts\` \`bestEffort(stepName, fn)\` wraps non-essential post-install steps (recording install info, shell completions, agent skills) in try/catch. On failure: calls \`warn(stepName, error)\` + \`captureException(error, { level: 'warning', tags: { 'setup.step': stepName } })\`. These steps must NEVER crash setup — enforced by \`bestEffort()\`. \`runConfigurationSteps()\` applies \`bestEffort()\` independently to all 4 steps. Install dir priority: (1) \`$SENTRY\_INSTALL\_DIR\`, (2) \`~/.local/bin\` if exists+in PATH, (3) \`~/bin\` if exists+in PATH, (4) \`~/.sentry/bin\` fallback. Welcome message only on fresh install (not upgrades). - * **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. \`issue list --limit\` is global total: \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus. \`trimWithProjectGuarantee\` ensures ≥1 issue per project. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). - -* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: (1) \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`loader()\` return type union causes \`.call()\` LSP false-positives that pass \`tsc --noEmit\`. (2) When API functions are renamed, update both spy target AND mock return shape. (3) \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. (4) Bun \`mockFetch()\` replaces \`globalThis.fetch\` — use one unified mock dispatching by URL. (5) \`mock.module()\` pollutes module registry for ALL subsequent files — put in \`test/isolated/\` and run via \`test:isolated\`. (6) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - - -* **wizard-runner.ts: large shared context via initialState, not inputData — D1 row size limit**: In \`wizard-runner.ts\`, large shared context (\`dirListing\`, \`fileCache\`, \`existingSentry\`) travels via \`initialState\` (not \`inputData\`) to avoid D1 per-row size overflow (see getsentry/cli-init-api#98). \`MAX\_RESUME\_RETRIES = 3\`, \`RETRY\_BACKOFF\_MS = \[2000, 4000, 8000]\`. \`resumeWithRetry()\` handles stale-step recovery via \`tryRecoverCurrentRunState()\` when \`isStepAlreadyAdvancedError()\` detects 'was not suspended' 500. +* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`.call()\` LSP false-positives pass \`tsc --noEmit\`. When API functions are renamed, update both spy target AND mock return shape. \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. Vitest: use \`vi.spyOn\` / mock fetch via \`globalThis.fetch\`. \`mock.module()\` pollutes module registry — put in \`test/isolated/\` and run via \`test:isolated\`. ALL test files MUST import from \`'vitest'\` — NEVER \`'bun:test'\`. ### Preference - -* **Always add new check scripts to both package.json and CI workflow**: When introducing a new check or validation script, the user expects it to be registered in two places simultaneously: (1) as a named script in \`package.json\` alongside other \`check:\*\` scripts, and (2) as a \`- run: pnpm run \\` step in \`.github/workflows/ci.yml\`. Never add to only one location. This applies to any new linting, validation, or verification script added to the project. - - -* **Always check with user before taking irreversible or external actions**: When the user asks the assistant to perform actions that affect external systems (sending messages, merging PRs, deploying, etc.), they explicitly require confirmation before proceeding. The user states 'check with user before sending any messages' or similar directives. The assistant should always pause and present a plan or draft to the user for approval before executing any action that cannot be easily undone — such as sending communications, merging code, or triggering external workflows. This applies even when the user has asked the assistant to handle the task end-to-end. - - -* **Always clarify that the repo uses plain git (not jj) when jj commands fail**: When a jj command fails with 'no jj repo in .', the user consistently clarifies that the repo is a plain git repo and that jj's 'never fails on conflict' behavior is being referenced conceptually — meaning conflicts should be recorded/resolved rather than aborting operations. The agent should: (1) fall back to git commands immediately without retrying jj, (2) handle merge conflicts by stashing, pulling, and resolving (e.g., \`git checkout --theirs\` for files like \`.lore.md\`), and (3) not attempt \`jj git init\` or any jj initialization. This pattern appears at the start of every build session. - - -* **Always commit and push after tests pass locally**: When a local test run completes successfully, the user consistently moves immediately to committing the changes and pushing to the remote branch. This pattern applies regardless of test suite size or duration. The assistant should proactively plan or execute a commit+push step as the natural next action after a passing test run, without waiting to be asked. Commit messages should follow conventional commit format (e.g., 'fix:', 'refactor:'). If warnings are present in the test output, they are noted but do not block the commit/push flow. - - -* **Always compare PR branch against main before reviewing changes**: When reviewing a PR, the user consistently wants to understand exactly what changed in the PR branch versus main before diving into the content. This means fetching the remote branch if not available locally, running \`git log main..origin/\\` to see commits, and \`git diff\` (with stat) to understand the scope of changes. The user explicitly frames this as needing to know 'what changes were made vs what actually exists on main.' Always establish this baseline diff context first before analyzing or discussing PR content. - - -* **Always conduct thorough PR reviews with severity-classified findings**: PR review standards: (1) Compare branch vs main first (\`git log main..origin/\\`, \`git diff --stat\`). (2) Verify every PR description claim against actual source files at specific line numbers — never trust PR metadata. (3) Classify findings as BLOCKING vs NON-BLOCKING with file paths and line numbers. (4) Flag LLM-generated planning artifacts (e.g., DOCS-AUDIT.md) as blocking violations of repo conventions. (5) Investigate root causes — check bundle output, trace esbuild variable renaming, identify silent regressions. (6) Run relevant check scripts and grep codebase directly rather than reasoning from PR metadata. - - -* **Always create a dedicated branch when updating fossilize versions**: When a new version of fossilize is released, always create a branch named \`chore/fossilize-{version}\` tracking origin/main, update the dependency, remove any functionality now handled natively by fossilize (e.g., \`stripCachedNodeBinaries()\` removed in 0.7.0), verify the build succeeds, then commit with \`chore: update fossilize to X.Y.Z\`. Follow this exact pattern: branch → update dep → remove superseded code → build verify → commit → PR. - - -* **Always document invariants and non-obvious design decisions as inline code comments or JSDoc**: When implementing functions, types, or test utilities with non-obvious invariants, the user consistently adds explicit inline comments or JSDoc explaining \*why\* a design choice was made — not just what it does. Examples: why a function always returns non-null, why a branch is intentionally omitted, why a specific API is used over an alternative, and what would break if the pattern changed. These comments are written as assertions ('Always returned (never null)...', 'Always restore — never delete.') and reference the downstream consumer or failure mode. When generating or modifying code, always include this kind of explanatory commentary for invariants, idempotency guarantees, and intentional omissions. - - -* **Always explore codebase systematically before implementing changes**: When investigating a feature or bug, the user consistently requests reads of multiple related files in sequence before any implementation: the command file, the API layer, the type definitions, the formatters, and any generated/skill files. They want to understand the complete data flow end-to-end (API response → types → command → output/formatter) before making changes. Always read all relevant files across these layers when asked to investigate or analyze a feature, rather than jumping straight to implementation or reading only one file. - - -* **Always explore e2e test infrastructure thoroughly before debugging or modifying tests**: When approaching e2e test work, always explore the full infrastructure before making changes: \`test/e2e/\` (14 files: api, auth, bundle, completion, delta-upgrade, event, issue, library, log, multiregion, project, skill-eval, telemetry-exit, trace), \`test/fixture.ts\` (getCliCommand, runCli, createE2EContext), \`test/helpers.ts\` (useTestConfigDir, useEnvSandbox, resetHostScopingState, mintSntrysToken, extractFetchUrl), \`test/mocks/\` (server.ts, routes.js, multiregion.ts), \`src/bin.ts\`, \`src/cli.ts\`. Key: \`getCliCommand()\` returns \`\[SENTRY\_CLI\_BINARY]\` if set, else \`\[process.execPath, 'run', 'src/bin.ts']\`. \`createE2EContext.run()\` sets \`SENTRY\_AUTH\_TOKEN: ''\`, \`SENTRY\_TOKEN: ''\`, \`SENTRY\_CLI\_NO\_TELEMETRY: '1'\`. \`test:e2e\` runs without \`--isolate --parallel\`. Map full infrastructure before proposing fixes. - - -* **Always fix CI lint failures immediately before proceeding with other work**: When CI reports lint or typecheck failures, the user consistently treats them as blocking issues that must be resolved before continuing with feature work or merging. The user runs \`biome check --write\` (safe fixes first), then \`biome check --write --unsafe\` for remaining issues, and manually fixes any residual errors (e.g., hoisting regex to module level, reformatting long strings). Only after lint is clean does the user proceed with the next task. This applies across projects (getsentry/cli, loreai) and includes both formatter and linter rule violations. - - -* **Always flag import framework mismatches as blocking CI issues in PR reviews**: When reviewing PRs, the user consistently identifies test files using the wrong import framework (e.g., \`bun:test\` instead of \`vitest\`) as a BLOCKING issue, not a non-blocking suggestion. This applies when the project has migrated frameworks and all other test files use the new one. The user expects the reviewer/assistant to explicitly label it as blocking (B1, B2, etc.) and distinguish it from non-blocking issues (N1, N2, etc.), using a clear severity classification system in PR review feedback. - - -* **Always get a PR up and monitor CI until all checks pass**: After completing implementation work, the user consistently requests that the assistant create a PR, then actively monitor CI and ensure all checks pass before considering the task done. This applies across different repos and task types. The user expects the assistant to: create the PR on the correct branch (not a wrong base), watch CI results, fix any failing checks, and only declare completion once all checks are green. The user sometimes also instructs the assistant to merge the PR once CI passes. In plan mode, the assistant should note it cannot execute these steps and call plan\_exit to signal readiness to proceed in build mode. - - -* **Always honor Retry-After header when present in LLM adapter**: (architecture) LLM adapter backoff in \`packages/gateway/src/llm-adapter.ts\`: Always honor Retry-After — \`backoffMs()\` returns \`Math.min(retryAfterMs, cap)\` where cap is \`RETRY\_AFTER\_CAP\_URGENT\_MS=8\_000\` or \`RETRY\_AFTER\_CAP\_BACKGROUND\_MS=120\_000\`. TRANSIENT\_CODES={429,500,502,503,529}; MAX\_RETRIES: rate-limit=3, server=3, urgent=2. Backoff (no Retry-After): 429 background=60s/120s/180s; urgent=min(1000×2^n,4000); 5xx background=min(1000×2^n,8000). Bearer tokens inject \`billingBlock\` as first system block; \`signBody()\` replaces \`cch=00000\` with xxHash64. System prompt caching uses \`cache\_control:{type:'ephemeral',ttl:'1h'}\`. \`opts.thinking\` NOT forwarded to bare API calls. Circuit breaker tripped on non-urgent 429s via \`tripCircuitBreaker()\`. Gateway auth (\`packages/gateway/src/auth.ts\`): \`AuthCredential\` (api-key|bearer). Two-level lookup: \`sessionAuth\` Map → \`lastSeenAuth\` global fallback via \`resolveAuth(sessionID?)\`. \`authFingerprint()\` = SHA-256 truncated to 16 hex chars. - - -* **Always investigate and distinguish pre-existing issues from PR-specific failures before fixing**: When CI failures occur, the user consistently checks whether the failure exists on main (e.g., checking last successful main runs) before treating it as something to fix. If the failure is pre-existing or unrelated to current changes, the user explicitly notes it as such and moves on rather than fixing it. Apply this pattern: when a test failure or lint warning appears, first determine if it's on main or PR-specific. Only fix PR-specific regressions; document pre-existing issues as out of scope for the current change. - - -* **Always investigate bundle resolution issues by inspecting minified variable names and esbuild's static analysis limitations**: When debugging 'Cannot find module' errors in bundled output, the user consistently digs into the root cause at the esbuild level: checking whether require calls use bare \`require()\` vs renamed aliases like \`\_require\`, inspecting minified bundle output for renamed variable patterns, and verifying that esbuild only statically resolves bare \`require()\` calls. The user expects the assistant to check the actual bundle contents (grep for minified names, count createRequire occurrences, verify no relative requires remain unresolved) rather than assuming the fix worked. The fix pattern is always: use bare \`require\` (not \`\_require\` or other aliases) for local relative imports so esbuild can inline them at bundle time. - - -* **Always investigate root cause by tracing through multiple specific code layers before accepting a fix**: When facing a runtime bug (especially undefined values from framework internals), the user consistently demands thorough investigation across multiple layers — framework source code (node\_modules), wrapper utilities, bundler config, and call sites — before accepting any fix. The user explicitly rejects surface-level explanations and pushes for tracing the exact code path that produces the unexpected value. Only after exhausting the investigation does the user accept a defensive fix strategy. When directing investigation, the user specifies concrete areas to search (e.g., 4 specific code locations). Always read and analyze the relevant framework internals, not just application code. - - -* **Always investigate root causes before accepting PR fixes at face value**: When reviewing PRs, the user consistently digs past the stated fix to verify whether the implementation actually solves the root cause. They examine bundle output, run smoke tests, check CI logs, and trace failure modes (e.g., esbuild variable renaming, wrong \`createRequire\` anchor, silent runtime failures). They expect the reviewer/assistant to identify not just surface bugs but also latent/silent failures introduced by the fix. Reviews should include: confirming the fix works end-to-end, identifying any new failure modes introduced, and flagging silent regressions (e.g., features that appear to work but silently fall back or skip logic). - - -* **Always match user's casual, short, stream-of-consciousness messaging style**: When sending messages on the user's behalf (e.g., via Beeper or other chat tools), always read further back in the conversation history to understand existing relationships and context before composing. Messages must be short, casual, and match the user's natural style — not formal, long, or structured. Never reintroduce people who already know each other. If unsure about the relationship between recipients, check prior chat history first. The user will explicitly correct style mismatches and flag social context errors as serious mistakes. - -* **Always migrate Bun-specific APIs and tooling to Node.js equivalents**: Migrating from Bun to Node.js/pnpm. Replace Bun-specific APIs: \`Bun.spawn\`→\`node:child\_process\`, \`Bun.sleep\`→\`node:timers/promises\`, \`bun:sqlite\`→\`node:sqlite\`, \`bun run\`→\`pnpm run\`/\`tsx\`, \`bun.lock\`→pnpm lockfile. All packages in \`devDependencies\` (never \`dependencies\`). Exception: \`script/build.ts\` uses fossilize (not \`Bun.build\`) and stays on Bun for the build-binary CI job. \`script/bundle.ts\` (npm bundle) uses esbuild via tsx and is Node-native. \`packageManager\` field in package.json: \`pnpm@10.11.0\`. After each migration phase, ensure lint and tests pass before committing. Migration is complete as of main branch (bun.lock deleted, vitest.config.ts added, all test files migrated to vitest). - - -* **Always monitor CI after push and fix all failures before considering work done**: After pushing code or merging PRs, the user expects the assistant to actively monitor CI results, wait for all checks (including bots like Sentry Seer and Cursor BugBot) to complete, fix any failing jobs, and address all unresolved comments (from both bots and humans). The cycle repeats until CI is fully green and no unresolved comments remain. Use \`gh run view --log-failed\` and \`gh pr checks\` to identify failures. Do not consider a task complete until this full cycle is done. - - -* **Always pause current tasks to resolve architectural blockers before implementation**: When the user discovers that a foundational architectural assumption is wrong or a planned approach has a critical flaw (e.g., a security boundary that doesn't hold, a rejected integration pattern), they immediately reprioritize: they pause the current implementation task and require the architecture to be redesigned first before any further implementation proceeds. This applies even mid-task. The user explicitly calls out the rejected approach and states the new design direction, expecting the assistant to treat the architectural decision as a prerequisite gate before resuming the original work. - - -* **Always perform thorough codebase exploration before designing or implementing fixes**: When investigating a bug or feature, the user consistently requests comprehensive upfront exploration across multiple files before any code changes. This includes: reading relevant command and API files completely, searching for all references to key terms/parameters, checking type definitions in SDK/node\_modules, and understanding the full data flow from flags to API calls. The user expects the assistant to map out the entire call chain, identify misleading comments, and surface all related code paths before proposing a solution. Do not jump to fixes — first read all relevant files thoroughly and report findings. - - -* **Always perform thorough quality reviews of PRs distinguishing blocking vs non-blocking issues**: When reviewing PRs, the user expects a structured quality review that: (1) categorizes issues as BLOCKING vs non-blocking/low-priority, (2) verifies each claimed change against the actual codebase, (3) flags LLM-generated planning artifacts (e.g., DOCS-AUDIT.md) that violate repo conventions as blocking issues, (4) checks for missed/inconsistent changes across all affected files, and (5) confirms correct changes are working as intended. The user wants specific file paths and line numbers cited for each issue. Non-blocking issues should still be reported but clearly distinguished from blockers. - - -* **Always plan systemic fixes with structured multi-problem breakdowns before implementation**: When the user identifies documentation or tooling issues, they consistently organize them as numbered problems with precise file locations, line numbers, and root causes before any code is written. They expect the assistant to engage at the planning level first — proposing detection strategies, fix approaches, and tradeoffs — and to consolidate related problems (e.g., merging overlapping tasks) rather than treating each in isolation. Plans are written to files and iterated on. Implementation only follows after the plan is agreed upon. The user prefers systemic/automated fixes (e.g., derive patterns from package.json) over one-off patches. - - -* **Always prefer systemic/automated solutions over one-off fixes**: When the user identifies errors, gaps, or problems, they explicitly direct the assistant to create or fix systems that prevent the entire class of errors in the future, rather than applying isolated one-off fixes. This applies especially when evaluating code quality, reviewing PRs, or addressing bugs. The user wants automated checks (e.g., CI steps, lint rules, scripts) and general solutions that scale, not patches that only address the immediate symptom. When planning or executing fixes, always ask: 'Can this be automated or systematized?' and prefer that approach. +* **Always migrate Bun-specific APIs and tooling to Node.js equivalents**: Bun→Node.js migration complete. Replace Bun-specific APIs: \`Bun.spawn\`→\`node:child\_process\`, \`Bun.sleep\`→\`node:timers/promises\`, \`bun:sqlite\`→\`node:sqlite\`, \`bun run\`→\`pnpm run\`/\`tsx\`. Exception: \`script/build.ts\` uses fossilize (not \`Bun.build\`) and stays on Bun for build-binary CI job. \`script/bundle.ts\` uses esbuild via tsx (Node-native). \`packageManager\`: \`pnpm@10.11.0\`. bun.lock deleted, vitest.config.ts added, all tests migrated to vitest. \`NODE\_VERSION='lts'\` in build.ts. Verify \`script/build.ts\` does NOT contain \`Bun.build({ compile: true })\`. - -* **Always provide documentation/context dumps before requesting technical analysis**: The user consistently pastes large reference documents, source files, or full code listings into the conversation before asking for analysis or implementation work. This applies when exploring new APIs (e.g., Node.js SEA docs), auditing codebases, or planning migrations. The user expects the assistant to extract key insights, identify problems, and propose solutions directly from the pasted material — not from general knowledge alone. When responding, prioritize findings grounded in the specific pasted content, cite line numbers or section names where possible, and proactively surface implications the user may not have explicitly asked about. - - -* **Always pull from origin/main before starting any exploration or work in getsentry/cli**: Before beginning any exploration, investigation, or implementation work in the getsentry/cli repository, always run \`git pull origin/main\` first. If there are local changes (e.g., in \`.lore.md\`) that block the pull, stash them, complete the pull, resolve any conflicts by checking out the index version, then drop the stash. This is an explicit user directive that applies at the very start of every session involving this repo, regardless of what work is planned. - - -* **Always pursue native runner builds to enable platform-specific optimizations**: When the user discovers that cross-compilation from a non-native runner is blocking optimizations (e.g., code cache, codesigning, strip+resign), they consistently push to move builds to native runners for each target platform. macOS targets require macOS runners (Xcode >= 16.4 for Node.js builds; \`strip -x\` on Mach-O requires re-codesigning). Linux cross-compilation to darwin is NOT officially supported by Node.js. The user will switch to per-platform native builds if bytecode (\`useCodeCache\`) yields meaningful startup improvement. Always check whether current CI runners match the target platform and propose native runner alternatives when they don't. - - -* **Always read and document full file details before proceeding with analysis or implementation**: When exploring a codebase, the user consistently reads files in full and records comprehensive structured details: exact line counts, all imports, every exported type/interface with their fields, all constants, all function signatures with their logic, and any notable comments or assertions. This applies to both source files and build/tooling scripts. The user expects the assistant to capture and reference these details precisely rather than summarizing loosely. When examining related files (e.g., a module and its consumers), the user reads each completely before drawing conclusions. This pattern applies during architecture exploration, feature planning, and documentation generation tasks. - - -* **Always read source files thoroughly before asking questions or making changes**: The user consistently reads full source files (often 400-900+ lines) and traces complete data flow pipelines across multiple modules before taking action. They examine types, constants, function signatures, and cross-module dependencies in depth. They do not ask clarifying questions upfront — instead they investigate the codebase themselves to build a complete mental model. When helping this user, proactively read all relevant files in full, trace imports and data flows end-to-end, and present comprehensive findings rather than asking what they want to know. Assume they want the full picture, not a summary. - - -* **Always reference external tools and prior art when exploring build/size optimization approaches**: When investigating build pipeline improvements or binary size reduction, the user consistently references specific external tools, repos, and contacts (e.g., Vercel's build-binary.mjs, binpunch, fossilize, Melkey's work) as starting points for evaluation. They expect the assistant to analyze whether each referenced approach actually applies to their specific setup before recommending it. The user wants a clear breakdown of what's relevant vs. irrelevant given their actual architecture (e.g., 'we already use esbuild full bundling, so node\_modules stripping doesn't apply'), followed by concrete alternative opportunities ranked by impact. - - -* **Always request a critical pre-merge review before merging PRs to production**: Before merging any PR, the user consistently requests a thorough, final pre-merge review — often using a subagent for objectivity. The review follows a structured checklist covering: file corruption (especially from biome formatter), import consistency (.js extensions, unused/missing imports), type safety, PR description accuracy, dead code, security issues (e.g., shell injection), error handling correctness, test coverage, and lint/CI status. The user expects the reviewer to surface BLOCKING vs NON-BLOCKING findings explicitly, and only approves merge when zero blocking issues are confirmed. This pattern applies to all PRs regardless of size or prior review rounds. - - -* **Always require a critical self-review before merging any PR**: Before merging a PR, the user consistently requires the assistant to perform a thorough, critical self-review of its own code and PR description. The user prefers this review be done via a subagent for greater objectivity. The review should cover: code correctness, file corruption/integrity, import consistency, type safety, PR description accuracy, test coverage, edge cases, and maintainability. The merge decision is contingent on the review outcome — merge only if the review passes, otherwise address gaps first. This applies to all PRs regardless of size or complexity. - - -* **Always research technical approaches thoroughly before implementation**: When facing a significant technical decision or migration, the user consistently requests deep research into multiple approaches before writing any code. This includes: fetching specific upstream documentation/source files (e.g., BUILDING.md, configure.py), identifying concrete flags/options, estimating build times, and evaluating cross-compilation feasibility. The user wants tradeoffs between paths laid out explicitly. Only after research is complete does implementation begin. When presenting research, include specific flags, URLs, estimated costs (time/size), and platform constraints. - - -* **Always stage all modified files before committing, not just already-staged ones**: When preparing to commit, the user reviews git status and expects ALL modified files to be staged together — not just files already in the index. If unstaged modified files exist alongside staged ones, the user treats this as an incomplete commit state that needs to be resolved before proceeding. The user reviews the full list of changed files (staged + unstaged) as a checklist against completed tasks, and expects the commit to encompass all related changes from the session as a single coherent unit. - - -* **Always store plans as markdown files in the \`.opencode/plans\` directory with timestamp-prefixed filenames**: When working in plan mode, the user expects plans to be written to \`.opencode/plans/\` as markdown files. Filenames follow the pattern \`{timestamp}-{slug}.md\` (e.g., \`1779289703678-sentryclirc-migration.md\`). Some plans use descriptive slugs without timestamps (e.g., \`require-conventional-pr-title.md\`). Plans are created before implementation begins, and the assistant should call \`plan\_exit\` when done planning. Plans may be edited iteratively during the planning phase before switching to build mode. - - -* **Always switch from plan mode to build mode before executing changes**: The user consistently uses a two-phase workflow: first planning (read-only exploration, writing a plan file), then explicitly approving a switch to build/agent mode before any changes are executed. When the user approves the mode switch, the assistant should immediately begin executing the existing plan file — typically by re-reading the key files to be modified. Never execute changes while still in plan mode, even if the plan is complete and approved. Wait for the explicit mode-switch approval before acting. - - -* **Always track migration progress with explicit completion criteria and remaining blockers**: The Bun→Node migration is complete only when \`Bun.build({ compile: true })\` is replaced by fossilize in \`script/build.ts\`. As of the current session, \`script/build.ts\` already uses fossilize (\`--no-bundle\`, \`--out-dir dist-bin\`, \`--node-version lts\`) with esbuild for bundling — the migration is complete. NODE\_VERSION='lts' in build.ts. The user expects the assistant to track this state across sessions and confirm the migration is done. When resuming sessions, verify \`script/build.ts\` does not contain \`Bun.build({ compile: true })\` before declaring migration complete. - - -* **Always track pre-existing failures separately from introduced regressions**: When running tests, the user consistently distinguishes between failures that existed before their changes and failures caused by their changes. They verify pre-existing failures by checking out main/stashing changes and confirming the same failures reproduce. Only new failures introduced by the current branch are treated as actionable. When reporting test results, always clarify which failures are pre-existing (with evidence) versus newly introduced, and never treat pre-existing failures as blockers for the current fix. - - -* **Always update dependencies promptly after releasing new versions**: When the user releases a new version of a tool they own (e.g., fossilize), they immediately update dependent projects to use that new version. This includes bumping the version in package files, creating a dedicated branch with a descriptive name (e.g., \`chore/tool-x.y.z\`), and opening a pull request. The commit message follows conventional commit format: \`chore: update \ to \ (\)\`. The assistant should proactively handle the full update workflow: fetch latest main, create the branch, update the dependency, commit, push, and open a PR. - - -* **Always verify all tasks are complete before committing, then commit with descriptive conventional commit messages**: Before committing, the user reviews a task checklist to confirm all items are completed or in-progress. They stage all relevant files, then commit with a conventional commit message (e.g., 'docs: fix stale Bun references and add systemic doc checks') that summarizes the scope of changes. The commit message reflects the primary theme of the work session. The user expects the assistant to help verify task completion status, check git status, and confirm the commit succeeds with a summary of files changed and insertions/deletions. - - -* **Always verify code claims against actual file contents before accepting them as true**: When evaluating PRs, documentation, or assertions about code behavior, the user systematically cross-checks every claim against the actual source files at specific line numbers. They expect the assistant to read the real files, confirm exact line locations, quote the relevant code/comments, and flag discrepancies between what is claimed and what the code actually does. The user marks confirmed findings with 🟡 (verified) and actionable directives with 🔴 (user assertion/directive). Never accept a PR description or assertion at face value — always ground-truth it against the codebase with precise line references. - - -* **Always verify PR claims against actual codebase before accepting changes**: When reviewing a PR, the user consistently directs the assistant to check each stated claim against the real source files on the main branch rather than trusting the PR description or commit messages. This applies especially to documentation PRs: the user wants specific file paths, line numbers, and code excerpts cited as evidence. The user also cross-checks automated tooling (scripts, CI configs) against what they actually produce. When a PR introduces fixes, the user wants confirmation that the underlying problem genuinely existed and that the fix is correct — not just that the PR author says so. Always run the relevant check scripts and grep the codebase directly rather than reasoning from PR metadata alone. - - -* **Always work around the worktree conflict error when merging to main**: When merging PRs locally, the user consistently encounters \`fatal: 'main' is already used by worktree at ...\` and expects a workaround to be applied automatically rather than treating it as a blocking error. The merge is always completed successfully despite this error (e.g., using \`gh pr merge\` via CLI or other workaround). Never stop or report failure when this specific worktree conflict appears — proceed with the merge using an alternative method and confirm the PR was merged successfully. - - -* **Always work from a plan file before making code changes**: When starting work on a GitHub issue or significant feature, the user expects a plan file to be created first (at \`.opencode/plans/\-\.md\`) before any edits to other files. The user enforces a 'plan mode' where only the plan file may be written. Only after the plan is established does implementation proceed. This applies to new features, bug fixes, and test additions alike. The assistant should create the plan file first, document the problem, proposed approach, and estimated impact, then await confirmation before touching source or CI files. - - -* **Always work from a structured plan file before executing multi-step tasks**: When tackling multi-step or multi-file changes, the user consistently creates a formal plan file (e.g., \`.opencode/plans/\-\.md\`) during a planning phase before any edits are made. The plan enumerates discrete numbered tasks with priorities and target files. Execution only begins after the user explicitly approves the plan. During execution, tasks are marked in\_progress and completed sequentially. The user expects this plan-then-execute workflow to be followed strictly — no file edits during planning, and tasks tracked against the approved plan. - - -* **Always write tests after implementing new modules or features**: After implementing a new module or integrating a feature, the user consistently adds corresponding tests — both a dedicated test file for the new module (e.g., \`semantic-display.test.ts\`) and additional tests in existing test files for integration points (e.g., new describe blocks or test cases in \`local.test.ts\`). The user also reads existing test files first to understand patterns before writing new tests. Tests are added as a required step in the todo list, not as an afterthought, and are followed by typecheck/test runs to verify correctness. - - -* **Bot review triage: distinguish real bugs from SDK-mirroring false positives**: When Sentry Seer or Cursor Bugbot flags 'unusual' code that intentionally mirrors upstream SDK behavior (e.g., \`http\_proxy\` as last-resort fallback for HTTPS URLs — deliberate in \`@sentry/node-core\` \`applyNoProxyOption\`), decline with a written rationale referencing the SDK source rather than silently changing behavior. Removing the mirror creates a divergence where users get different proxy semantics from our transport vs. the SDK default. BYK's pattern: verify against \`node\_modules/@sentry/node-core/build/esm/transports/http.js\`, post a reply explaining the precedent, and resolve the thread. Real bugs (uppercase env var support, whitespace trimming, wildcard handling) get fixed; SDK-mirroring 'bugs' get explained and dismissed. - - -* **Follow the established git workflow (branch, PR, review)**: Behavioral pattern detected across 6 sessions (action: enforced-workflow). The user consistently demonstrates this behavior. - - -* **Never merge a PR if CI is failing**: NEVER merge a PR if CI is failing unless the user explicitly says to ignore specific failures in that session. This is an absolute directive repeated across 20+ sessions. + +* **Always replace existsSync guards with try/catch ENOENT pattern**: Absolute directive: replace \`existsSync()\` guards before file operations with try/catch ENOENT pattern — no TOCTOU races. Correct pattern: attempt the operation directly (sync or async), catch errors, handle \`ENOENT\` (or other expected codes) in the catch block. For async functions, prefer \`await realpath(p)\` (node:fs/promises) over \`realpathSync\` (node:fs) — consistency with async context. Non-ENOENT errors should log via \`logger.debug(msg, error)\` then return fallback. Never use \`existsSync(p)\` as a guard before \`realpathSync(p)\` or any other FS operation. -* **Prefers Bun-native APIs; use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS = 50; architecture tree documented; error exit code ranges: 1x=auth**: Project conventions (AGENTS.md): use \`pnpm run\`/\`pnpm install\`/\`pnpm add -D\` (NOT bun for package management); use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited — every catch must re-throw, log with log.debug()/log.warn(), or return fallback WITH a log.debug() call; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS=50; error exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. Testing: vitest + fast-check (NEVER bun:test). All packages in devDependencies (CI enforces via \`pnpm run check:deps\`). NEVER merge a PR if CI is failing unless explicitly told to ignore. Always use \`pnpm add -D \\` — never add to \`dependencies\`. - - -* **Respect explicitly rejected approaches**: Behavioral pattern detected across 3 sessions (action: rejected-approach). The user consistently demonstrates this behavior. +* **Prefers Bun-native APIs; use buildCommand from lib/command.js (never @stricli/core directly); use buildRouteMap from lib/route-map.js; silent catch blocks prohibited; every new src/lib/\*\*/\*.ts must start with module-level JSDoc; test isolation via useTestConfigDir(); prefer property-based and model-based tests over unit tests; DEFAULT\_NUM\_RUNS = 50; architecture tree documented; error exit code ranges: 1x=auth**: Project conventions (AGENTS.md): use \`pnpm run\`/\`pnpm install\`/\`pnpm add -D\` (NOT bun for package management); \`buildCommand\` from \`lib/command.js\` (never \`@stricli/core\` directly); \`buildRouteMap\` from \`lib/route-map.js\`; silent catch blocks prohibited — every catch must re-throw, log with \`log.debug()\`/\`log.warn()\`, or return fallback WITH \`log.debug()\`; every new \`src/lib/\*\*/\*.ts\` must start with module-level JSDoc; test isolation via \`useTestConfigDir()\`; prefer property-based and model-based tests; \`DEFAULT\_NUM\_RUNS=50\`; error exit codes: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. Testing: vitest + fast-check (NEVER bun:test). All packages in devDependencies. NEVER merge a PR if CI is failing. Anti-pattern: \`existsSync(p)\` guard before any FS operation — TOCTOU race; use try/catch on ENOENT instead. In async functions, prefer \`await realpath(p)\` (node:fs/promises) over \`realpathSync\`. - -* **Review code before committing**: Behavioral pattern detected across 7 sessions (action: requested-review). The user consistently demonstrates this behavior. - - -* **Smoke tests must cover critical lazy-loaded paths, not just --help/--version**: Smoke tests that only run \`--help\` are insufficient — they never trigger lazy-loaded code paths. Critical paths: \`auth status\` (exits code 10, \`auth: false\`, exercises SQLite init/schema migrations/telemetry lazy import/CJS require chain, no network calls) and \`cli defaults\` (exits 0, \`auth: false\`, exercises \`getAllDefaults()\`/metadata KV). Both binary and npm bundle smoke tests must cover these paths. \`init --dry-run\` is NOT suitable as a smoke test — it lacks \`auth: false\`, so the auth guard runs first. CI currently only runs \`--help\` for all smoke tests (ci.yml lines 277-285, 683). - - -* **Spend time on robust esbuild config — cover sourcemaps too**: When working on the build pipeline, spend time figuring out a robust esbuild config and stick to it. The config must also cover sourcemaps (debug ID injection, upload gating on SENTRY\_AUTH\_TOKEN). Don't iterate ad-hoc — design the full config once correctly. + +* **Review code before committing**: Behavioral pattern detected across 8 sessions (action: requested-review). The user consistently demonstrates this behavior. -* **Telemetry implementation invariants: handler cleanup, uid check, non-blocking, redaction**: Four absolute telemetry directives in \`src/lib/telemetry.ts\`: (1) \`initSentry()\` ALWAYS removes \`currentBeforeExitHandler\` via \`process.removeListener\` before registering new one — prevents duplicate handlers on re-init. (2) \`isOwnedByRoot()\` ALWAYS returns \`false\` immediately on \`process.platform === 'win32'\` — Windows \`fs.stat().uid\` always returns 0 regardless of actual ownership. (3) NEVER block CLI execution for telemetry emission — all telemetry drains are best-effort, wrapped in try/catch. (4) \`SENSITIVE\_ARGV\_FLAGS\` (includes \`token\`, \`auth-token\`) NEVER sent to telemetry — \`redactArgv()\` handles both \`--flag=value\` and \`--flag \\` forms. - - -* **tryReadPem must never throw — missing CA file is non-fatal**: Absolute directive: \`tryReadPem()\` in \`src/lib/custom-ca.ts\` NEVER throws. A missing or unreadable CA file must not crash the CLI — log a warning and return \`undefined\`. This is a design invariant for CA loading: failures are best-effort, not fatal. +* **Telemetry implementation invariants: handler cleanup, uid check, non-blocking, redaction**: Telemetry invariants (\`src/lib/telemetry.ts\`): (1) \`initSentry()\` ALWAYS removes \`currentBeforeExitHandler\` before registering new one — prevents duplicate handlers on re-init. (2) \`isOwnedByRoot()\` returns \`false\` immediately on \`process.platform === 'win32'\` — Windows \`fs.stat().uid\` always returns 0. (3) NEVER block CLI execution for telemetry — all drains are best-effort, wrapped in try/catch. (4) \`SENSITIVE\_ARGV\_FLAGS\` (includes \`token\`, \`auth-token\`) NEVER sent — \`redactArgv()\` handles both \`--flag=value\` and \`--flag \\` forms. \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\` to skip \`@sentry/node-core\` lazy-require (~280ms). \`reportUnknownCommand()\` wrapped in try/catch — telemetry must never crash CLI. diff --git a/src/lib/binary.ts b/src/lib/binary.ts index 099630fa5..a9355f2df 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -9,12 +9,11 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, - realpathSync, renameSync, unlinkSync, writeFileSync, } from "node:fs"; -import { chmod, copyFile, mkdir, unlink } from "node:fs/promises"; +import { chmod, copyFile, mkdir, realpath, unlink } from "node:fs/promises"; import { delimiter, join, resolve } from "node:path"; import { compare as semverCompare } from "semver"; import { getUserAgent } from "./constants.js"; @@ -445,36 +444,27 @@ export async function installBinary( // When upgrade spawns setup --install, the child's execPath IS the // .download file (sourcePath === tempPath). In that case skip the // unlink+copy — the file is already where we need it. - // - // Compare canonical (symlink-resolved) paths, not just absolute ones: - // process.execPath is canonicalized by the OS, while installDir may - // reach the same location through a symlink (e.g. macOS /tmp -> - // /private/tmp). Plain resolve() would see these as different and we'd - // unlink the very file we're about to copy. realpathSync requires the - // path to exist, so fall back to resolve() for the normal case where - // tempPath hasn't been created yet. - const canonical = (p: string): string => { - // realpathSync throws when the path does not exist yet — the normal - // case for tempPath before the download lands — so short-circuit that - // without logging. A throw for any other reason (e.g. permissions) is - // unexpected and worth surfacing in debug logs. - if (!existsSync(p)) { - return resolve(p); - } + // Compare symlink-resolved paths: process.execPath is canonicalized by + // the OS, but installDir may go through a symlink (e.g. macOS /tmp → + // /private/tmp). Falls back to resolve() when the path doesn't exist yet. + const canonical = async (p: string): Promise => { try { - return realpathSync(p); + return await realpath(p); } catch (error) { - logger.debug("realpathSync failed, falling back to resolve()", error); + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return resolve(p); + } + logger.debug("realpath failed, falling back to resolve()", error); return resolve(p); } }; - if (canonical(sourcePath) !== canonical(tempPath)) { + if ((await canonical(sourcePath)) !== (await canonical(tempPath))) { // Clean up any leftover temp file from interrupted operation try { await unlink(tempPath); - } catch { - // Ignore if doesn't exist + } catch (error) { + logger.debug("Failed to clean up temp file", error); } // Copy source binary to temp path next to install location diff --git a/test/lib/binary.mocked.test.ts b/test/lib/binary.mocked.test.ts index 1382cdc32..ce8611c04 100644 --- a/test/lib/binary.mocked.test.ts +++ b/test/lib/binary.mocked.test.ts @@ -1,14 +1,7 @@ /** - * Tests for installBinary's canonical() fallback when realpathSync throws on a - * path that exists. - * - * realpathSync only throws for an existing path under rare conditions (e.g. a - * permission error, or a TOCTOU race where the file is removed between the - * existsSync check and the realpathSync call). We mock realpathSync to throw so - * the catch branch — which logs and falls back to resolve() — is exercised. - * - * Kept in a sibling `.mocked.test.ts` file so the node:fs mock doesn't leak - * into binary.test.ts, which relies on the real filesystem. + * Tests for installBinary's canonical() fallback when realpath throws on an + * existing path. Kept in a separate file so the node:fs/promises mock doesn't + * leak into binary.test.ts. */ import { chmodSync, mkdirSync, rmSync } from "node:fs"; @@ -16,22 +9,21 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -// Mock only realpathSync to throw; everything else stays real via importOriginal. -vi.mock("node:fs", async (importOriginal) => { - const orig = await importOriginal(); +// Mock only realpath to throw; everything else stays real via importOriginal. +vi.mock("node:fs/promises", async (importOriginal) => { + const orig = await importOriginal(); return { ...orig, - realpathSync: () => { - throw new Error("EACCES: permission denied (simulated)"); - }, + realpath: () => + Promise.reject(new Error("EACCES: permission denied (simulated)")), }; }); -// Import AFTER the mock so binary.ts picks up the throwing realpathSync. +// Import AFTER the mock so binary.ts picks up the throwing realpath. import { getBinaryFilename, installBinary } from "../../src/lib/binary.js"; import { logger } from "../../src/lib/logger.js"; -describe("installBinary canonical() fallback when realpathSync throws", () => { +describe("installBinary canonical() fallback when realpath throws", () => { let testDir: string; let installDir: string; @@ -49,13 +41,12 @@ describe("installBinary canonical() fallback when realpathSync throws", () => { rmSync(testDir, { recursive: true, force: true }); }); - test("falls back to resolve() and logs when realpathSync throws on an existing path", async () => { + test("falls back to resolve() and logs when realpath throws on an existing path", async () => { if (process.platform === "win32") return; // Mirror the upgrade-spawn case: sourcePath IS the .download file, so the - // guard must recognize them as the same file. With realpathSync throwing, - // canonical() must fall back to resolve() (which still matches here) rather - // than unlinking the source and crashing. + // guard must recognize them as the same file. With realpath throwing, + // canonical() falls back to resolve() rather than unlinking the source. const tempPath = join(installDir, `${getBinaryFilename()}.download`); await writeFile(tempPath, "upgraded binary"); chmodSync(tempPath, 0o755); @@ -67,7 +58,7 @@ describe("installBinary canonical() fallback when realpathSync throws", () => { expect(result).toBe(join(installDir, getBinaryFilename())); expect(await readFile(result, "utf-8")).toBe("upgraded binary"); expect(debugSpy).toHaveBeenCalledWith( - "realpathSync failed, falling back to resolve()", + "realpath failed, falling back to resolve()", expect.any(Error) ); }); From af2191bbf298a77c1e126c1c51a8fe343ac16c1b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 1 Jun 2026 20:51:37 +0000 Subject: [PATCH 5/5] ref: only log non-ENOENT errors in temp file cleanup Address Seer review: ENOENT is the expected case (no leftover temp file from a previous interrupted operation), so skip the debug log for it. --- src/lib/binary.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/binary.ts b/src/lib/binary.ts index a9355f2df..b1ba64c46 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -464,7 +464,9 @@ export async function installBinary( try { await unlink(tempPath); } catch (error) { - logger.debug("Failed to clean up temp file", error); + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug("Failed to clean up temp file", error); + } } // Copy source binary to temp path next to install location