2f3291d280
Root cause: runBunInstallWithDetails() invoked spawnWithWindowsHide() without passing env, so the child bun install lost https_proxy / http_proxy / NO_PROXY and other parent env vars. @npmcli/agent then received an empty proxy URL and rejected fetch with 'fetch() proxy.url must be a non-empty string', breaking plugin auto-install on networks that require an outbound proxy. Fix: pass env: process.env to spawnWithWindowsHide so the child bun install inherits the full parent environment, including proxy variables. Verification: added a regression test that sets https_proxy/http_proxy on process.env and asserts those values are forwarded to the spawn options. Test fails before the fix and passes after. typecheck clean. Other pre-existing path-separator test failures on Windows are unrelated to this change.
151 lines
4.2 KiB
TypeScript
151 lines
4.2 KiB
TypeScript
import { existsSync } from "node:fs"
|
|
import { join } from "node:path"
|
|
|
|
import { getOpenCodeCacheDir } from "../../shared/data-path"
|
|
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
|
|
/** Workspace directory to install to. Defaults to cache dir if not provided. */
|
|
workspaceDir?: string
|
|
}
|
|
|
|
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
|
|
error?: string
|
|
}
|
|
|
|
export async function runBunInstall(): Promise<boolean> {
|
|
const result = await runBunInstallWithDetails()
|
|
return result.success
|
|
}
|
|
|
|
function getDefaultWorkspaceDir(): string {
|
|
return join(getOpenCodeCacheDir(), "packages")
|
|
}
|
|
|
|
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 ?? "pipe"
|
|
const cacheDir = options?.workspaceDir ?? getDefaultWorkspaceDir()
|
|
const packageJsonPath = `${cacheDir}/package.json`
|
|
|
|
if (!existsSync(packageJsonPath)) {
|
|
return {
|
|
success: false,
|
|
error: `Workspace not initialized: ${packageJsonPath} not found. OpenCode should create this on first run.`,
|
|
}
|
|
}
|
|
|
|
try {
|
|
const proc = spawnWithWindowsHide(["bun", "install"], {
|
|
cwd: cacheDir,
|
|
env: process.env,
|
|
stdout: outputMode,
|
|
stderr: outputMode,
|
|
})
|
|
|
|
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])
|
|
if (timeoutId) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
|
|
if (result === "timeout") {
|
|
try {
|
|
proc.kill()
|
|
} catch (err) {
|
|
log("[cli/install] Failed to kill timed out bun install process:", err)
|
|
}
|
|
|
|
if (outputMode === "pipe") {
|
|
void outputPromise
|
|
.then((output) => {
|
|
logCapturedOutputOnFailure(outputMode, output)
|
|
})
|
|
.catch((err) => {
|
|
log("[bun-install] Failed to read captured output after timeout:", err)
|
|
})
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
timedOut: true,
|
|
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd "${cacheDir}" && 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}`,
|
|
}
|
|
}
|
|
|
|
return { success: true }
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
return {
|
|
success: false,
|
|
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
|
|
}
|
|
}
|
|
}
|