From 3ccf378b2d9d67b1814fe666732499d7b5c4ebdf Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 8 Mar 2026 02:15:56 +0900 Subject: [PATCH] fix(config-manager): support silent bun install execution Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/cli/config-manager/bun-install.test.ts | 67 +++++++++++++++++--- src/cli/config-manager/bun-install.ts | 71 ++++++++++++++++++++-- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/cli/config-manager/bun-install.test.ts b/src/cli/config-manager/bun-install.test.ts index 97cd4585a..389df04c6 100644 --- a/src/cli/config-manager/bun-install.test.ts +++ b/src/cli/config-manager/bun-install.test.ts @@ -1,10 +1,25 @@ -import { beforeEach, afterEach, describe, expect, it, spyOn } from "bun:test" import * as fs from "node:fs" + +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test" + import * as dataPath from "../../shared/data-path" import * as logger from "../../shared/logger" import * as spawnHelpers from "../../shared/spawn-with-windows-hide" import { runBunInstallWithDetails } from "./bun-install" +function createProc( + exitCode: number, + output?: { stdout?: string; stderr?: string } +): ReturnType { + return { + exited: Promise.resolve(exitCode), + exitCode, + stdout: output?.stdout !== undefined ? new Blob([output.stdout]).stream() : undefined, + stderr: output?.stderr !== undefined ? new Blob([output.stderr]).stream() : undefined, + kill: () => {}, + } satisfies ReturnType +} + describe("runBunInstallWithDetails", () => { let getOpenCodeCacheDirSpy: ReturnType let logSpy: ReturnType @@ -14,11 +29,7 @@ describe("runBunInstallWithDetails", () => { beforeEach(() => { getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache") logSpy = spyOn(logger, "log").mockImplementation(() => {}) - spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({ - exited: Promise.resolve(0), - exitCode: 0, - kill: () => {}, - } as ReturnType) + spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue(createProc(0)) existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true) }) @@ -29,9 +40,13 @@ describe("runBunInstallWithDetails", () => { existsSyncSpy.mockRestore() }) - it("runs bun install in the OpenCode cache directory", async () => { + it("runs bun install in the OpenCode cache directory with inherited output by default", async () => { + // given + + // when const result = await runBunInstallWithDetails() + // then expect(result).toEqual({ success: true }) expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1) expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { @@ -40,4 +55,42 @@ describe("runBunInstallWithDetails", () => { stderr: "inherit", }) }) + + it("pipes install output when requested", async () => { + // given + + // when + const result = await runBunInstallWithDetails({ outputMode: "pipe" }) + + // then + expect(result).toEqual({ success: true }) + expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], { + cwd: "/tmp/opencode-cache", + stdout: "pipe", + stderr: "pipe", + }) + }) + + it("logs captured output when piped install fails", async () => { + // given + spawnWithWindowsHideSpy.mockReturnValue( + createProc(1, { + stdout: "resolved 10 packages", + stderr: "network error", + }) + ) + + // when + const result = await runBunInstallWithDetails({ outputMode: "pipe" }) + + // then + expect(result).toEqual({ + success: false, + error: "bun install failed with exit code 1", + }) + expect(logSpy).toHaveBeenCalledWith("[bun-install] Captured output from failed bun install", { + stdout: "resolved 10 packages", + stderr: "network error", + }) + }) }) diff --git a/src/cli/config-manager/bun-install.ts b/src/cli/config-manager/bun-install.ts index 230b03eae..f6ba9e99d 100644 --- a/src/cli/config-manager/bun-install.ts +++ b/src/cli/config-manager/bun-install.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs" + import { getOpenCodeCacheDir } from "../../shared/data-path" import { log } from "../../shared/logger" import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" @@ -6,6 +7,26 @@ import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide" const BUN_INSTALL_TIMEOUT_SECONDS = 60 const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000 +type BunInstallOutputMode = "inherit" | "pipe" + +interface RunBunInstallOptions { + outputMode?: BunInstallOutputMode +} + +interface BunInstallOutput { + stdout: string + stderr: string +} + +declare function setTimeout(callback: () => void, delay?: number): number +declare function clearTimeout(timeout: number): void + +type ProcessOutputStream = ReturnType["stdout"] + +declare const Bun: { + readableStreamToText(stream: NonNullable): Promise +} + export interface BunInstallResult { success: boolean timedOut?: boolean @@ -17,7 +38,33 @@ export async function runBunInstall(): Promise { return result.success } -export async function runBunInstallWithDetails(): Promise { +function readProcessOutput(stream: ProcessOutputStream): Promise { + if (!stream) { + return Promise.resolve("") + } + + return Bun.readableStreamToText(stream) +} + +function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: BunInstallOutput): void { + if (outputMode !== "pipe") { + return + } + + const stdout = output.stdout.trim() + const stderr = output.stderr.trim() + if (!stdout && !stderr) { + return + } + + log("[bun-install] Captured output from failed bun install", { + stdout, + stderr, + }) +} + +export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise { + const outputMode = options?.outputMode ?? "inherit" const cacheDir = getOpenCodeCacheDir() const packageJsonPath = `${cacheDir}/package.json` @@ -31,17 +78,23 @@ export async function runBunInstallWithDetails(): Promise { try { const proc = spawnWithWindowsHide(["bun", "install"], { cwd: cacheDir, - stdout: "inherit", - stderr: "inherit", + stdout: outputMode, + stderr: outputMode, }) - let timeoutId: ReturnType + const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then( + ([stdout, stderr]) => ({ stdout, stderr }) + ) + + let timeoutId: ReturnType | undefined const timeoutPromise = new Promise<"timeout">((resolve) => { timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS) }) const exitPromise = proc.exited.then(() => "completed" as const) const result = await Promise.race([exitPromise, timeoutPromise]) - clearTimeout(timeoutId!) + if (timeoutId) { + clearTimeout(timeoutId) + } if (result === "timeout") { try { @@ -49,6 +102,10 @@ export async function runBunInstallWithDetails(): Promise { } catch (err) { log("[cli/install] Failed to kill timed out bun install process:", err) } + + await proc.exited + logCapturedOutputOnFailure(outputMode, await outputPromise) + return { success: false, timedOut: true, @@ -56,7 +113,11 @@ export async function runBunInstallWithDetails(): Promise { } } + const output = await outputPromise + if (proc.exitCode !== 0) { + logCapturedOutputOnFailure(outputMode, output) + return { success: false, error: `bun install failed with exit code ${proc.exitCode}`,