Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/cli/config-manager/bun-install.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 7, 2026

Choose a reason for hiding this comment

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

P2: Unconditionally deleting process.env.OPENCODE_CONFIG_DIR can corrupt the test environment if it was previously set by a developer or another test suite.

Without restoring the original environment variable, this test pollutes the environment and can cause flaky, hard-to-debug failures in other concurrent tests that rely on OPENCODE_CONFIG_DIR (as seen safely handled in install.test.ts).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/config-manager/bun-install.test.ts, line 22:

<comment>Unconditionally deleting `process.env.OPENCODE_CONFIG_DIR` can corrupt the test environment if it was previously set by a developer or another test suite.

Without restoring the original environment variable, this test pollutes the environment and can cause flaky, hard-to-debug failures in other concurrent tests that rely on `OPENCODE_CONFIG_DIR` (as seen safely handled in `install.test.ts`).</comment>

<file context>
@@ -0,0 +1,96 @@
+}
+
+describe("runBunInstallWithDetails", () => {
+  beforeEach(() => {
+    process.env.OPENCODE_CONFIG_DIR = "/test/opencode"
+    resetConfigContext()
</file context>
Fix with Cubic

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()
}
})
})
72 changes: 67 additions & 5 deletions src/cli/config-manager/bun-install.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import { getConfigDir } from "./config-context"
import { log } from "../../shared/logger"
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
Expand All @@ -15,36 +36,77 @@ 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"

try {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",
stdout: outputMode,
stderr: outputMode,
})

let timeoutId: ReturnType<typeof setTimeout>
const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then(
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 7, 2026

Choose a reason for hiding this comment

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

P1: Add a .catch() to outputPromise to prevent unhandled promise rejections if stream reading fails asynchronously.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/config-manager/bun-install.ts, line 74:

<comment>Add a `.catch()` to `outputPromise` to prevent unhandled promise rejections if stream reading fails asynchronously.</comment>

<file context>
@@ -15,36 +36,77 @@ export async function runBunInstall(): Promise<boolean> {
     })
 
-    let timeoutId: ReturnType<typeof setTimeout>
+    const outputPromise = Promise.all([readProcessOutput(proc.stdout), readProcessOutput(proc.stderr)]).then(
+      ([stdout, stderr]) => ({ stdout, stderr })
+    )
</file context>
Fix with Cubic

([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 {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
}

await proc.exited
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 7, 2026

Choose a reason for hiding this comment

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

P1: Awaiting process exit and output on timeout can cause indefinite hangs on Windows due to orphaned child processes holding pipes open.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/config-manager/bun-install.ts, line 95:

<comment>Awaiting process exit and output on timeout can cause indefinite hangs on Windows due to orphaned child processes holding pipes open.</comment>

<file context>
@@ -15,36 +36,77 @@ export async function runBunInstall(): Promise<boolean> {
         /* intentionally empty - process may have already exited */
       }
+
+      await proc.exited
+      logCapturedOutputOnFailure(outputMode, await outputPromise)
+
</file context>
Fix with Cubic

logCapturedOutputOnFailure(outputMode, await outputPromise)

return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`,
}
}

const output = await outputPromise

if (proc.exitCode !== 0) {
logCapturedOutputOnFailure(outputMode, output)

return {
success: false,
error: `bun install failed with exit code ${proc.exitCode}`,
Expand Down
28 changes: 15 additions & 13 deletions src/hooks/auto-update-checker/hook/background-update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const mockGetCachedVersion = mock((): string | null => "3.4.0")
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
const mockExtractChannel = mock(() => "latest")
const mockInvalidatePackage = mock(() => {})
const mockRunBunInstall = mock(async () => true)
const mockRunBunInstallWithDetails = mock(async () => ({ success: true }))
const mockShowUpdateAvailableToast = mock(
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
)
Expand All @@ -41,7 +41,7 @@ mock.module("../checker", () => ({
}))
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
mock.module("../../../cli/config-manager", () => ({ runBunInstall: mockRunBunInstall }))
mock.module("../../../cli/config-manager", () => ({ runBunInstallWithDetails: mockRunBunInstallWithDetails }))
mock.module("./update-toasts", () => ({
showUpdateAvailableToast: mockShowUpdateAvailableToast,
showAutoUpdatedToast: mockShowAutoUpdatedToast,
Expand All @@ -62,15 +62,15 @@ describe("runBackgroundUpdateCheck", () => {
mockGetLatestVersion.mockReset()
mockExtractChannel.mockReset()
mockInvalidatePackage.mockReset()
mockRunBunInstall.mockReset()
mockRunBunInstallWithDetails.mockReset()
mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset()

mockFindPluginEntry.mockReturnValue(createPluginEntry())
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
mockExtractChannel.mockReturnValue("latest")
mockRunBunInstall.mockResolvedValue(true)
mockRunBunInstallWithDetails.mockResolvedValue({ success: true })
})

describe("#given no plugin entry found", () => {
Expand All @@ -83,7 +83,7 @@ describe("runBackgroundUpdateCheck", () => {
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled()
})
})

Expand All @@ -110,7 +110,7 @@ describe("runBackgroundUpdateCheck", () => {
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledWith("latest")
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
Expand All @@ -125,7 +125,7 @@ describe("runBackgroundUpdateCheck", () => {
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
Expand All @@ -139,7 +139,7 @@ describe("runBackgroundUpdateCheck", () => {
await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
Expand All @@ -152,7 +152,7 @@ describe("runBackgroundUpdateCheck", () => {
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockRunBunInstallWithDetails).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})

Expand Down Expand Up @@ -182,12 +182,13 @@ describe("runBackgroundUpdateCheck", () => {
describe("#given unpinned with auto-update and install succeeds", () => {
it("invalidates cache, installs, and shows auto-updated toast", async () => {
//#given
mockRunBunInstall.mockResolvedValue(true)
mockRunBunInstallWithDetails.mockResolvedValue({ success: true })
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockRunBunInstallWithDetails).toHaveBeenCalledTimes(1)
expect(mockRunBunInstallWithDetails).toHaveBeenCalledWith({ outputMode: "pipe" })
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
Expand All @@ -196,11 +197,12 @@ describe("runBackgroundUpdateCheck", () => {
describe("#given unpinned with auto-update and install fails", () => {
it("falls back to notification-only toast", async () => {
//#given
mockRunBunInstall.mockResolvedValue(false)
mockRunBunInstallWithDetails.mockResolvedValue({ success: false, error: "install failed" })
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockRunBunInstallWithDetails).toHaveBeenCalledTimes(1)
expect(mockRunBunInstallWithDetails).toHaveBeenCalledWith({ outputMode: "pipe" })
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
Expand Down
9 changes: 7 additions & 2 deletions src/hooks/auto-update-checker/hook/background-update-check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { runBunInstall } from "../../../cli/config-manager"
import { runBunInstallWithDetails } from "../../../cli/config-manager"
import { log } from "../../../shared/logger"
import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants"
Expand All @@ -13,7 +13,12 @@ function getPinnedVersionToastMessage(latestVersion: string): string {

async function runBunInstallSafe(): Promise<boolean> {
try {
return await runBunInstall()
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
if (!result.success && result.error) {
log("[auto-update-checker] bun install failed:", result.error)
}

return result.success
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
log("[auto-update-checker] bun install error:", errorMessage)
Expand Down