Skip to content
Closed
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
47 changes: 37 additions & 10 deletions packages/opencode/src/installation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import { homedir } from "os"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag"
Expand Down Expand Up @@ -132,6 +133,32 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)

const packageVersion = Effect.fnUntraced(function* (cmd: string[]) {
const result = yield* run([...cmd, "view", `opencode-ai@${InstallationChannel}`, "version", "--json"], {
// Avoid repo-local packageManager settings influencing npm-like metadata lookups.
cwd: homedir(),
})
const name = cmd.join(" ")
if (result.code !== 0) {
return yield* Effect.fail(new Error(result.stderr || `Failed to query opencode version via ${name}`))
}

const raw = result.stdout.trim()
if (!raw) {
return yield* Effect.fail(new Error(`Empty version response from ${name}`))
}

return yield* Effect.try({
try: () => {
const parsed = JSON.parse(raw)
return typeof parsed === "string" ? parsed : raw.replace(/^"|"$/g, "")
},
catch: () => raw.replace(/^"|"$/g, ""),
}).pipe(
Effect.flatMap((value) => (value ? Effect.succeed(value) : Effect.fail(new Error(`Invalid version response from ${name}`)))),
)
})

const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
Expand Down Expand Up @@ -216,16 +243,16 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
return data.versions.stable
}

if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (yield* text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg
const channel = InstallationChannel
const response = yield* httpOk.execute(
HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
if (detectedMethod === "npm") {
return yield* packageVersion(["npm"])
}

if (detectedMethod === "pnpm") {
return yield* packageVersion(["pnpm"])
}

if (detectedMethod === "bun") {
return yield* packageVersion(["bun", "pm"])
}

if (detectedMethod === "choco") {
Expand Down
77 changes: 67 additions & 10 deletions packages/opencode/test/installation/installation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { homedir } from "os"
import { Installation } from "../../src/installation"
import { InstallationChannel } from "../../src/installation/version"

const encoder = new TextEncoder()

Expand All @@ -11,10 +13,16 @@ function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest)
return Layer.succeed(HttpClient.HttpClient, client)
}

function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") {
function mockSpawner(
handler: (
cmd: string,
args: readonly string[],
options?: { cwd?: string },
) => string = () => "",
) {
const spawner = ChildProcessSpawner.make((command) => {
const std = ChildProcess.isStandardCommand(command) ? command : undefined
const output = handler(std?.command ?? "", std?.args ?? [])
const output = handler(std?.command ?? "", std?.args ?? [], std?.options)
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
Expand Down Expand Up @@ -43,7 +51,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[], options?: { cwd?: string }) => string,
) {
return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(mockSpawner(spawnHandler)))
}
Expand All @@ -68,11 +76,18 @@ describe("installation", () => {
expect(result).toBe("4.0.0-beta.1")
})

test("reads npm registry versions", async () => {
test("uses npm view for npm installs so registry auth stays package-manager aware", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.5.0" }),
(cmd, args) => {
if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n"
() => {
throw new Error("unexpected http request")
},
(cmd, args, options) => {
if (
cmd === "npm" &&
args.join(" ") === `view opencode-ai@${InstallationChannel} version --json` &&
options?.cwd === homedir()
)
return '"1.5.0"\n'
return ""
},
)
Expand All @@ -83,10 +98,20 @@ describe("installation", () => {
expect(result).toBe("1.5.0")
})

test("reads npm registry versions for bun method", async () => {
test("uses bun pm view for bun installs so registry config stays auth-aware", async () => {
const layer = testLayer(
() => jsonResponse({ version: "1.6.0" }),
() => "",
() => {
throw new Error("unexpected http request")
},
(cmd, args, options) => {
if (
cmd === "bun" &&
args.join(" ") === `pm view opencode-ai@${InstallationChannel} version --json` &&
options?.cwd === homedir()
)
return '"1.6.0"\n'
return ""
},
)

const result = await Effect.runPromise(
Expand All @@ -95,6 +120,38 @@ describe("installation", () => {
expect(result).toBe("1.6.0")
})

test("uses pnpm view for pnpm installs so registry auth stays package-manager aware", async () => {
const layer = testLayer(
() => {
throw new Error("unexpected http request")
},
(cmd, args, options) => {
if (
cmd === "pnpm" &&
args.join(" ") === `view opencode-ai@${InstallationChannel} version --json` &&
options?.cwd === homedir()
)
return '"1.7.0"\n'
return ""
},
)

const result = await Effect.runPromise(
Installation.Service.use((svc) => svc.latest("pnpm")).pipe(Effect.provide(layer)),
)
expect(result).toBe("1.7.0")
})

test("fails instead of returning a blank version when npm view produces no metadata", async () => {
const layer = testLayer(() => {
throw new Error("unexpected http request")
})

await expect(
Effect.runPromise(Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer))),
).rejects.toThrow("Empty version response from npm")
})

test("reads scoop manifest versions", async () => {
const layer = testLayer(() => jsonResponse({ version: "2.3.4" }))

Expand Down
Loading