From cc0803a74a0a74381670c283e3a7c9f640a3188b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 20 May 2026 14:25:39 +0000 Subject: [PATCH] refactor: replace Bun.file/write/which/spawn/sleep/Glob/semver/uuid with Node.js APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all Bun-specific API calls in src/ with Node.js equivalents, continuing the Bun → Node.js migration. Groups replaced: - Bun.file().text/json/exists/stat/size/lastModified → node:fs/promises - Bun.write() → writeFile from node:fs/promises - Bun.write(dest, Bun.file(src)) → copyFile from node:fs/promises - Bun.which() → new src/lib/which.ts helper (uses 'command -v' on Unix) - Bun.spawn/spawnSync → spawn/spawnSync from node:child_process - Bun.sleep() → setTimeout from node:timers/promises - Bun.Glob → picomatch (already a devDependency) - Bun.randomUUIDv7() → uuidv7 package (already a devDependency) - Bun.semver.order() → semver.compare (already a devDependency) Remaining Bun APIs (Group D — to be addressed separately): - Bun.file().writer() in bspatch.ts and upgrade.ts - Bun.zstdCompress/DecompressSync in sourcemaps.ts and bspatch.ts - Bun.mmap() in bspatch.ts - Bun.CryptoHasher in bspatch.ts - bun:sqlite fallback in sqlite.ts (removed when tests migrate to Vitest) 30 files changed, 7012 tests pass, 0 failures. --- .lore.md | 90 +++++++++++++++++++++++++++++ AGENTS.md | 82 +------------------------- src/commands/api.ts | 10 +++- src/commands/cli/upgrade.ts | 14 +++-- src/commands/dashboard/list.ts | 9 +-- src/lib/agent-skills.ts | 3 +- src/lib/binary.ts | 13 +++-- src/lib/browser.ts | 26 ++++++--- src/lib/bspatch.ts | 4 +- src/lib/clipboard.ts | 28 +++++---- src/lib/completions.ts | 3 +- src/lib/db/dsn-cache.ts | 6 +- src/lib/db/instance.ts | 4 +- src/lib/delta-upgrade.ts | 7 ++- src/lib/dsn/code-scanner.ts | 3 +- src/lib/dsn/detector.ts | 4 +- src/lib/dsn/fs-utils.ts | 9 +-- src/lib/dsn/project-root.ts | 16 ++--- src/lib/dsn/scanner.ts | 7 ++- src/lib/init/tools/command-utils.ts | 2 +- src/lib/init/tools/run-commands.ts | 18 +++--- src/lib/patch-cache.ts | 18 +++--- src/lib/polling.ts | 3 +- src/lib/safe-read.ts | 14 ++--- src/lib/scan/grep.ts | 5 +- src/lib/scan/ignore.ts | 5 +- src/lib/scan/types.ts | 4 +- src/lib/sentry-client.ts | 3 +- src/lib/sentryclirc.ts | 4 +- src/lib/shell.ts | 26 ++++++--- src/lib/upgrade.ts | 6 +- src/lib/version-check.ts | 5 +- src/lib/which.ts | 65 +++++++++++++++++++++ test/commands/cli/upgrade.test.ts | 88 +++++++++++++++++----------- 34 files changed, 376 insertions(+), 228 deletions(-) create mode 100644 src/lib/which.ts diff --git a/.lore.md b/.lore.md index 6765aafa0..93d44ae15 100644 --- a/.lore.md +++ b/.lore.md @@ -1,2 +1,92 @@ +## Long-term Knowledge + +### Architecture + + +* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`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. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval. + + +* **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. + + +* **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})\` (~20% faster than walkFiles). \`scanCodeForFirstDsn\` stays on direct walker loop (worker init ~20ms dominates single-DSN). 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 (would silently bless unvisited dirs); \`ConfigError\` re-throws. + + +* **Grep worker pool: binary-transferable matches + streaming dispatch in src/lib/scan/**: Grep worker pool (\`src/lib/scan/worker-pool.ts\` + \`grep-worker.js\`): lazy singleton, size \`min(8, max(2, availableParallelism()))\`. Matches encoded as \`Uint32Array\` quads \`\[pathIdx, lineNum, lineOffset, lineLength]\` + \`linePool\` string, transferred via \`postMessage(msg, \[ints.buffer])\` (~40% faster than structuredClone). Worker imported via \`with { type: 'text' }\` → \`Blob\` + \`URL.createObjectURL\`; \`new Worker(new URL(...))\` HANGS in \`bun build --compile\` binaries. FIFO \`pending\` queue per worker — per-dispatch \`addEventListener\` causes wrong-request resolution. \`ref()\`/\`unref()\` idempotent booleans, NOT refcounted — only unref when \`inflight\` drops to 0; spawn unref'd. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. Track dispatched/failed batches with \`Promise.allSettled\`; throw if all failed so DSN cache doesn't persist false-negatives. + + +* **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (PR #844): every token bound to issuing host via \`auth.host\` column (schema v16), 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 (mtime-based freshness doesn't work: git clone resets, \`touch -t\` backdates). 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). Login refusal scoped to \`--token\`. \`HostScopeError\` (\`src/lib/errors.ts\`) is canonical formatter with overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`; used by rc-shim, URL-arg, fetch bearer, sntrys\_ claim, OAuth refresh. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; child \`SENTRY\_URL\` alone doesn't anchor. + + +* **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**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\\`→\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\@\\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. + + +* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic @ selectors resolve issues dynamically: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\` (@ not in forbidden charset). \`SELECTOR\_MAP\` provides case-insensitive matching. \`resolveSelector\` maps to \`IssueSort\` values, calls \`listIssuesPaginated\` with \`perPage: 1\`, \`query: 'is:unresolved'\`. Supports org-prefixed: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through. \`ParsedIssueArg\` union includes \`{ type: 'selector' }\`. + + +* **safe-read.ts wraps isRegularFile + Bun.file().text() for FIFO-safe user-path reads**: \`src/lib/safe-read.ts\` \`safeReadFile(path, operation): Promise\\` combines \`isRegularFile()\` + \`Bun.file().text()\` + broad error swallow (FIFO/ENOENT/EACCES/EPERM/EISDIR/ENOTDIR). Sole caller: \`apply-patchset.ts\`. \*\*Do NOT use for committed config loads\*\* — swallows EPERM/EISDIR, making \`chmod 000 .sentryclirc\` manifest as confusing 'no auth token'. For loud permission surfacing (\`tryReadSentryCliRc\`), call \`fs.promises.stat\` directly, gate on \`isFile()\`, catch only ENOENT/EACCES. \`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. + + +* **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 CLI authenticated fetch architecture with response caching**: (architecture) 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). GET response cache checked BEFORE fetch — tests asserting call counts see 0 calls if prior test cached same URL. + + +* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: (architecture) 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. Malformed combos discarded. \`resolveFromEnvVars()\` injected into all four resolution functions. 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 — dedicated tables give clearer schema, proper indexes, simpler bulk-clear. \`metadata\` KV fine for small scalars. Example: \`issue\_org\_cache\` (v15) replaced \`metadata\` keys. + + +* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: (architecture) 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\`. + + +* **Sentry SDK uses @sentry/node-core/light instead of @sentry/bun to avoid OTel overhead**: (architecture) Sentry SDK uses \`@sentry/node-core/light\` (not \`@sentry/bun\`) to avoid OpenTelemetry overhead (~150ms, 24MB). \`@sentry/core\` barrel patched via \`bun patch\` to remove ~32 unused exports. \`LightNodeClient\` hardcodes \`runtime:{name:'node'}\` AFTER spreading options — fix by patching \`client.getOptions().runtime\` post-init. Transport uses Node \`http\` instead of native \`fetch\`. Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` before imports, skipping SDK load (~85ms). Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE...RETURNING\`. Always import from \`@sentry/node-core/light\`; root barrel pulls uninstalled @opentelemetry/instrumentation. When bumping SDK: remove patches, install, patch, edit, commit. + + +* **Sentry token formats: only sntrys\_ embeds host claim, and it's unsigned**: (architecture) Sentry token formats: \`sntryu\_\\` (user auth, no claims); \`sntrys\_\\_\\` (org auth, \*\*unsigned\*\*/plaintext — UX hint only, not verifiable); \`sntrya\_\`/\`sntryi\_\` — random hex; OAuth — random, no prefix. \`classifySentryToken()\` returns \`'org-auth-token'\`/\`'user-auth-token'\`/\`'oauth-or-legacy'\`. \`parseSntrysClaim\` requires exactly 2 underscores, 2KB cap, fail-open. Two consumers: (1) \`captureEnvTokenHost\` — claim url first for \`sntrys\_\` (defends against \`$GITHUB\_ENV\` poisoning); env wins for \`sntryu\_\`/OAuth. (2) \`prepareHeaders\` — refuses bearer attach if request origin doesn't match claim url. \`auth.host\` column \[\[019dc168-adb2-7bed-900e-cab5d3716099]] is strictly stronger than token claims. + + +* **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\`. + +### Gotcha + + +* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: (gotcha) Biome lint traps (run \`bun run lint\` not \`lint:fix\` before pushing): (1) \`noUselessUndefined\`+\`noEmptyBlockStatements\` reject \`()=>undefined\` and \`()=>{}\` — use \`function noop():void{}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers. (3) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (4) \`noIncrementDecrement\` — use \`i+=1\`. (5) \`useYield\` on \`async \*func()\` needs \`biome-ignore\`. (6) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (7) \`noMisplacedAssertion\` fires on helper functions — use inline \`biome-ignore\` above each \`expect()\`, NOT file-level. (8) \`AuthError(reason, message?)\` — easy to swap args; correct: \`new AuthError("expired", "Token expired")\`. Tests aren't type-checked but ARE lint-checked. Node polyfill (\`script/node-polyfills.ts\`) is INCOMPLETE — prefer \`node:fs/promises\` for file ops; \`execSync\` for shell. + + +* **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: \`process.stdin.isTTY\` unreliable in Bun — use \`isatty(0)\` from \`node:tty\`. Bun's single-file binary can leave \`process.stdin.isTTY === undefined\` on TTY fds inherited via redirects like \`exec … \ +* **runInteractiveLogin swallows errors and sets process.exitCode = 1**: \`runInteractiveLogin\` in \`src/lib/interactive-login.ts\` catches OAuth flow errors internally (device-code fetch failures, timeout, etc.) and returns falsy on failure. The login command then sets \`process.exitCode = 1\` and returns normally — the wrapped command function resolves, NOT rejects. Tests that mock fetch to throw and expect \`rejects.toThrow()\` will fail with \`resolved: Promise { \ }\`. Assert behavior via fetch-call inspection (\`fetchCalls.length > 0\`, header content) instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var — unset in tests means it throws \`ConfigError\` before any fetch fires. + + +* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli flag parsing traps: (1) Unknown \`--flag\`s rejected — global flags parsed in \`bin.ts\` MUST be spliced from argv (check both \`--flag value\` and \`--flag=value\`). (2) \`FLAG\_NAME\_PATTERN\` requires 2+ chars after \`--\`; single-char flags like \`--x\` silently become positionals — use aliases (\`-x\` → longer name). Bit \`dashboard widget --x\`/\`--y\`. (3) \`FlagDef.hidden\` is propagated by \`extractFlags\` so \`generateCommandDoc\` filters hidden flags alongside \`help\`/\`helpAll\`; hidden \`--log-level\`/\`--verbose\` appear only in global options docs. + + +* **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\` (Turkish \`İ\`→\`i̇\`). (3) Extractor \`hasTopLevelAlternation\`+\`skipGroup\` must call \`skipCharacterClass\` (PCRE \`\[]abc]\` ≠ JS empty class). (4) Wake-latch race: naive \`let notify=null; await new Promise(r=>notify=r)\` loses signals — use latched \`pendingWake\` flag. (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\`. + +### Pattern + + +* **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. + + +* **Merging mock.module() test files with static-import counterparts**: (pattern) Bun test mocking traps: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — static imports won't re-bind. (3) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (4) \`mock.module()\` pollutes registry — use \`test/isolated/\`. (5) \`buildCommand\` wrapper: \`cmd.loader()\` returns wrapped async fn; call \`func.call(ctx, flags, ...args)\` as a promise. Auth guard runs first; \`test/preload.ts\` sets fake \`SENTRY\_AUTH\_TOKEN\`. (6) Test glob \`test:unit\` only picks up \`test/lib\`, \`test/commands\`, \`test/types\` — tests under \`test/fixtures/\`, \`test/scripts/\`, \`test/script/\` NOT run by CI. (7) Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — filesystem cache will serve prior test responses otherwise. + + +* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: (pattern) 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. \`paginationHint()\` builds nav strings. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. Hidden global \`--org\`/\`--project\` flags accept old \`sentry-cli\` syntax via \`mergeGlobalFlags()\` in command.ts; \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. No short aliases (\`-p\` conflicts). 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. + + +* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: (pattern) Telemetry instrumentation + command bypass: Use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans (\`onlyIfParent: true\` — no-op without active transaction) and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\`. \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is the single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add new env vars here with \`installOnly: true\` if install-script-only. Opt-out priority: (1) \`SENTRY\_CLI\_NO\_TELEMETRY=1\`, (2) \`DO\_NOT\_TRACK=1\`, (3) \`metadata.defaults.telemetry\`, (4) default on. \`computeTelemetryEffective()\` returns resolved source for display. + + +* **Test helpers for host-scoping security tests**: (pattern) Test helpers for host-scoping security tests: \`test/helpers.ts\` provides shared utilities. \`useEnvSandbox(keys)\` saves+clears+restores env keys (do NOT use in tests depending on preload's \`SENTRY\_AUTH\_TOKEN\`). \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\` (always reset together). \`mintSntrysToken(payload)\` produces \`sntrys\_\\_\\` test tokens (rstrip \`=\`). \`extractFetchUrl(input)\` for fetch-mock assertions. \`useTestConfigDir\` handles config-dir isolation. Tests mocking fetch with non-SaaS URLs must pass \`{host}\` to \`setAuthToken\` — token defaults to SaaS via \`captureEnvTokenHost\`. For \`assertRcUrlTrusted\`: sequence is \`resetEnvTokenHostForTesting()\` → delete env vars → \`captureEnvTokenHost()\` → \`applySentryCliRcEnvShim()\` → \`assertRcUrlTrusted()\`. E2E: \`createE2EContext\` parent must \`setAuthToken(token, ttl, {host: serverUrl})\`; multi-region tests need \`registerTrustedRegionUrls\`. diff --git a/AGENTS.md b/AGENTS.md index f6b660c28..400ef7dbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1066,85 +1066,5 @@ mock.module("./some-module", () => ({ ## Long-term Knowledge -### Architecture - - -* **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. - - -* **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. - - -* **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. - -### Decision - - -* **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. - -### Gotcha - - -* **--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. - - -* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: Biome lint traps: (1) \`noUselessUndefined\` rejects \`() => undefined\` AND \`noEmptyBlockStatements\` rejects \`() => {}\` — use top-level \`function noop(): void {}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15. (3) \`expect(() => fn()).toThrow(X)\` must be one line. (4) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (5) Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`, \`useSimplifiedLogicExpression\`, \`noShadow\`. Namespace imports forbidden. (6) \`useYield\` fires on \`async \*func()\` with statements but not empty bodies — only add \`biome-ignore\` to generators with statements. \`lint:fix\` differs from CI \`lint\`: auto-fix hides \`noPrecisionLoss\` on >2^53 literals, \`noIncrementDecrement\`, import ordering. Always \`bun run lint\` before pushing. - - -* **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 \ -* **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); ... })\`. - - -* **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\`. - -### Pattern - - -* **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. - - -* **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\`. - - -* **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. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: 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. \`hasPreviousPage\` checks \`page\_index > 0\`. \`paginationHint()\` builds nav strings. All list commands use this. Critical: \`resolveCursor()\` must be called inside \`org-all\` override closures, not before \`dispatchOrgScopedList\`. - - -* **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\`. - -### Preference - - -* **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. +For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) (gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) in the project root. diff --git a/src/commands/api.ts b/src/commands/api.ts index a80334263..a08436147 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -5,6 +5,7 @@ * Similar to 'gh api' for GitHub. */ +import { access, readFile } from "node:fs/promises"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import type { SentryContext } from "../context.js"; @@ -828,11 +829,14 @@ export async function buildBodyFromInput( if (inputPath === "-") { content = await readStdin(stdin); } else { - const file = Bun.file(inputPath); - if (!(await file.exists())) { + const exists = await access(inputPath).then( + () => true, + () => false + ); + if (!exists) { throw new ValidationError(`File not found: ${inputPath}`, "input"); } - content = await file.text(); + content = await readFile(inputPath, "utf-8"); } // Try to parse as JSON for the API client diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 7fb4599ff..cac820f45 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -14,8 +14,10 @@ * so that subsequent bare `sentry cli upgrade` calls use the same channel. */ +import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { dirname } from "node:path"; +import { setTimeout } from "node:timers/promises"; import type { SentryContext } from "../../context.js"; import { determineInstallDir, @@ -425,12 +427,14 @@ async function spawnWithRetry( ): Promise { for (let attempt = 1; attempt <= SPAWN_MAX_ATTEMPTS; attempt++) { try { - const proc = Bun.spawn([binaryPath, ...args], { - stdout: "inherit", - stderr: "inherit", + const proc = spawn(binaryPath, args, { + stdio: ["ignore", "inherit", "inherit"], env, }); - return await proc.exited; + return await new Promise((resolve) => { + proc.on("close", (code) => resolve(code ?? 1)); + proc.on("error", () => resolve(1)); + }); } catch (error) { // Translate the opaque Bun "Executable not found" error into an // actionable UpgradeError. This path triggers when the binary at @@ -454,7 +458,7 @@ async function spawnWithRetry( log.warn( `Binary is locked (antivirus scan?), retrying in ${delay}ms... (attempt ${attempt}/${SPAWN_MAX_ATTEMPTS})` ); - await Bun.sleep(delay); + await setTimeout(delay); } } // Unreachable — the loop either returns or throws diff --git a/src/commands/dashboard/list.ts b/src/commands/dashboard/list.ts index 52185bcce..13998f1cd 100644 --- a/src/commands/dashboard/list.ts +++ b/src/commands/dashboard/list.ts @@ -5,6 +5,7 @@ * and optional client-side glob filtering by title. */ +import picomatch from "picomatch"; import type { SentryContext } from "../../context.js"; import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js"; import { @@ -233,7 +234,7 @@ function processPage( limit: number; serverCursor: string | undefined; afterId: string | undefined; - glob: InstanceType | undefined; + glob: ((input: string) => boolean) | undefined; } ): PageResult { // When resuming mid-page, find the afterId and skip everything up to and @@ -249,7 +250,7 @@ function processPage( for (let i = startIdx; i < data.length; i++) { const item = data[i] as DashboardListItem; - if (!opts.glob || opts.glob.match(item.title.toLowerCase())) { + if (!opts.glob || opts.glob(item.title.toLowerCase())) { results.push(item); if (results.length >= opts.limit) { return { @@ -283,7 +284,7 @@ async function fetchDashboards( perPage: number; serverCursor: string | undefined; afterId: string | undefined; - glob: InstanceType | undefined; + glob: ((input: string) => boolean) | undefined; } ): Promise { let { serverCursor, afterId } = opts; @@ -434,7 +435,7 @@ export const listCommand = buildListCommand("dashboard", { const { serverCursor, afterId } = decodeCursor(rawCursor ?? ""); const glob = titleFilter - ? new Bun.Glob(titleFilter.toLowerCase()) + ? picomatch(titleFilter.toLowerCase(), { dot: true }) : undefined; // When filtering, fetch max-size pages to minimize round trips. diff --git a/src/lib/agent-skills.ts b/src/lib/agent-skills.ts index 3823a82b4..8bea02239 100644 --- a/src/lib/agent-skills.ts +++ b/src/lib/agent-skills.ts @@ -10,6 +10,7 @@ */ import { accessSync, constants, existsSync, mkdirSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { captureException } from "@sentry/node-core/light"; import { SKILL_FILES } from "../generated/skill-content.js"; @@ -68,7 +69,7 @@ async function writeSkillFiles( if (!existsSync(dir)) { mkdirSync(dir, { recursive: true, mode: 0o755 }); } - await Bun.write(fullPath, content); + await writeFile(fullPath, content, "utf-8"); if (relativePath.startsWith("references/")) { referenceCount += 1; } diff --git a/src/lib/binary.ts b/src/lib/binary.ts index 408bea1b0..214a30912 100644 --- a/src/lib/binary.ts +++ b/src/lib/binary.ts @@ -5,6 +5,7 @@ * Used by both `setup --install` (fresh installs) and `upgrade` (self-updates). */ +import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, @@ -12,8 +13,9 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { chmod, mkdir, unlink } from "node:fs/promises"; +import { chmod, copyFile, mkdir, unlink } from "node:fs/promises"; import { delimiter, join, resolve } from "node:path"; +import { compare as semverCompare } from "semver"; import { getUserAgent } from "./constants.js"; import { buildTlsErrorDetail, @@ -56,9 +58,8 @@ export function isMusl(): boolean { // Heuristic 2: ldd --version output (musl ldd writes "musl libc" to stderr) try { - const result = Bun.spawnSync(["ldd", "--version"], { - stdout: "pipe", - stderr: "pipe", + const result = spawnSync("ldd", ["--version"], { + stdio: ["ignore", "pipe", "pipe"], }); const output = Buffer.from(result.stdout).toString() + @@ -130,7 +131,7 @@ export function isNightlyVersion(version: string): boolean { * @returns 1 if a > b, -1 if a < b, 0 if equal */ export function compareVersions(a: string, b: string): -1 | 0 | 1 { - return Bun.semver.order(a, b); + return semverCompare(a, b); } /** @@ -451,7 +452,7 @@ export async function installBinary( } // Copy source binary to temp path next to install location - await Bun.write(tempPath, Bun.file(sourcePath)); + await copyFile(sourcePath, tempPath); // Set executable permission (Unix only) if (process.platform !== "win32") { diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 7536bbe37..9b940c065 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -2,10 +2,18 @@ * Browser utilities * * Cross-platform utilities for interacting with the user's browser. - * Uses Bun.spawn and Bun.which for process management. + * Uses child_process.spawn and whichSync for process management. */ +import { spawn } from "node:child_process"; +import { setTimeout } from "node:timers/promises"; import { generateQRCode } from "./qrcode.js"; +import { whichSync } from "./which.js"; + +/** No-op error handler to prevent unhandled error crashes from spawn. */ +function noop(): void { + // Intentionally empty — absorbs async spawn errors (e.g., ENOENT) +} /** * Open a URL in the user's default browser. @@ -20,10 +28,10 @@ export async function openBrowser(url: string): Promise { let args: string[]; if (platform === "darwin") { - command = Bun.which("open"); + command = whichSync("open"); args = [url]; } else if (platform === "win32") { - command = Bun.which("cmd"); + command = whichSync("cmd"); args = ["/c", "start", "", url]; } else { // Linux and other Unix-like systems - try multiple openers @@ -35,7 +43,7 @@ export async function openBrowser(url: string): Promise { "kde-open", ]; for (const opener of linuxOpeners) { - command = Bun.which(opener); + command = whichSync(opener); if (command) { break; } @@ -48,13 +56,15 @@ export async function openBrowser(url: string): Promise { } try { - const proc = Bun.spawn([command, ...args], { - stdout: "ignore", - stderr: "ignore", + const proc = spawn(command, args, { + stdio: ["ignore", "ignore", "ignore"], }); + // Prevent unhandled error crash if the binary disappears between + // whichSync() and spawn() (TOCTOU window). + proc.on("error", noop); // Give browser time to open, then detach - await Bun.sleep(500); + await setTimeout(500); proc.unref(); return true; } catch { diff --git a/src/lib/bspatch.ts b/src/lib/bspatch.ts index 25c98ec27..86cfbca68 100644 --- a/src/lib/bspatch.ts +++ b/src/lib/bspatch.ts @@ -31,7 +31,7 @@ */ import { constants, copyFileSync } from "node:fs"; -import { unlink } from "node:fs/promises"; +import { readFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -269,7 +269,7 @@ async function loadOldBinary(oldPath: string): Promise { /* May not exist if copyFileSync failed */ }); return { - data: new Uint8Array(await Bun.file(oldPath).arrayBuffer()), + data: new Uint8Array(await readFile(oldPath)), cleanup: () => { // Data is in JS heap — no temp file to clean up }, diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index d2200c407..e4ed2460b 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -5,7 +5,9 @@ * Includes both low-level copy function and interactive keyboard-triggered copy. */ +import { spawn } from "node:child_process"; import { logger } from "./logger.js"; +import { whichSync } from "./which.js"; const log = logger.withTag("clipboard"); @@ -29,18 +31,18 @@ export async function copyToClipboard(text: string): Promise { let args: string[] = []; if (platform === "darwin") { - command = Bun.which("pbcopy"); + command = whichSync("pbcopy"); args = []; } else if (platform === "win32") { - command = Bun.which("clip"); + command = whichSync("clip"); args = []; } else { // Linux - try xclip first, then xsel - command = Bun.which("xclip"); + command = whichSync("xclip"); if (command) { args = ["-selection", "clipboard"]; } else { - command = Bun.which("xsel"); + command = whichSync("xsel"); if (command) { args = ["--clipboard", "--input"]; } @@ -52,16 +54,20 @@ export async function copyToClipboard(text: string): Promise { } try { - const proc = Bun.spawn([command, ...args], { - stdin: "pipe", - stdout: "ignore", - stderr: "ignore", + const proc = spawn(command, args, { + stdio: ["pipe", "ignore", "ignore"], }); - proc.stdin.write(text); - proc.stdin.end(); + const { stdin } = proc; + if (stdin) { + stdin.write(text); + stdin.end(); + } - const exitCode = await proc.exited; + const exitCode = await new Promise((resolve) => { + proc.on("close", (code) => resolve(code ?? 1)); + proc.on("error", () => resolve(1)); + }); return exitCode === 0; } catch { return false; diff --git a/src/lib/completions.ts b/src/lib/completions.ts index 860cbcdb7..d3746e219 100644 --- a/src/lib/completions.ts +++ b/src/lib/completions.ts @@ -13,6 +13,7 @@ */ import { existsSync, mkdirSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { routes } from "../app.js"; import { type FlagDef, isCommand, isRouteMap } from "./introspect.js"; @@ -649,7 +650,7 @@ export async function installCompletions( } const alreadyExists = existsSync(path); - await Bun.write(path, script); + await writeFile(path, script, "utf-8"); return { path, diff --git a/src/lib/db/dsn-cache.ts b/src/lib/db/dsn-cache.ts index 2dddd478c..3fe87281d 100644 --- a/src/lib/db/dsn-cache.ts +++ b/src/lib/db/dsn-cache.ts @@ -256,11 +256,11 @@ async function validateFileMtime( cachedMtime: number ): Promise { try { - const file = Bun.file(fullPath); - if (!(await file.exists())) { + const stats = await stat(fullPath); + if (!stats.isFile()) { return false; } - return file.lastModified === cachedMtime; + return Math.floor(stats.mtimeMs) === cachedMtime; } catch { return false; } diff --git a/src/lib/db/instance.ts b/src/lib/db/instance.ts index 0b064d606..ea4805051 100644 --- a/src/lib/db/instance.ts +++ b/src/lib/db/instance.ts @@ -5,6 +5,7 @@ * Uses UUIDv7 for time-sortable, unique identifiers. */ +import { uuidv7 } from "uuidv7"; import { getDatabase } from "./index.js"; /** @@ -28,8 +29,7 @@ export function getInstanceId(): string { // Generate and store new instance ID // Use INSERT OR IGNORE to handle race condition when multiple CLI processes // start simultaneously - only the first insert succeeds - // Bun.randomUUIDv7() is native in Bun, polyfilled via uuidv7 package for Node.js - const instanceId = Bun.randomUUIDv7(); + const instanceId = uuidv7(); const now = Date.now(); db.query(` diff --git a/src/lib/delta-upgrade.ts b/src/lib/delta-upgrade.ts index a9d35f4f2..cf7d0ed18 100644 --- a/src/lib/delta-upgrade.ts +++ b/src/lib/delta-upgrade.ts @@ -21,6 +21,7 @@ import { unlinkSync } from "node:fs"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; +import { compare as semverCompare } from "semver"; import { GITHUB_RELEASES_URL, @@ -465,15 +466,15 @@ export function filterAndSortChainTags( const version = tag.slice(PATCH_TAG_PREFIX.length); // Include tags where: currentVersion < version <= targetVersion if ( - Bun.semver.order(version, currentVersion) === 1 && - Bun.semver.order(version, targetVersion) !== 1 + semverCompare(version, currentVersion) === 1 && + semverCompare(version, targetVersion) !== 1 ) { chainTags.push({ tag, version }); } } // Sort by version (chronological for nightlies) - chainTags.sort((a, b) => Bun.semver.order(a.version, b.version)); + chainTags.sort((a, b) => semverCompare(a.version, b.version)); return chainTags.map((t) => t.tag); } diff --git a/src/lib/dsn/code-scanner.ts b/src/lib/dsn/code-scanner.ts index f0ab2043b..f2f81dd42 100644 --- a/src/lib/dsn/code-scanner.ts +++ b/src/lib/dsn/code-scanner.ts @@ -26,6 +26,7 @@ * the cache schema. */ +import { readFile } from "node:fs/promises"; import path from "node:path"; import { DEFAULT_SENTRY_HOST, getConfiguredSentryUrl } from "../constants.js"; import { ConfigError } from "../errors.js"; @@ -166,7 +167,7 @@ export function scanCodeForFirstDsn(cwd: string): Promise { filesScanned += 1; let content: string; try { - content = await Bun.file(entry.absolutePath).text(); + content = await readFile(entry.absolutePath, "utf-8"); } catch { continue; } diff --git a/src/lib/dsn/detector.ts b/src/lib/dsn/detector.ts index 19aef2e6f..aa8bf590d 100644 --- a/src/lib/dsn/detector.ts +++ b/src/lib/dsn/detector.ts @@ -12,7 +12,7 @@ * Priority: .env with SENTRY_DSN > code > .env files > SENTRY_DSN env var */ -import { stat } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { getCachedDetection, @@ -291,7 +291,7 @@ async function verifyFileDsnCache( if (!(await isRegularFile(filePath, "verifyFileDsnCache.stat"))) { return null; } - const content = await Bun.file(filePath).text(); + const content = await readFile(filePath, "utf-8"); const foundDsn = extractDsnFromContent(content, cached.source); if (foundDsn === cached.dsn) { diff --git a/src/lib/dsn/fs-utils.ts b/src/lib/dsn/fs-utils.ts index e0bc0f9e1..a84141b3f 100644 --- a/src/lib/dsn/fs-utils.ts +++ b/src/lib/dsn/fs-utils.ts @@ -4,6 +4,7 @@ * Shared utilities for handling file system errors during scanning. */ +import { stat } from "node:fs/promises"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; @@ -63,9 +64,9 @@ export function handleFileError( * Check if a path points to a regular file (not a FIFO, socket, device, etc.). * * Named pipes (FIFOs) — commonly used by 1Password to stream secrets via - * symlinked `.env` files — cause `Bun.file().text()` to block indefinitely - * waiting for a writer. This guard uses `Bun.file(path).stat()`, which follows - * symlinks and inspects file type without performing the blocking read, so a + * symlinked `.env` files — cause `readFile()` to block indefinitely + * waiting for a writer. This guard uses `stat()`, which follows symlinks + * and inspects file type without performing the blocking read, so a * symlink → FIFO is correctly detected. * * @param filePath - Absolute path to check @@ -77,7 +78,7 @@ export async function isRegularFile( operation = "isRegularFile" ): Promise { try { - const stats = await Bun.file(filePath).stat(); + const stats = await stat(filePath); return stats.isFile(); } catch (error) { handleFileError(error, { operation, path: filePath }); diff --git a/src/lib/dsn/project-root.ts b/src/lib/dsn/project-root.ts index fbde43247..11bf454d9 100644 --- a/src/lib/dsn/project-root.ts +++ b/src/lib/dsn/project-root.ts @@ -13,10 +13,11 @@ * Stops at: home directory or filesystem root */ -import { opendir, stat } from "node:fs/promises"; +import { opendir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import pLimit from "p-limit"; +import picomatch from "picomatch"; import { anyTrue } from "../promises.js"; import { applyGlobalFallbacks, @@ -223,7 +224,7 @@ function anyExists(dir: string, names: readonly string[]): Promise { * Check if any files matching glob patterns exist in a directory. * Uses `opendir` to lazily stream directory entries and exits on first match * without reading the entire directory. Matches via synchronous - * `Bun.Glob.match()` (no async I/O, event-loop safe). + * `picomatch` (no async I/O, event-loop safe). * * @param dir - Directory to check * @param patterns - Glob patterns to match @@ -238,11 +239,10 @@ async function anyGlobMatches( // No explicit handle.close() needed: for-await-of auto-closes the Dir // handle when the loop exits (including early return or break). try { + // Pre-compile matchers outside the loop to avoid recompiling per entry. + const matchers = patterns.map((p) => picomatch(p, { dot: true })); for await (const entry of await opendir(dir)) { - if ( - entry.isFile() && - patterns.some((p) => new Bun.Glob(p).match(entry.name)) - ) { + if (entry.isFile() && matchers.some((m) => m(entry.name))) { return true; } } @@ -268,7 +268,7 @@ async function checkEditorConfigRoot(dir: string): Promise { ) { return false; } - const content = await Bun.file(editorConfigPath).text(); + const content = await readFile(editorConfigPath, "utf-8"); return EDITORCONFIG_ROOT_REGEX.test(content); } catch (error) { handleFileError(error, { @@ -373,7 +373,7 @@ function checkEnvForDsn(dir: string): Promise { if (!(await isRegularFile(filePath, "checkEnvForDsn.stat"))) { continue; } - const content = await Bun.file(filePath).text(); + const content = await readFile(filePath, "utf-8"); const dsn = extractDsnFromEnvContent(content); if (dsn) { return createDetectedDsn(dsn, "env_file", filename); diff --git a/src/lib/dsn/scanner.ts b/src/lib/dsn/scanner.ts index 883408750..c792def7e 100644 --- a/src/lib/dsn/scanner.ts +++ b/src/lib/dsn/scanner.ts @@ -5,6 +5,7 @@ * Used by env-file detection for scanning .env file variants. */ +import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { handleFileError, isRegularFile } from "./fs-utils.js"; import type { DetectedDsn } from "./types.js"; @@ -76,8 +77,7 @@ export async function scanSpecificFiles( if (!(await isRegularFile(filepath, "scanSpecificFiles.stat"))) { continue; } - const file = Bun.file(filepath); - const content = await file.text(); + const content = await readFile(filepath, "utf-8"); const result = processFile(filename, content); if (result?.dsn) { @@ -85,7 +85,8 @@ export async function scanSpecificFiles( if (detected) { dsns.push(detected); // Record mtime for cache invalidation - sourceMtimes[filename] = file.lastModified; + const stats = await stat(filepath); + sourceMtimes[filename] = Math.floor(stats.mtimeMs); if (stopOnFirst) { return { dsns, sourceMtimes }; diff --git a/src/lib/init/tools/command-utils.ts b/src/lib/init/tools/command-utils.ts index f78b0a991..74461ac8d 100644 --- a/src/lib/init/tools/command-utils.ts +++ b/src/lib/init/tools/command-utils.ts @@ -6,7 +6,7 @@ import { MAX_OUTPUT_BYTES } from "../constants.js"; const WHITESPACE_CHAR_RE = /\s/u; /** - * Patterns that indicate shell injection. Commands run via `Bun.spawn` + * Patterns that indicate shell injection. Commands run via `child_process.spawn` * without a shell, so these patterns are defense-in-depth for chaining, * piping, redirection, and command substitution. */ diff --git a/src/lib/init/tools/run-commands.ts b/src/lib/init/tools/run-commands.ts index c9918e0e9..f869db1b1 100644 --- a/src/lib/init/tools/run-commands.ts +++ b/src/lib/init/tools/run-commands.ts @@ -1,4 +1,6 @@ +import { spawn } from "node:child_process"; import { addBreadcrumb } from "@sentry/node-core/light"; +import { whichSync } from "../../which.js"; import { DEFAULT_COMMAND_TIMEOUT_MS } from "../constants.js"; import type { RunCommandsPayload, ToolResult } from "../types.js"; import { @@ -77,23 +79,25 @@ async function runSingleCommand( stdout: string; stderr: string; }> { - const executable = Bun.which(command.executable) ?? command.executable; + const executable = whichSync(command.executable) ?? command.executable; try { - const child = Bun.spawn([executable, ...command.args], { + const child = spawn(executable, command.args, { cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + stdio: ["ignore", "pipe", "pipe"], + }); + const exited = new Promise((resolve) => { + child.on("close", (code) => resolve(code ?? 1)); + child.on("error", () => resolve(1)); }); let timedOut = false; - const timer = setTimeout(() => { + const timer = globalThis.setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs); const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, + exited, readSpawnOutput(child.stdout), readSpawnOutput(child.stderr), ]); diff --git a/src/lib/patch-cache.ts b/src/lib/patch-cache.ts index d58de9b5e..92b3ede82 100644 --- a/src/lib/patch-cache.ts +++ b/src/lib/patch-cache.ts @@ -19,7 +19,7 @@ * offline. */ -import { mkdir, readdir, unlink } from "node:fs/promises"; +import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { getConfigDir } from "./db/index.js"; @@ -112,7 +112,7 @@ export async function savePatchesToCache( cacheDir, patchFileName(step.fromVersion, step.toVersion) ); - return [Bun.write(filePath, patch.data)]; + return [writeFile(filePath, patch.data)]; }) ); @@ -136,7 +136,7 @@ export async function savePatchesToCache( cacheDir, chainFileName(firstStep.fromVersion, lastStep.toVersion) ); - await Bun.write(metaPath, JSON.stringify(meta)); + await writeFile(metaPath, JSON.stringify(meta), "utf-8"); } } } @@ -164,7 +164,9 @@ async function loadAllChainMetas(cacheDir: string): Promise { const results = await Promise.all( metaFiles.map(async (file) => { try { - return (await Bun.file(join(cacheDir, file)).json()) as ChainMeta; + return JSON.parse( + await readFile(join(cacheDir, file), "utf-8") + ) as ChainMeta; } catch { // Corrupt metadata file — skip return null; @@ -275,7 +277,7 @@ export async function loadCachedChain( patchFileName(step.fromVersion, step.toVersion) ); try { - const data = new Uint8Array(await Bun.file(filePath).arrayBuffer()); + const data = new Uint8Array(await readFile(filePath)); return { data, size: data.byteLength }; } catch (err) { if (isNotFound(err)) { @@ -321,9 +323,9 @@ async function removeExpiredEntries( .filter((f) => f.startsWith("chain-") && f.endsWith(".json")) .map(async (file) => { try { - const meta = (await Bun.file( - join(cacheDir, file) - ).json()) as ChainMeta; + const meta = JSON.parse( + await readFile(join(cacheDir, file), "utf-8") + ) as ChainMeta; return { file, meta }; } catch { // Corrupt metadata — schedule for removal diff --git a/src/lib/polling.ts b/src/lib/polling.ts index 8857fe79e..7dd104c2c 100644 --- a/src/lib/polling.ts +++ b/src/lib/polling.ts @@ -5,6 +5,7 @@ * Used by commands that need to wait for async operations to complete. */ +import { setTimeout as sleepMs } from "node:timers/promises"; import { TimeoutError } from "./errors.js"; import { isPlainOutput } from "./formatters/plain-detect.js"; import { @@ -101,7 +102,7 @@ export async function poll(options: PollOptions): Promise { } } - await Bun.sleep(pollIntervalMs); + await sleepMs(pollIntervalMs); } throw new TimeoutError(timeoutMessage, timeoutHint); diff --git a/src/lib/safe-read.ts b/src/lib/safe-read.ts index d8a62cb57..9a7a90d35 100644 --- a/src/lib/safe-read.ts +++ b/src/lib/safe-read.ts @@ -2,16 +2,16 @@ * FIFO-safe file-read helper for user-controlled paths. * * Named pipes (FIFOs), commonly created by 1Password's `.env` symlink - * integration, cause `Bun.file(path).text()` to block indefinitely - * waiting for a writer. Any read of a path under the user's project - * tree or home directory needs a `stat`-based regular-file check - * first. + * integration, cause `readFile()` to block indefinitely waiting for a + * writer. Any read of a path under the user's project tree or home + * directory needs a `stat`-based regular-file check first. * - * Prefer this helper over calling `isRegularFile` + `Bun.file().text()` - * by hand: a single call, consistent error handling, no way to forget + * Prefer this helper over calling `isRegularFile` + `readFile()` by + * hand: a single call, consistent error handling, no way to forget * the guard. */ +import { readFile } from "node:fs/promises"; import { handleFileError, isRegularFile } from "./dsn/fs-utils.js"; /** @@ -37,7 +37,7 @@ export async function safeReadFile( return null; } try { - return await Bun.file(filePath).text(); + return await readFile(filePath, "utf-8"); } catch (error) { handleFileError(error, { operation, path: filePath }); return null; diff --git a/src/lib/scan/grep.ts b/src/lib/scan/grep.ts index 4967ed48b..045065fb5 100644 --- a/src/lib/scan/grep.ts +++ b/src/lib/scan/grep.ts @@ -12,7 +12,7 @@ * `collectGrep` (drained + sorted). Both share `setupGrepPipeline` * so defaults/compilation/matching are configured in one place. * - * File reads use `Bun.file(path).text()`; the walker's `maxFileSize` + * File reads use `readFile(path, "utf-8")`; the walker's `maxFileSize` * (default 256 KB) caps the blast radius. CRLF is preserved verbatim. * * Matching uses whole-buffer `regex.exec` iteration with the per-file @@ -28,6 +28,7 @@ * debugging via `SENTRY_SCAN_DISABLE_WORKERS=1`. */ +import { readFile } from "node:fs/promises"; import { handleFileError } from "../dsn/fs-utils.js"; import { type ConcurrentOptions, @@ -148,7 +149,7 @@ async function readAndGrep( ): Promise { let content: string; try { - content = await Bun.file(entry.absolutePath).text(); + content = await readFile(entry.absolutePath, "utf-8"); } catch (error) { handleFileError(error, { operation: "scan.grep.readFile", diff --git a/src/lib/scan/ignore.ts b/src/lib/scan/ignore.ts index de18ec963..420b75b6b 100644 --- a/src/lib/scan/ignore.ts +++ b/src/lib/scan/ignore.ts @@ -41,6 +41,7 @@ * `${cwd}/.git/info/exclude` if it exists. Matches ripgrep's behavior. */ +import { readFile } from "node:fs/promises"; import path from "node:path"; import ignore, { type Ignore } from "ignore"; import { handleFileError } from "../dsn/fs-utils.js"; @@ -147,7 +148,7 @@ export class IgnoreStack implements IgnoreMatcher { } const gitignorePath = path.join(absDir, ".gitignore"); try { - const content = await Bun.file(gitignorePath).text(); + const content = await readFile(gitignorePath, "utf-8"); if (!content || content.trim().length === 0) { return; } @@ -268,7 +269,7 @@ export class IgnoreStack implements IgnoreMatcher { */ async function appendGitignoreFile(ig: Ignore, absPath: string): Promise { try { - const content = await Bun.file(absPath).text(); + const content = await readFile(absPath, "utf-8"); if (content.length > 0) { ig.add(content); } diff --git a/src/lib/scan/types.ts b/src/lib/scan/types.ts index d1747cebf..1125a4fba 100644 --- a/src/lib/scan/types.ts +++ b/src/lib/scan/types.ts @@ -21,10 +21,10 @@ export type WalkEntry = { * Does not start with `./`. Does not end with `/`. */ relativePath: string; - /** Size in bytes, from `Bun.file(path).size`. */ + /** Size in bytes. */ size: number; /** - * mtime in milliseconds since epoch, from `Bun.file(path).lastModified`. + * mtime in milliseconds since epoch. * Zero when `recordMtimes: false` (the default) — stat'ing every file * adds measurable overhead on large scans. */ diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index c09bf48e5..026e8400b 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -8,6 +8,7 @@ * through the SDK function options (baseUrl, fetch, headers). */ +import { setTimeout as sleepMs } from "node:timers/promises"; import { getTraceData } from "@sentry/node-core/light"; import { maybeWarnEnvTokenIgnored } from "./auth-hint.js"; import { computeInvalidationPrefixes } from "./cache-keys.js"; @@ -544,7 +545,7 @@ async function fetchWithRetry( log.debug( `${method} ${new URL(fullUrl).pathname} → retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms` ); - await Bun.sleep(delay); + await sleepMs(delay); } // Unreachable: the last attempt always returns 'done' or 'throw' diff --git a/src/lib/sentryclirc.ts b/src/lib/sentryclirc.ts index 7270b6218..83406f94c 100644 --- a/src/lib/sentryclirc.ts +++ b/src/lib/sentryclirc.ts @@ -16,7 +16,7 @@ * `resolve-target.ts`. */ -import { stat } from "node:fs/promises"; +import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { normalizeUrl } from "./constants.js"; @@ -163,7 +163,7 @@ async function tryReadSentryCliRc(filePath: string): Promise { return null; } try { - return await Bun.file(filePath).text(); + return await readFile(filePath, "utf-8"); } catch (error) { if (isNarrowAbsenceError(error)) { return null; diff --git a/src/lib/shell.ts b/src/lib/shell.ts index da626f154..728d82b15 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -6,7 +6,9 @@ */ import { existsSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; import { basename, delimiter, join } from "node:path"; +import { whichSync } from "./which.js"; /** Supported shell types */ export type ShellType = "bash" | "zsh" | "fish" | "sh" | "ash" | "unknown"; @@ -187,12 +189,14 @@ async function addToShellConfig( command: string, label: string ): Promise { - const file = Bun.file(configFile); - const exists = await file.exists(); + const exists = await access(configFile).then( + () => true, + () => false + ); if (!exists) { try { - await Bun.write(configFile, `# sentry\n${command}\n`); + await writeFile(configFile, `# sentry\n${command}\n`, "utf-8"); return { modified: true, configFile, @@ -209,7 +213,7 @@ async function addToShellConfig( } } - const content = await file.text(); + const content = await readFile(configFile, "utf-8"); if (content.includes(command) || content.includes(`"${directory}"`)) { return { @@ -225,7 +229,7 @@ async function addToShellConfig( ? `${content}\n# sentry\n${command}\n` : `${content}\n\n# sentry\n${command}\n`; - await Bun.write(configFile, newContent); + await writeFile(configFile, newContent, "utf-8"); return { modified: true, configFile, @@ -292,14 +296,18 @@ export async function addToGitHubPath( } try { - const file = Bun.file(env.GITHUB_PATH); - const content = (await file.exists()) ? await file.text() : ""; + let content = ""; + try { + content = await readFile(env.GITHUB_PATH, "utf-8"); + } catch { + // File doesn't exist yet — start with empty content + } if (!content.includes(directory)) { const newContent = content.endsWith("\n") ? `${content}${directory}\n` : `${content}\n${directory}\n`; - await Bun.write(env.GITHUB_PATH, newContent); + await writeFile(env.GITHUB_PATH, newContent, "utf-8"); } return true; } catch { @@ -318,5 +326,5 @@ export async function addToGitHubPath( */ export function isBashAvailable(pathEnv?: string): boolean { const opts = pathEnv !== undefined ? { PATH: pathEnv } : undefined; - return Bun.which("bash", opts) !== null; + return whichSync("bash", opts) !== null; } diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 9758b9acf..a5b1a3342 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -8,8 +8,10 @@ import { spawn } from "node:child_process"; import { chmodSync, realpathSync, statSync, unlinkSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, sep } from "node:path"; +import { setTimeout } from "node:timers/promises"; import { acquireLock, cleanupOldBinary, @@ -658,7 +660,7 @@ async function downloadStableToPath( // process before the download completes (Bun event-loop bug). // See: https://github.com/oven-sh/bun/issues/13237 const body = await response.arrayBuffer(); - await Bun.write(destPath, body); + await writeFile(destPath, new Uint8Array(body)); } /** @@ -725,7 +727,7 @@ async function waitForBinaryVisible(path: string): Promise { log.debug( `Downloaded binary not yet visible at ${path}, retrying in ${delay}ms (attempt ${attempt}/${VERIFY_MAX_ATTEMPTS})` ); - await Bun.sleep(delay); + await setTimeout(delay); } throw new UpgradeError( "execution_failed", diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index 44500fc2f..837ddb925 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -9,6 +9,7 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; +import { compare as semverCompare } from "semver"; import { CLI_VERSION } from "./constants.js"; import { getReleaseChannel } from "./db/release-channel.js"; import { @@ -125,7 +126,7 @@ async function maybePrefetchPatches( latestVersion: string, signal: AbortSignal ): Promise { - if (Bun.semver.order(latestVersion, CLI_VERSION) !== 1) { + if (semverCompare(latestVersion, CLI_VERSION) !== 1) { return; } try { @@ -272,7 +273,7 @@ function getUpdateNotificationWithCopy( // Use Bun's native semver comparison (polyfilled for Node.js) // order() returns 1 if first arg is greater than second - if (Bun.semver.order(latestVersion, CLI_VERSION) !== 1) { + if (semverCompare(latestVersion, CLI_VERSION) !== 1) { return null; } diff --git a/src/lib/which.ts b/src/lib/which.ts new file mode 100644 index 000000000..881b2e9de --- /dev/null +++ b/src/lib/which.ts @@ -0,0 +1,65 @@ +/** + * Cross-platform executable lookup utility. + * + * Provides a `whichSync` function that finds an executable in the system PATH, + * replacing direct `Bun.which()` calls so the same code works under both + * Bun and the Node.js npm distribution. + */ + +import { execFileSync } from "node:child_process"; + +/** + * Synchronously find the full path to a command in the system PATH. + * + * On Unix, delegates to `sh -c 'command -v "$1"' -- ` so the + * command name is never interpolated into the shell string (safe from + * injection). On Windows, uses `where.exe` via `execFileSync` (no shell). + * + * Returns the first match or `null` when the command is not found. + * + * @param command - The executable name to look up + * @param opts - Optional overrides; set `PATH` to search a custom path string + * @returns Absolute path to the executable, or `null` if not found + */ +export function whichSync( + command: string, + opts?: { PATH?: string } +): string | null { + try { + const isWindows = process.platform === "win32"; + // If a custom PATH is provided, override it in the subprocess env. + // Use !== undefined (not truthy) so empty-string PATH is respected. + const env = + opts?.PATH !== undefined + ? { ...process.env, PATH: opts.PATH } + : undefined; + + let stdout: string; + if (isWindows) { + // execFileSync bypasses the shell entirely — no injection risk + stdout = execFileSync("where.exe", [command], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + env, + }); + } else { + // Pass command as a positional arg ($1) so it's never interpolated + // into the shell string. `command -v` is a POSIX builtin — works + // even when PATH is overridden to a restricted set of directories. + // Use absolute /bin/sh so the shell itself is found regardless of PATH. + stdout = execFileSync( + "/bin/sh", + ["-c", 'command -v "$1"', "--", command], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + env, + } + ); + } + + return stdout.trim().split("\n")[0] || null; + } catch { + return null; + } +} diff --git a/test/commands/cli/upgrade.test.ts b/test/commands/cli/upgrade.test.ts index e607cbe1e..c208f9d77 100644 --- a/test/commands/cli/upgrade.test.ts +++ b/test/commands/cli/upgrade.test.ts @@ -9,7 +9,17 @@ * via a spy on process.stderr.write and assert on the collected output. */ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as child_process from "node:child_process"; import { mkdirSync, rmSync } from "node:fs"; import { unlink } from "node:fs/promises"; import { join } from "node:path"; @@ -641,22 +651,34 @@ describe("sentry cli upgrade — nightly channel", () => { }); // --------------------------------------------------------------------------- -// Download + setup paths (Option B: Bun.spawn spy) +// Download + setup paths (Option B: child_process.spawn spy) // // These tests cover runSetupOnNewBinary and the full executeUpgrade flow by: // 1. Mocking fetch to return a fake binary payload for downloadBinaryToTemp -// 2. Replacing Bun.spawn with a spy that resolves immediately with exit 0 +// 2. Spying on child_process.spawn so it resolves immediately with exit 0 // -// Bun.spawn is writable on the global Bun object, so it can be temporarily -// replaced without mock.module. +// child_process.spawn is spied via spyOn so the module-level import in the +// production code picks up the mock. // --------------------------------------------------------------------------- -describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => { +/** + * Create a fake ChildProcess-like object that emits "close" with the given + * exit code on the next microtask. Used to mock child_process.spawn in tests. + */ +function fakeChildProcess(exitCode: number): child_process.ChildProcess { + const { EventEmitter } = require("node:events"); + const emitter = new EventEmitter(); + // Emit "close" asynchronously so the caller can attach listeners first + queueMicrotask(() => emitter.emit("close", exitCode)); + return emitter as unknown as child_process.ChildProcess; +} + +describe("sentry cli upgrade — curl full upgrade path (child_process.spawn spy)", () => { useTestConfigDir("test-upgrade-spawn-"); let testDir: string; - let originalSpawn: typeof Bun.spawn; - let spawnedArgs: string[][]; + let spawnedArgs: Array<{ cmd: string; args: string[] }>; + let spawnSpy: ReturnType; let restoreStderr: (() => void) | undefined; /** Redirect curl install paths to temp dir instead of ~/.sentry/bin/ */ @@ -680,21 +702,22 @@ describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => }); originalFetch = globalThis.fetch; - originalSpawn = Bun.spawn; spawnedArgs = []; - // Replace Bun.spawn with a spy that immediately resolves with exit 0 - Bun.spawn = ((cmd: string[], _opts: unknown) => { - spawnedArgs.push(cmd); - return { exited: Promise.resolve(0) }; - }) as typeof Bun.spawn; + // Spy on child_process.spawn — captures args and resolves with exit 0 + spawnSpy = spyOn(child_process, "spawn").mockImplementation( + (cmd: string, args?: readonly string[]) => { + spawnedArgs.push({ cmd, args: [...(args ?? [])] }); + return fakeChildProcess(0); + } + ); }); afterEach(async () => { restoreStderr?.(); restoreStderr = undefined; globalThis.fetch = originalFetch; - Bun.spawn = originalSpawn; + spawnSpy.mockRestore(); rmSync(testDir, { recursive: true, force: true }); // Clean up any temp binary files written to the redirected install path @@ -745,19 +768,19 @@ describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => expect(combined).toContain("Upgraded to"); expect(combined).toContain("99.99.99"); - // Verify Bun.spawn was called with the downloaded binary + setup args + // Verify child_process.spawn was called with the downloaded binary + setup args expect(spawnedArgs.length).toBeGreaterThan(0); - const setupCall = spawnedArgs.find((args) => args.includes("setup")); + const setupCall = spawnedArgs.find((entry) => entry.args.includes("setup")); expect(setupCall).toBeDefined(); - expect(setupCall).toContain("cli"); - expect(setupCall).toContain("setup"); - expect(setupCall).toContain("--quiet"); - expect(setupCall).toContain("--method"); - expect(setupCall).toContain("curl"); - expect(setupCall).toContain("--install"); + expect(setupCall?.args).toContain("cli"); + expect(setupCall?.args).toContain("setup"); + expect(setupCall?.args).toContain("--quiet"); + expect(setupCall?.args).toContain("--method"); + expect(setupCall?.args).toContain("curl"); + expect(setupCall?.args).toContain("--install"); }); - test("reports setup failure when Bun.spawn exits non-zero", async () => { + test("reports setup failure when spawn exits non-zero", async () => { // Use a unified mock that handles both the version endpoint and binary download const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); const gzipped = Bun.gzipSync(fakeContent); @@ -773,9 +796,7 @@ describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => return new Response(gzipped, { status: 200 }); }); - Bun.spawn = ((_cmd: string[], _opts: unknown) => ({ - exited: Promise.resolve(1), - })) as typeof Bun.spawn; + spawnSpy.mockImplementation(() => fakeChildProcess(1)); const { context, errors, restore } = createMockContext({ homeDir: testDir, @@ -872,11 +893,11 @@ describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => }); }); -describe("sentry cli upgrade — migrateToStandaloneForNightly (Bun.spawn spy)", () => { +describe("sentry cli upgrade — migrateToStandaloneForNightly (child_process.spawn spy)", () => { useTestConfigDir("test-upgrade-migrate-"); let testDir: string; - let originalSpawn: typeof Bun.spawn; + let migrateSpawnSpy: ReturnType; let restoreStderr: (() => void) | undefined; /** Redirect curl install paths to temp dir instead of ~/.sentry/bin/ */ @@ -900,18 +921,17 @@ describe("sentry cli upgrade — migrateToStandaloneForNightly (Bun.spawn spy)", }); originalFetch = globalThis.fetch; - originalSpawn = Bun.spawn; - Bun.spawn = ((_cmd: string[], _opts: unknown) => ({ - exited: Promise.resolve(0), - })) as typeof Bun.spawn; + migrateSpawnSpy = spyOn(child_process, "spawn").mockImplementation(() => + fakeChildProcess(0) + ); }); afterEach(async () => { restoreStderr?.(); restoreStderr = undefined; globalThis.fetch = originalFetch; - Bun.spawn = originalSpawn; + migrateSpawnSpy.mockRestore(); rmSync(testDir, { recursive: true, force: true }); for (const suffix of ["", ".download", ".old", ".lock"]) {