fix(config-manager): support silent bun install execution

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-03-08 02:15:56 +09:00
parent 4ded281ee0
commit 3ccf378b2d
2 changed files with 126 additions and 12 deletions

View File

@@ -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<typeof spawnHelpers.spawnWithWindowsHide> {
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<typeof spawnHelpers.spawnWithWindowsHide>
}
describe("runBunInstallWithDetails", () => {
let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
@@ -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<typeof spawnHelpers.spawnWithWindowsHide>)
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",
})
})
})

View File

@@ -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<typeof spawnWithWindowsHide>["stdout"]
declare const Bun: {
readableStreamToText(stream: NonNullable<ProcessOutputStream>): Promise<string>
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
@@ -17,7 +38,33 @@ export async function runBunInstall(): Promise<boolean> {
return result.success
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
function readProcessOutput(stream: ProcessOutputStream): Promise<string> {
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<BunInstallResult> {
const outputMode = options?.outputMode ?? "inherit"
const cacheDir = getOpenCodeCacheDir()
const packageJsonPath = `${cacheDir}/package.json`
@@ -31,17 +78,23 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: cacheDir,
stdout: "inherit",
stderr: "inherit",
stdout: outputMode,
stderr: outputMode,
})
let timeoutId: ReturnType<typeof setTimeout>
const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then(
([stdout, stderr]) => ({ stdout, stderr })
)
let timeoutId: ReturnType<typeof setTimeout> | 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<BunInstallResult> {
} 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<BunInstallResult> {
}
}
const output = await outputPromise
if (proc.exitCode !== 0) {
logCapturedOutputOnFailure(outputMode, output)
return {
success: false,
error: `bun install failed with exit code ${proc.exitCode}`,