diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 079bc30ffa..6240cee886 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -67,7 +67,11 @@ export function isLocal() { export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { stderr: Schema.String, -}) {} +}) { + override get message() { + return this.stderr + } +} // Response schemas for external version APIs const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) @@ -139,6 +143,12 @@ export const layer: Layer.Layer { + if (method === "choco") return "not running from an elevated command shell" + if (result) return `Upgrade failed for ${method} (exit code ${result.code}).` + return `Upgrade failed for ${method}.` + } + const upgradeCurl = Effect.fnUntraced(function* (target: string) { const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) const body = yield* response.text @@ -155,7 +165,7 @@ export const layer: Layer.Layer new UpgradeFailedError({ stderr: upgradeFailure("curl") }))) const result: Interface = { info: Effect.fn("Installation.info")(function* () { @@ -299,11 +309,10 @@ export const layer: Layer.Layer string = () => "") { +function mockSpawner( + handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () => + "", +) { const spawner = ChildProcessSpawner.make((command) => { const std = ChildProcess.isStandardCommand(command) ? command : undefined - const output = handler(std?.command ?? "", std?.args ?? []) + const result = handler(std?.command ?? "", std?.args ?? []) + const output = typeof result === "string" ? { code: 0, stdout: result, stderr: "" } : result return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(0), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)), isRunning: Effect.succeed(false), kill: () => Effect.void, stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, - stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, - stderr: Stream.empty, + stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty, + stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty, all: Stream.empty, getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, getOutputFd: () => Stream.empty, @@ -46,7 +50,7 @@ function jsonResponse(body: unknown) { function testLayer( httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, - spawnHandler?: (cmd: string, args: readonly string[]) => string, + spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, ) { const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler))) return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess)) @@ -166,4 +170,44 @@ describe("installation", () => { }), ) }) + + describe("upgrade", () => { + testEffect( + testLayer( + () => jsonResponse({}), + (cmd) => { + if (cmd === "npm") return { code: 1, stderr: "token=secret command output" } + return "" + }, + ), + ).effect("returns sanitized typed errors for failed package upgrades", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("npm", "9.9.9")) + expect(error).toBeInstanceOf(Installation.UpgradeFailedError) + expect(error.stderr).toBe("Upgrade failed for npm (exit code 1).") + expect(error.message).toBe(error.stderr) + expect(error.stderr).not.toContain("secret") + expect(error.stderr).not.toContain("command output") + }), + ) + + testEffect( + testLayer( + () => new Response("install script with token=secret", { status: 200 }), + (cmd) => { + if (cmd === "bash") return { code: 1, stderr: "script output with token=secret" } + return "" + }, + ), + ).effect("returns sanitized typed errors when the curl install script fails", () => + Effect.gen(function* () { + const error = yield* Effect.flip(Installation.use.upgrade("curl", "9.9.9")) + expect(error).toBeInstanceOf(Installation.UpgradeFailedError) + expect(error.stderr).toBe("Upgrade failed for curl (exit code 1).") + expect(error.message).toBe(error.stderr) + expect(error.stderr).not.toContain("secret") + expect(error.stderr).not.toContain("script output") + }), + ) + }) })