Skip to content

Commit 472f381

Browse files
authored
refactor: replace Bun APIs with Node.js equivalents (#984)
## 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.Glob` → `picomatch` (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.
1 parent d6d69e3 commit 472f381

34 files changed

Lines changed: 376 additions & 228 deletions

.lore.md

Lines changed: 90 additions & 0 deletions
Large diffs are not rendered by default.

AGENTS.md

Lines changed: 1 addition & 81 deletions
Large diffs are not rendered by default.

src/commands/api.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Similar to 'gh api' for GitHub.
66
*/
77

8+
import { access, readFile } from "node:fs/promises";
89
// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import
910
import * as Sentry from "@sentry/node-core/light";
1011
import type { SentryContext } from "../context.js";
@@ -828,11 +829,14 @@ export async function buildBodyFromInput(
828829
if (inputPath === "-") {
829830
content = await readStdin(stdin);
830831
} else {
831-
const file = Bun.file(inputPath);
832-
if (!(await file.exists())) {
832+
const exists = await access(inputPath).then(
833+
() => true,
834+
() => false
835+
);
836+
if (!exists) {
833837
throw new ValidationError(`File not found: ${inputPath}`, "input");
834838
}
835-
content = await file.text();
839+
content = await readFile(inputPath, "utf-8");
836840
}
837841

838842
// Try to parse as JSON for the API client

src/commands/cli/upgrade.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
* so that subsequent bare `sentry cli upgrade` calls use the same channel.
1515
*/
1616

17+
import { spawn } from "node:child_process";
1718
import { homedir } from "node:os";
1819
import { dirname } from "node:path";
20+
import { setTimeout } from "node:timers/promises";
1921
import type { SentryContext } from "../../context.js";
2022
import {
2123
determineInstallDir,
@@ -425,12 +427,14 @@ async function spawnWithRetry(
425427
): Promise<number> {
426428
for (let attempt = 1; attempt <= SPAWN_MAX_ATTEMPTS; attempt++) {
427429
try {
428-
const proc = Bun.spawn([binaryPath, ...args], {
429-
stdout: "inherit",
430-
stderr: "inherit",
430+
const proc = spawn(binaryPath, args, {
431+
stdio: ["ignore", "inherit", "inherit"],
431432
env,
432433
});
433-
return await proc.exited;
434+
return await new Promise<number>((resolve) => {
435+
proc.on("close", (code) => resolve(code ?? 1));
436+
proc.on("error", () => resolve(1));
437+
});
434438
} catch (error) {
435439
// Translate the opaque Bun "Executable not found" error into an
436440
// actionable UpgradeError. This path triggers when the binary at
@@ -454,7 +458,7 @@ async function spawnWithRetry(
454458
log.warn(
455459
`Binary is locked (antivirus scan?), retrying in ${delay}ms... (attempt ${attempt}/${SPAWN_MAX_ATTEMPTS})`
456460
);
457-
await Bun.sleep(delay);
461+
await setTimeout(delay);
458462
}
459463
}
460464
// Unreachable — the loop either returns or throws

src/commands/dashboard/list.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* and optional client-side glob filtering by title.
66
*/
77

8+
import picomatch from "picomatch";
89
import type { SentryContext } from "../../context.js";
910
import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js";
1011
import {
@@ -233,7 +234,7 @@ function processPage(
233234
limit: number;
234235
serverCursor: string | undefined;
235236
afterId: string | undefined;
236-
glob: InstanceType<typeof Bun.Glob> | undefined;
237+
glob: ((input: string) => boolean) | undefined;
237238
}
238239
): PageResult {
239240
// When resuming mid-page, find the afterId and skip everything up to and
@@ -249,7 +250,7 @@ function processPage(
249250

250251
for (let i = startIdx; i < data.length; i++) {
251252
const item = data[i] as DashboardListItem;
252-
if (!opts.glob || opts.glob.match(item.title.toLowerCase())) {
253+
if (!opts.glob || opts.glob(item.title.toLowerCase())) {
253254
results.push(item);
254255
if (results.length >= opts.limit) {
255256
return {
@@ -283,7 +284,7 @@ async function fetchDashboards(
283284
perPage: number;
284285
serverCursor: string | undefined;
285286
afterId: string | undefined;
286-
glob: InstanceType<typeof Bun.Glob> | undefined;
287+
glob: ((input: string) => boolean) | undefined;
287288
}
288289
): Promise<FetchResult> {
289290
let { serverCursor, afterId } = opts;
@@ -434,7 +435,7 @@ export const listCommand = buildListCommand("dashboard", {
434435
const { serverCursor, afterId } = decodeCursor(rawCursor ?? "");
435436

436437
const glob = titleFilter
437-
? new Bun.Glob(titleFilter.toLowerCase())
438+
? picomatch(titleFilter.toLowerCase(), { dot: true })
438439
: undefined;
439440

440441
// When filtering, fetch max-size pages to minimize round trips.

src/lib/agent-skills.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
13+
import { writeFile } from "node:fs/promises";
1314
import { dirname, join } from "node:path";
1415
import { captureException } from "@sentry/node-core/light";
1516
import { SKILL_FILES } from "../generated/skill-content.js";
@@ -68,7 +69,7 @@ async function writeSkillFiles(
6869
if (!existsSync(dir)) {
6970
mkdirSync(dir, { recursive: true, mode: 0o755 });
7071
}
71-
await Bun.write(fullPath, content);
72+
await writeFile(fullPath, content, "utf-8");
7273
if (relativePath.startsWith("references/")) {
7374
referenceCount += 1;
7475
}

src/lib/binary.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55
* Used by both `setup --install` (fresh installs) and `upgrade` (self-updates).
66
*/
77

8+
import { spawnSync } from "node:child_process";
89
import {
910
existsSync,
1011
readFileSync,
1112
renameSync,
1213
unlinkSync,
1314
writeFileSync,
1415
} from "node:fs";
15-
import { chmod, mkdir, unlink } from "node:fs/promises";
16+
import { chmod, copyFile, mkdir, unlink } from "node:fs/promises";
1617
import { delimiter, join, resolve } from "node:path";
18+
import { compare as semverCompare } from "semver";
1719
import { getUserAgent } from "./constants.js";
1820
import {
1921
buildTlsErrorDetail,
@@ -56,9 +58,8 @@ export function isMusl(): boolean {
5658

5759
// Heuristic 2: ldd --version output (musl ldd writes "musl libc" to stderr)
5860
try {
59-
const result = Bun.spawnSync(["ldd", "--version"], {
60-
stdout: "pipe",
61-
stderr: "pipe",
61+
const result = spawnSync("ldd", ["--version"], {
62+
stdio: ["ignore", "pipe", "pipe"],
6263
});
6364
const output =
6465
Buffer.from(result.stdout).toString() +
@@ -130,7 +131,7 @@ export function isNightlyVersion(version: string): boolean {
130131
* @returns 1 if a > b, -1 if a < b, 0 if equal
131132
*/
132133
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
133-
return Bun.semver.order(a, b);
134+
return semverCompare(a, b);
134135
}
135136

136137
/**
@@ -451,7 +452,7 @@ export async function installBinary(
451452
}
452453

453454
// Copy source binary to temp path next to install location
454-
await Bun.write(tempPath, Bun.file(sourcePath));
455+
await copyFile(sourcePath, tempPath);
455456

456457
// Set executable permission (Unix only)
457458
if (process.platform !== "win32") {

src/lib/browser.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
* Browser utilities
33
*
44
* Cross-platform utilities for interacting with the user's browser.
5-
* Uses Bun.spawn and Bun.which for process management.
5+
* Uses child_process.spawn and whichSync for process management.
66
*/
77

8+
import { spawn } from "node:child_process";
9+
import { setTimeout } from "node:timers/promises";
810
import { generateQRCode } from "./qrcode.js";
11+
import { whichSync } from "./which.js";
12+
13+
/** No-op error handler to prevent unhandled error crashes from spawn. */
14+
function noop(): void {
15+
// Intentionally empty — absorbs async spawn errors (e.g., ENOENT)
16+
}
917

1018
/**
1119
* Open a URL in the user's default browser.
@@ -20,10 +28,10 @@ export async function openBrowser(url: string): Promise<boolean> {
2028
let args: string[];
2129

2230
if (platform === "darwin") {
23-
command = Bun.which("open");
31+
command = whichSync("open");
2432
args = [url];
2533
} else if (platform === "win32") {
26-
command = Bun.which("cmd");
34+
command = whichSync("cmd");
2735
args = ["/c", "start", "", url];
2836
} else {
2937
// Linux and other Unix-like systems - try multiple openers
@@ -35,7 +43,7 @@ export async function openBrowser(url: string): Promise<boolean> {
3543
"kde-open",
3644
];
3745
for (const opener of linuxOpeners) {
38-
command = Bun.which(opener);
46+
command = whichSync(opener);
3947
if (command) {
4048
break;
4149
}
@@ -48,13 +56,15 @@ export async function openBrowser(url: string): Promise<boolean> {
4856
}
4957

5058
try {
51-
const proc = Bun.spawn([command, ...args], {
52-
stdout: "ignore",
53-
stderr: "ignore",
59+
const proc = spawn(command, args, {
60+
stdio: ["ignore", "ignore", "ignore"],
5461
});
62+
// Prevent unhandled error crash if the binary disappears between
63+
// whichSync() and spawn() (TOCTOU window).
64+
proc.on("error", noop);
5565

5666
// Give browser time to open, then detach
57-
await Bun.sleep(500);
67+
await setTimeout(500);
5868
proc.unref();
5969
return true;
6070
} catch {

src/lib/bspatch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
*/
3232

3333
import { constants, copyFileSync } from "node:fs";
34-
import { unlink } from "node:fs/promises";
34+
import { readFile, unlink } from "node:fs/promises";
3535
import { tmpdir } from "node:os";
3636
import { join } from "node:path";
3737

@@ -269,7 +269,7 @@ async function loadOldBinary(oldPath: string): Promise<OldFileHandle> {
269269
/* May not exist if copyFileSync failed */
270270
});
271271
return {
272-
data: new Uint8Array(await Bun.file(oldPath).arrayBuffer()),
272+
data: new Uint8Array(await readFile(oldPath)),
273273
cleanup: () => {
274274
// Data is in JS heap — no temp file to clean up
275275
},

src/lib/clipboard.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
* Includes both low-level copy function and interactive keyboard-triggered copy.
66
*/
77

8+
import { spawn } from "node:child_process";
89
import { logger } from "./logger.js";
10+
import { whichSync } from "./which.js";
911

1012
const log = logger.withTag("clipboard");
1113

@@ -29,18 +31,18 @@ export async function copyToClipboard(text: string): Promise<boolean> {
2931
let args: string[] = [];
3032

3133
if (platform === "darwin") {
32-
command = Bun.which("pbcopy");
34+
command = whichSync("pbcopy");
3335
args = [];
3436
} else if (platform === "win32") {
35-
command = Bun.which("clip");
37+
command = whichSync("clip");
3638
args = [];
3739
} else {
3840
// Linux - try xclip first, then xsel
39-
command = Bun.which("xclip");
41+
command = whichSync("xclip");
4042
if (command) {
4143
args = ["-selection", "clipboard"];
4244
} else {
43-
command = Bun.which("xsel");
45+
command = whichSync("xsel");
4446
if (command) {
4547
args = ["--clipboard", "--input"];
4648
}
@@ -52,16 +54,20 @@ export async function copyToClipboard(text: string): Promise<boolean> {
5254
}
5355

5456
try {
55-
const proc = Bun.spawn([command, ...args], {
56-
stdin: "pipe",
57-
stdout: "ignore",
58-
stderr: "ignore",
57+
const proc = spawn(command, args, {
58+
stdio: ["pipe", "ignore", "ignore"],
5959
});
6060

61-
proc.stdin.write(text);
62-
proc.stdin.end();
61+
const { stdin } = proc;
62+
if (stdin) {
63+
stdin.write(text);
64+
stdin.end();
65+
}
6366

64-
const exitCode = await proc.exited;
67+
const exitCode = await new Promise<number>((resolve) => {
68+
proc.on("close", (code) => resolve(code ?? 1));
69+
proc.on("error", () => resolve(1));
70+
});
6571
return exitCode === 0;
6672
} catch {
6773
return false;

0 commit comments

Comments
 (0)