Skip to content

refactor: replace Bun APIs with Node.js equivalents#984

Merged
BYK merged 1 commit into
mainfrom
refactor/remove-bun-apis
May 20, 2026
Merged

refactor: replace Bun APIs with Node.js equivalents#984
BYK merged 1 commit into
mainfrom
refactor/remove-bun-apis

Conversation

@BYK
Copy link
Copy Markdown
Member

@BYK BYK commented May 20, 2026

Summary

Third step of the Bun → Node.js migration (follows #967, #970). Replaces all Bun-specific API calls in src/ with Node.js equivalents.

Changes

File I/O (18 files):

  • Bun.file(path).text()readFile(path, "utf-8") from node:fs/promises
  • Bun.file(path).json()JSON.parse(await readFile(path, "utf-8"))
  • Bun.file(path).exists()access(path).then(() => true, () => false)
  • Bun.file(path).stat()stat(path) from node:fs/promises
  • Bun.write(path, content)writeFile(path, content) from node:fs/promises
  • Bun.write(dest, Bun.file(src))copyFile(src, dest) from node:fs/promises

Process/System (9 files + 1 new):

  • Bun.which() → new src/lib/which.ts helper using command -v (POSIX) / where (Windows)
  • Bun.spawn()spawn() from node:child_process with Promise-wrapped exit code
  • Bun.spawnSync()spawnSync() from node:child_process
  • Bun.sleep()setTimeout() from node:timers/promises

Utilities (6 files):

  • Bun.Globpicomatch (already a devDependency)
  • Bun.randomUUIDv7()uuidv7() from uuidv7 package
  • Bun.semver.order()compare() from semver package

What's NOT in this PR (Group D — separate follow-up)

These Bun APIs in bspatch.ts and upgrade.ts require more careful handling:

  • Bun.file().writer() — streaming file writer (needs fs.createWriteStream)
  • Bun.zstdCompress/DecompressSync — zstd compression (needs node:zlib 22.15+)
  • Bun.mmap() — memory-mapped files (has existing fallback)
  • Bun.CryptoHasher — streaming hash (needs crypto.createHash)

Test results

All 7012 unit tests pass, 0 failures.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 20, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/_preview/pr-984/

Built to branch gh-pages at 2026-05-20 14:58 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Comment thread src/lib/which.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 98702ba. Configure here.

return await new Promise<number>((resolve) => {
proc.on("close", (code) => resolve(code ?? 1));
proc.on("error", () => resolve(1));
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spawn errors silently swallowed, breaking retry logic

High Severity

In Node.js, child_process.spawn emits ENOENT and EBUSY as asynchronous 'error' events rather than throwing synchronously (unlike Bun.spawn). The proc.on("error", () => resolve(1)) handler silently resolves the promise with exit code 1, so the catch block's ENOENT-to-UpgradeError translation and EBUSY retry-with-backoff logic are now unreachable dead code. The function will return 1 instead of retrying on Windows antivirus locks or showing a user-friendly message for missing binaries. The error handler needs to reject instead of resolve so the catch block can inspect the error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 98702ba. Configure here.

Comment thread src/lib/which.ts Outdated
Comment thread src/lib/browser.ts
Comment thread src/commands/cli/upgrade.ts
Comment thread src/lib/init/tools/run-commands.ts
Comment thread src/lib/browser.ts
Comment thread src/lib/shell.ts
…ith Node.js APIs

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.
@BYK BYK force-pushed the refactor/remove-bun-apis branch from 98702ba to cc0803a Compare May 20, 2026 14:57
@github-actions
Copy link
Copy Markdown
Contributor

Codecov Results 📊

7012 passed | Total: 7012 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

❌ Patch coverage is 78.19%. Project has 14194 uncovered lines.
✅ Project coverage is 77.13%. Comparing base (base) to head (head).

Files with missing lines (8)
File Patch % Lines
src/lib/clipboard.ts 11.76% ⚠️ 15 Missing
src/lib/which.ts 63.41% ⚠️ 15 Missing
src/lib/browser.ts 69.23% ⚠️ 4 Missing
src/commands/dashboard/list.ts 60.00% ⚠️ 2 Missing
src/lib/shell.ts 88.24% ⚠️ 2 Missing
src/commands/cli/upgrade.ts 88.89% ⚠️ 1 Missing
src/lib/init/tools/run-commands.ts 90.91% ⚠️ 1 Missing
src/lib/scan/grep.ts 50.00% ⚠️ 1 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    77.12%    77.13%    +0.01%
==========================================
  Files          321       322        +1
  Lines        61993     62061       +68
  Branches         0         0         —
==========================================
+ Hits         47808     47867       +59
- Misses       14185     14194        +9
- Partials         0         0         —

Generated by Codecov Action

@BYK BYK merged commit 472f381 into main May 20, 2026
30 checks passed
@BYK BYK deleted the refactor/remove-bun-apis branch May 20, 2026 15:05
* so that subsequent bare `sentry cli upgrade` calls use the same channel.
*/

import { spawn } from "node:child_process";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node.js spawn errors swallowed in Promise, making EBUSY retry and ENOENT detection dead code

Unlike Bun.spawn(), Node.js child_process.spawn() never throws synchronously — ENOENT and EBUSY are delivered as error events on the child process. The proc.on("error", () => resolve(1)) handler silently resolves with exit code 1, so the catch block (with its ENOENT→UpgradeError translation and EBUSY retry loop) is unreachable dead code. Change the error handler to reject(err) so errors propagate into the catch block.

Evidence
  • spawn from node:child_process (imported at line 17) returns a ChildProcess immediately without throwing; errors arrive via proc.on('error', …), not as exceptions from the spawn() call itself.
  • spawnWithRetry (line 423) wraps spawn in a try/catch that expects synchronous throws — valid for Bun.spawn() but not for Node.js.
  • proc.on('error', () => resolve(1)) at line 436 converts every spawn error (ENOENT, EBUSY, EACCES) into a successful promise resolution with exit code 1, bypassing the catch block entirely.
  • The EBUSY retry loop (lines 453–460) — the stated purpose of the function and specifically needed for Windows Defender scanning — therefore never executes.
  • The ENOENT→UpgradeError('execution_failed') path (lines 448–453) also never executes, losing the actionable error message in favour of a generic 'Setup failed with exit code 1'.
Also found at 1 additional location
  • src/commands/cli/upgrade.ts:434-437

Identified by Warden find-bugs · HM4-JQ6

Comment thread src/lib/upgrade.ts

import { spawn } from "node:child_process";
import { chmodSync, realpathSync, statSync, unlinkSync } from "node:fs";
import { writeFile } from "node:fs/promises";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streamDecompressToFile still uses Bun.file().writer() after Node.js migration

streamDecompressToFile (line 557) calls Bun.file(destPath).writer(), which will throw ReferenceError: Bun is not defined at runtime under Node.js — replace with import { createWriteStream } from 'node:fs' and pipe chunks into a WriteStream.

Evidence
  • streamDecompressToFile at line 557 calls Bun.file(destPath).writer(), a Bun-specific API.
  • This function is called by both downloadNightlyToPath and downloadStableToPath, covering all binary download paths.
  • The PR adds writeFile and setTimeout imports but leaves Bun.file().writer() untouched.
  • Running under Node.js will produce ReferenceError: Bun is not defined on any upgrade attempt that decompresses a gzip download.
  • The JSDoc comment at line 544 explicitly references Bun.file().writer() as the implementation strategy, confirming it was never converted.

Identified by Warden find-bugs · YQ3-LVT

Comment thread src/lib/browser.ts
Comment on lines +64 to 70
proc.on("error", noop);

// Give browser time to open, then detach
await Bun.sleep(500);
await setTimeout(500);
proc.unref();
return true;
} catch {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spawn errors silently absorbed by noop while function still returns true

When spawn emits an error event (e.g., ENOENT due to the TOCTOU window), noop silently absorbs it but the function still returns true, causing callers like openOrShowUrl to print "Opening in browser..." and skip the URL/QR-code fallback even though no browser was opened.

Evidence
  • proc.on("error", noop) registers a no-op that discards any Error passed to it — the error is never surfaced to the surrounding scope.
  • await setTimeout(500) does not await process exit or observe whether an error was emitted; it simply waits 500 ms unconditionally.
  • return true on line 70 is always reached regardless of whether an error event fired during the 500 ms window.
  • The caller openOrShowUrl branches on the return value: true → prints "Opening in browser..." and returns without showing the QR fallback; the fallback is only shown when openBrowser returns false.
  • Node.js spawn always emits ENOENT/EACCES as an async error event — it never throws from the spawn() call itself — so the surrounding try/catch cannot catch these cases either.

Identified by Warden find-bugs · HK4-FVB

Comment thread src/lib/delta-upgrade.ts
Comment on lines +469 to +477
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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

semver.compare throws on invalid version strings where Bun.semver.order returned null

Replace semverCompare(version, ...) calls with a null-safe wrapper (e.g. semver.valid(version) ? semverCompare(...) : null) or catch the TypeError, so malformed GHCR tag versions are silently skipped rather than crashing filterAndSortChainTags.

Evidence
  • Bun.semver.order(v1, v2) returns null for invalid semver inputs; null === 1 is false, so invalid tags were silently skipped in the loop.
  • The npm semver package's compare(v1, v2) (v7.x) constructs new SemVer(v) internally and throws TypeError: Invalid SemVer version for any non-conforming string.
  • version at line 466 is derived from tag.slice(PATCH_TAG_PREFIX.length) with no prior semver validity guard — any unexpected GHCR tag (e.g. patch-latest, patch-) would produce a non-semver string.
  • currentVersion and targetVersion are caller-supplied strings (from the running binary's version header) and are not validated before being passed in.
  • The .sort() comparator at line 477 also calls semverCompare directly; a throw there would propagate uncaught from filterAndSortChainTags.

Identified by Warden find-bugs · ARU-GUD

Comment thread src/lib/dsn/scanner.ts
Comment on lines 87 to +89
// Record mtime for cache invalidation
sourceMtimes[filename] = file.lastModified;
const stats = await stat(filepath);
sourceMtimes[filename] = Math.floor(stats.mtimeMs);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TOCTOU: stat() called after readFile() may cache mismatched mtime and DSN content

Calling stat() after readFile() creates a race window: if the file is modified between the two calls, the cache stores the new file's mtime alongside the old file's DSN, causing future cache lookups to treat stale DSN data as fresh.

Evidence
  • readFile(filepath) captures file content at time T1 (line 84).
  • stat(filepath) is called separately at time T2 (line 88), after content is already processed.
  • If the file is modified between T1 and T2, stats.mtimeMs reflects the new write, not the content that was read.
  • sourceMtimes[filename] is then stored with this newer mtime (line 89), paired with the DSN extracted from the old content.
  • On the next cache check, the stored mtime matches the actual file mtime, so the cache returns the stale DSN without re-scanning.

Identified by Warden find-bugs · L54-AGR

@@ -1,4 +1,6 @@
import { spawn } from "node:child_process";
import { addBreadcrumb } from "@sentry/node-core/light";
import { whichSync } from "../../which.js";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whichSync returns path with trailing \r on Windows when where.exe finds multiple matches

When where.exe outputs multiple results (CRLF line endings), stdout.trim().split("\n")[0] strips only the trailing CRLF of the last line — the first line retains its \r, so spawn receives e.g. C:\path\node.exe\r and fails with ENOENT. The ?? command.executable fallback in runSingleCommand never fires because whichSync returns a non-null (but corrupt) path. Fix: use (stdout.trim().split("\n")[0] ?? "").trimEnd() || null in src/lib/which.ts:61.

Evidence
  • whichSync in src/lib/which.ts:40-61 calls execFileSync("where.exe", [command]) which outputs CRLF-separated lines on Windows.
  • stdout.trim() only strips leading/trailing whitespace from the whole string; inner \r\n separators become \r\n with the final \n gone — leaving \r at the end of each non-last line.
  • split("\n")[0] returns the first element, e.g. "C:\\Windows\\System32\\cmd.exe\r" with a trailing \r.
  • runSingleCommand (outside hunk, line ~80) uses whichSync(command.executable) ?? command.executable; since the return value is non-null, the raw-name fallback never activates and the malformed path is forwarded to spawn.
  • spawn with a path containing \r will throw ENOENT, caught by the outer try/catch returning { exitCode: 1 }.
Also found at 1 additional location
  • src/lib/which.ts:63

Identified by Warden find-bugs · LXH-SZS

Comment on lines 93 to 97
let timedOut = false;
const timer = setTimeout(() => {
const timer = globalThis.setTimeout(() => {
timedOut = true;
child.kill();
}, timeoutMs);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Timer not cleared when Promise.all rejects, child.kill() fires on dead process

If Promise.all rejects (e.g. readNodeStream rejects because child.stdout emits an error when the executable isn't found), execution jumps to the catch block and clearTimeout(timer) is never called. The timer fires later, sets timedOut = true, and calls child.kill() on an already-terminated process, which can throw an uncaught ESRCH exception and crash the process. Move clearTimeout(timer) into a finally block to fix this.

Evidence
  • readNodeStream (command-utils.ts:311) attaches stream.on('error', reject), so any error event on child.stdout or child.stderr rejects the promise returned by readSpawnOutput.
  • When the executable is not found (ENOENT), Node.js emits an error event on both the ChildProcess and on child.stdout/child.stderr, triggering this rejection path.
  • clearTimeout(timer) appears only after the Promise.all await (line 104, context-after), not in a finally block, so it is skipped entirely when Promise.all rejects.
  • The timer callback then fires after timeoutMs, calling child.kill() on a process that has already exited; process.kill(pid, ...) throws ESRCH if the PID no longer exists, producing an uncaught exception in the timer callback.

Identified by Warden find-bugs · J5Z-JEX

Comment thread src/lib/shell.ts
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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whichSync calls execFileSync with no timeout, blocking the event loop indefinitely

Both the Unix (/bin/sh -c 'command -v …') and Windows (where.exe) branches in the new whichSync helper omit a timeout option, so a hung shell (e.g. NFS stall, slow filesystem) blocks the entire Node.js process forever.

Evidence
  • which.ts line 40: execFileSync("where.exe", [command], { encoding, stdio, env }) — no timeout.
  • which.ts line 50: execFileSync("/bin/sh", ["-c", …], { encoding, stdio, env }) — no timeout.
  • execFileSync is synchronous and blocks the Node.js event loop for its entire duration; without a timeout the process can hang indefinitely.
  • isBashAvailable in shell.ts (line ~295) calls whichSync("bash") inline, making any caller (e.g. completions setup) susceptible to the same hang.

Identified by Warden find-bugs · 96U-DKF

Comment thread src/lib/shell.ts
Comment on lines +302 to 305
} catch {
// File doesn't exist yet — start with empty content
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inner catch silently discards all readFile errors, risking GITHUB_PATH content loss

The catch swallows every readFile error, not just ENOENT. If the file exists but is temporarily unreadable (e.g., a transient permission issue), content stays "" and the subsequent writeFile overwrites the file with only the new directory — silently losing all previously-appended PATH entries.

Evidence
  • The replaced Bun.file(path).exists() guard returned false only when the file did not exist; any other I/O error would propagate.
  • The new inner catch {} (lines 302-305) has no condition — it catches all errors, including EACCES, EIO, etc.
  • When content is "" and the outer writeFile succeeds, every previous entry in $GITHUB_PATH is permanently discarded.
  • The outer catch only fires if writeFile also fails, so data loss is possible without any returned error signal.

Identified by Warden find-bugs · R6N-BLP

BYK added a commit that referenced this pull request May 20, 2026
…writer) (#986)

## Summary

Fourth step of the Bun → Node.js migration (follows #967, #970, #984).
Replaces the remaining Bun-specific APIs in `src/` — the "Group D" items
that required more careful handling.

### Changes

**`src/lib/bspatch.ts`** (rewritten):
- `Bun.zstdDecompressSync()` → `zstdDecompressSync()` from `node:zlib`
- `DecompressionStream('zstd')` → `createZstdDecompress()` from
`node:zlib` piped through a Node Readable→Web ReadableStream bridge
- `Bun.mmap()` → removed entirely; uses `readFile()` for both paths
(copy-then-read and direct-read fallback)
- `Bun.CryptoHasher("sha256")` → `createHash("sha256")` from
`node:crypto`
- `Bun.file(destPath).writer()` → `createWriteStream(destPath)` from
`node:fs`

**`src/lib/upgrade.ts`**:
- `Bun.file(destPath).writer()` → `createWriteStream(destPath)` from
`node:fs`

**`src/lib/api/sourcemaps.ts`**:
- `Bun.zstdCompress(buf, { level: 3 })` → `zstdCompress()` from
`node:zlib`

**`src/lib/telemetry/zstd-transport.ts`**:
- Removed `globalThis.Bun.zstdCompress` dynamic access and `BunZstdHost`
type
- Replaced with direct `zstdCompress` from `node:zlib` (always available
in Node 22.15+)
- Removed belt-and-braces fallback (zstd can't disappear at runtime with
`node:zlib`)
- `hasZstdSupport()` now checks `typeof zstdCompressCb === "function"`
directly

**Tests updated** (`test/lib/telemetry/zstd-transport.test.ts`):
- Removed 4 tests for `globalThis.Bun.zstdCompress` fallback paths (no
longer applicable)
- Replaced `Bun.zstdDecompress` with `zstdDecompressSync` in assertions

### Result

**Zero non-comment `Bun.*` API calls remain in `src/`.** The only `bun:`
reference left is the `require("bun:sqlite")` fallback in `sqlite.ts`,
which will be removed when the test runner migrates to Vitest.

All 7014 tests pass, 0 failures.
BYK added a commit that referenced this pull request May 20, 2026
Consolidates fixes for review comments across PRs #967, #970, #984, #986.

Changes:
- Bump engines.node from >=22.12 to >=22.15 (zstd APIs require 22.15+,
  Node 20 is EOL). Update dist/bin.cjs runtime version check to match.
- Simplify zstd imports: replace feature-detection dance with direct
  imports from node:zlib now that >=22.15 is guaranteed.
- Fix spawn error handling in upgrade.ts: propagate error object to catch
  block so ENOENT/EBUSY detection works (was silently resolving with 1).
- Fix sqlite.ts transaction(): wrap ROLLBACK in try/catch so original
  error is preserved if ROLLBACK itself fails.
- Guard semver.compare calls in delta-upgrade.ts with semver.valid() to
  avoid throwing on invalid version strings.
- Narrow catch in shell.ts addToGitHubPath to ENOENT only, re-throw
  other errors (EACCES, EPERM) instead of silently swallowing.
- Add WriteStream backpressure handling in upgrade.ts
  streamDecompressToFile: check write() return value, await drain.
- Add setup-node@v6 with Node 22 to E2E CI job.
- Fix whichSync Windows CRLF: split on /\r?\n/ instead of \n.

7014 tests pass, 0 failures.
BYK added a commit that referenced this pull request May 20, 2026
Consolidates fixes for review comments across PRs #967, #970, #984, #986.

Changes:
- Bump engines.node from >=22.12 to >=22.15 (zstd APIs require 22.15+,
  Node 20 is EOL). Update dist/bin.cjs runtime version check to match.
- Simplify zstd imports: replace feature-detection dance with direct
  imports from node:zlib now that >=22.15 is guaranteed.
- Fix spawn error handling in upgrade.ts: propagate error object to catch
  block so ENOENT/EBUSY detection works (was silently resolving with 1).
- Fix sqlite.ts transaction(): wrap ROLLBACK in try/catch so original
  error is preserved if ROLLBACK itself fails.
- Guard semver.compare calls in delta-upgrade.ts with semver.valid() to
  avoid throwing on invalid version strings.
- Narrow catch in shell.ts addToGitHubPath to ENOENT only, re-throw
  other errors (EACCES, EPERM) instead of silently swallowing.
- Add WriteStream backpressure handling in upgrade.ts
  streamDecompressToFile: check write() return value, await drain.
- Add setup-node@v6 with Node 22 to E2E CI job.
- Fix whichSync Windows CRLF: split on /\r?\n/ instead of \n.

7014 tests pass, 0 failures.
BYK added a commit that referenced this pull request May 20, 2026
Consolidates fixes for review comments across PRs #967, #970, #984, #986.

Changes:
- Bump engines.node from >=22.12 to >=22.15 (zstd APIs require 22.15+,
  Node 20 is EOL). Update dist/bin.cjs runtime version check to match.
- Simplify zstd imports: replace feature-detection dance with direct
  imports from node:zlib now that >=22.15 is guaranteed.
- Fix spawn error handling in upgrade.ts: propagate error object to catch
  block so ENOENT/EBUSY detection works (was silently resolving with 1).
- Fix sqlite.ts transaction(): wrap ROLLBACK in try/catch so original
  error is preserved if ROLLBACK itself fails.
- Guard semver.compare calls in delta-upgrade.ts with semver.valid() to
  avoid throwing on invalid version strings.
- Narrow catch in shell.ts addToGitHubPath to ENOENT only, re-throw
  other errors (EACCES, EPERM) instead of silently swallowing.
- Add WriteStream backpressure handling in upgrade.ts
  streamDecompressToFile: check write() return value, await drain.
- Add setup-node@v6 with Node 22 to E2E CI job.
- Fix whichSync Windows CRLF: split on /\r?\n/ instead of \n.

7014 tests pass, 0 failures.
BYK added a commit that referenced this pull request May 20, 2026
## Summary

Consolidates fixes for review comments across PRs #967, #970, #984,
#986, superseding #988.

### Critical
- **Bump `engines.node` to `>=22.15`** — `node:zlib` zstd APIs require
22.15+. Node 20 is EOL. Updated `dist/bin.cjs` runtime version check to
match.
- **Simplify zstd imports** — replaced feature-detection dance with
direct `import { zstdCompress } from "node:zlib"` now that >=22.15 is
guaranteed.

### High
- **Fix spawn error handling in `upgrade.ts`** — `proc.on("error", () =>
resolve(1))` discarded the error object, making ENOENT/EBUSY detection
dead code. Now properly rejects with the error.

### Medium
- **Fix `sqlite.ts` ROLLBACK** — if ROLLBACK throws, the original
transaction error was lost. Now wrapped in try/catch.
- **Guard `semver.compare`** in `delta-upgrade.ts` with `semver.valid()`
to avoid throwing on invalid version strings.
- **Narrow catch in `shell.ts`** `addToGitHubPath` — only catch ENOENT,
re-throw EACCES/EPERM.
- **Add WriteStream backpressure** in `upgrade.ts`
`streamDecompressToFile` — check `write()` return value, await
`'drain'`.
- **Add `setup-node@v6`** with Node 22 to E2E CI job.

### Low
- **Fix `whichSync` Windows CRLF** — split on `/\r?\n/` instead of `\n`
to strip trailing `\r` from `where.exe` output.

### Test results
All 7014 tests pass, 0 failures.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant