fix(installation): type upgrade failures (#28883)
This commit is contained in:
@@ -67,7 +67,11 @@ export function isLocal() {
|
|||||||
|
|
||||||
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
|
||||||
stderr: Schema.String,
|
stderr: Schema.String,
|
||||||
}) {}
|
}) {
|
||||||
|
override get message() {
|
||||||
|
return this.stderr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Response schemas for external version APIs
|
// Response schemas for external version APIs
|
||||||
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
|
||||||
@@ -139,6 +143,12 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
|
|||||||
return "opencode"
|
return "opencode"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const upgradeFailure = (method: Method, result?: { code: number; stdout: string; stderr: string }) => {
|
||||||
|
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 upgradeCurl = Effect.fnUntraced(function* (target: string) {
|
||||||
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
|
const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
|
||||||
const body = yield* response.text
|
const body = yield* response.text
|
||||||
@@ -155,7 +165,7 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
|
|||||||
stdout: result.stdout.toString("utf8"),
|
stdout: result.stdout.toString("utf8"),
|
||||||
stderr: result.stderr.toString("utf8"),
|
stderr: result.stderr.toString("utf8"),
|
||||||
}
|
}
|
||||||
}, Effect.orDie)
|
}, Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })))
|
||||||
|
|
||||||
const result: Interface = {
|
const result: Interface = {
|
||||||
info: Effect.fn("Installation.info")(function* () {
|
info: Effect.fn("Installation.info")(function* () {
|
||||||
@@ -299,11 +309,10 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
|
|||||||
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
|
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
|
return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` })
|
||||||
}
|
}
|
||||||
if (!upgradeResult || upgradeResult.code !== 0) {
|
if (!upgradeResult || upgradeResult.code !== 0) {
|
||||||
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
|
return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) })
|
||||||
return yield* new UpgradeFailedError({ stderr })
|
|
||||||
}
|
}
|
||||||
log.info("upgraded", {
|
log.info("upgraded", {
|
||||||
method: m,
|
method: m,
|
||||||
|
|||||||
@@ -14,19 +14,23 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest)
|
|||||||
return Layer.succeed(HttpClient.HttpClient, client)
|
return Layer.succeed(HttpClient.HttpClient, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
|
function mockSpawner(
|
||||||
|
handler: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string } = () =>
|
||||||
|
"",
|
||||||
|
) {
|
||||||
const spawner = ChildProcessSpawner.make((command) => {
|
const spawner = ChildProcessSpawner.make((command) => {
|
||||||
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
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(
|
return Effect.succeed(
|
||||||
ChildProcessSpawner.makeHandle({
|
ChildProcessSpawner.makeHandle({
|
||||||
pid: ChildProcessSpawner.ProcessId(0),
|
pid: ChildProcessSpawner.ProcessId(0),
|
||||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
|
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(output.code)),
|
||||||
isRunning: Effect.succeed(false),
|
isRunning: Effect.succeed(false),
|
||||||
kill: () => Effect.void,
|
kill: () => Effect.void,
|
||||||
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||||
stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty,
|
stdout: output.stdout ? Stream.make(encoder.encode(output.stdout)) : Stream.empty,
|
||||||
stderr: Stream.empty,
|
stderr: output.stderr ? Stream.make(encoder.encode(output.stderr)) : Stream.empty,
|
||||||
all: Stream.empty,
|
all: Stream.empty,
|
||||||
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||||
getOutputFd: () => Stream.empty,
|
getOutputFd: () => Stream.empty,
|
||||||
@@ -46,7 +50,7 @@ function jsonResponse(body: unknown) {
|
|||||||
|
|
||||||
function testLayer(
|
function testLayer(
|
||||||
httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response,
|
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)))
|
const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler)))
|
||||||
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess))
|
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")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user