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:
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
|
||||||
|
|
||||||
|
import * as loggerModule from "../../shared/logger"
|
||||||
|
import * as spawnWithWindowsHideModule from "../../shared/spawn-with-windows-hide"
|
||||||
|
import { resetConfigContext } from "./config-context"
|
||||||
|
import { runBunInstallWithDetails } from "./bun-install"
|
||||||
|
|
||||||
|
function createProc(
|
||||||
|
exitCode: number,
|
||||||
|
output?: { stdout?: string; stderr?: string }
|
||||||
|
): ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide> {
|
||||||
|
return {
|
||||||
|
exited: Promise.resolve(exitCode),
|
||||||
|
exitCode,
|
||||||
|
stdout: output?.stdout ? new Blob([output.stdout]).stream() : undefined,
|
||||||
|
stderr: output?.stderr ? new Blob([output.stderr]).stream() : undefined,
|
||||||
|
kill: () => {},
|
||||||
|
} satisfies ReturnType<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runBunInstallWithDetails", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.OPENCODE_CONFIG_DIR = "/test/opencode"
|
||||||
|
resetConfigContext()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetConfigContext()
|
||||||
|
delete process.env.OPENCODE_CONFIG_DIR
|
||||||
|
})
|
||||||
|
|
||||||
|
it("inherits install output by default", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const result = await runBunInstallWithDetails()
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
|
expect(options.stdout).toBe("inherit")
|
||||||
|
expect(options.stderr).toBe("inherit")
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("pipes install output when requested", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(createProc(0))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toEqual({ success: true })
|
||||||
|
const [_, options] = spawnSpy.mock.calls[0] as Parameters<typeof spawnWithWindowsHideModule.spawnWithWindowsHide>
|
||||||
|
expect(options.stdout).toBe("pipe")
|
||||||
|
expect(options.stderr).toBe("pipe")
|
||||||
|
} finally {
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("logs captured output when piped install fails", async () => {
|
||||||
|
// given
|
||||||
|
const spawnSpy = spyOn(spawnWithWindowsHideModule, "spawnWithWindowsHide").mockReturnValue(
|
||||||
|
createProc(1, {
|
||||||
|
stdout: "resolved 10 packages",
|
||||||
|
stderr: "network error",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const logSpy = spyOn(loggerModule, "log").mockImplementation(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 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",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore()
|
||||||
|
spawnSpy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,30 @@
|
|||||||
import { getConfigDir } from "./config-context"
|
import { getConfigDir } from "./config-context"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
|
||||||
|
|
||||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
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 {
|
export interface BunInstallResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
timedOut?: boolean
|
timedOut?: boolean
|
||||||
@@ -15,21 +36,54 @@ export async function runBunInstall(): Promise<boolean> {
|
|||||||
return result.success
|
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"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = spawnWithWindowsHide(["bun", "install"], {
|
const proc = spawnWithWindowsHide(["bun", "install"], {
|
||||||
cwd: getConfigDir(),
|
cwd: getConfigDir(),
|
||||||
stdout: "inherit",
|
stdout: outputMode,
|
||||||
stderr: "inherit",
|
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) => {
|
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
||||||
timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
|
timeoutId = setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
|
||||||
})
|
})
|
||||||
const exitPromise = proc.exited.then(() => "completed" as const)
|
const exitPromise = proc.exited.then(() => "completed" as const)
|
||||||
const result = await Promise.race([exitPromise, timeoutPromise])
|
const result = await Promise.race([exitPromise, timeoutPromise])
|
||||||
clearTimeout(timeoutId!)
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
if (result === "timeout") {
|
if (result === "timeout") {
|
||||||
try {
|
try {
|
||||||
@@ -37,6 +91,10 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
|||||||
} catch {
|
} catch {
|
||||||
/* intentionally empty - process may have already exited */
|
/* intentionally empty - process may have already exited */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await proc.exited
|
||||||
|
logCapturedOutputOnFailure(outputMode, await outputPromise)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
timedOut: true,
|
timedOut: true,
|
||||||
@@ -44,7 +102,11 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const output = await outputPromise
|
||||||
|
|
||||||
if (proc.exitCode !== 0) {
|
if (proc.exitCode !== 0) {
|
||||||
|
logCapturedOutputOnFailure(outputMode, output)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `bun install failed with exit code ${proc.exitCode}`,
|
error: `bun install failed with exit code ${proc.exitCode}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user