Skip to content
Merged
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
210 changes: 25 additions & 185 deletions packages/opencode/src/installation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config"

const log = Log.create({ service: "installation" })

Expand Down Expand Up @@ -69,17 +68,12 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedErr
stderr: Schema.String,
}) {}

// Response schemas for external version APIs
// BrowserCode currently only ships the curl installer (https://bcode.sh/install).
// npm/brew/scoop/choco branches are stubbed: `method()` only returns "curl"
// or "unknown", and `latest()` / `upgrade()` short-circuit on "unknown".
// When we add another distribution channel, restore the corresponding registry
// schemas + branches from upstream `anomalyco/opencode`.
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
const NpmPackage = Schema.Struct({ version: Schema.String })
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
const BrewInfoV2 = Schema.Struct({
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
})
const ChocoPackage = Schema.Struct({
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
})
const ScoopManifest = NpmPackage

export interface Interface {
readonly info: () => Effect.Effect<Info>
Expand Down Expand Up @@ -114,40 +108,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.catch(() => Effect.succeed("")),
)

const run = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, stdout, stderr }
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
)

const getBrewFormula = Effect.fnUntraced(function* () {
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
})

// Re-runs the hosted install script with VERSION=<target>, which writes
// the new binary to ~/.bcode/bin/bcode in place. Same flow as the
// user's original `curl https://bcode.sh/install | bash` install.
const upgradeCurl = Effect.fnUntraced(
function* (target: string) {
const response = yield* httpOk.execute(HttpClientRequest.get("https://bcode.sh/install"))
const body = yield* response.text
const bodyBytes = new TextEncoder().encode(body)
const proc = ChildProcess.make("bash", [], {
stdin: Stream.make(bodyBytes),
stdin: Stream.make(new TextEncoder().encode(body)),
env: { VERSION: target },
extendEnv: true,
})
Expand All @@ -163,178 +132,49 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
Effect.orDie,
)

// BrowserCode self-upgrade is intentionally disabled. The upstream
// `Installation` service queries opencode-ai's npm/brew/scoop/choco
// registries and the anomalyco/opencode GitHub releases — running
// any of those against a `bcode` binary would replace it with
// `opencode`. Instead we report `latest === current` so the
// auto-upgrade in `cli/upgrade.ts` is a no-op, and `bcode upgrade`
// prints a friendly "not yet supported" error.
const BCODE_UPGRADE_DISABLED = true

const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
return {
version: InstallationVersion,
latest: yield* result.latest(),
}
}),
// Until BrowserCode ships beyond the curl installer, we only detect
// the curl path. Anything else is "unknown" — `upgrade()` handles
// that with a clear error pointing at the install script.
method: Effect.fn("Installation.method")(function* () {
if (process.execPath.includes(path.join(".bcode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()

const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
]

checks.sort((a, b) => {
const aMatches = exec.includes(a.name)
const bMatches = exec.includes(b.name)
if (aMatches && !bMatches) return -1
if (!aMatches && bMatches) return 1
return 0
})

for (const check of checks) {
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
return check.name
}
}

return "unknown" as Method
}),
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
if (BCODE_UPGRADE_DISABLED) {
return InstallationVersion
}
const detectedMethod = installMethod || (yield* result.method())

if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}

if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
`${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}

if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}

if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}

// No-op for unsupported methods so the TUI auto-upgrade check stays
// silent for devs running from source.
if (detectedMethod !== "curl") return InstallationVersion
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.get("https://api.github.com/repos/browser-use/browsercode/releases/latest").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
}, Effect.orDie),
upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
if (BCODE_UPGRADE_DISABLED) {
if (m !== "curl") {
return yield* new UpgradeFailedError({
stderr:
"BrowserCode auto-upgrade is not yet supported. Download a release from https://github.com/browser-use/browsercode/releases or rebuild from source.",
"Auto-upgrade currently supports only curl-installed bcode. " +
"Reinstall with:\n curl -fsSL https://bcode.sh/install | bash",
})
}
let upgradeResult: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
switch (m) {
case "curl":
upgradeResult = yield* upgradeCurl(target)
break
case "npm":
upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
break
case "pnpm":
upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
break
case "bun":
upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
break
case "brew": {
const formula = yield* getBrewFormula()
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
if (formula.includes("/")) {
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
if (tap.code !== 0) {
upgradeResult = tap
break
}
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
const dir = repo.trim()
if (dir) {
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
if (pull.code !== 0) {
upgradeResult = pull
break
}
}
}
upgradeResult = yield* run(["brew", "upgrade", formula], { env })
break
}
case "choco":
upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
break
case "scoop":
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!upgradeResult || upgradeResult.code !== 0) {
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
return yield* new UpgradeFailedError({ stderr })
const upgradeResult = yield* upgradeCurl(target)
if (upgradeResult.code !== 0) {
return yield* new UpgradeFailedError({
stderr: `${upgradeResult.stderr.trimEnd()}\n\nReinstall with:\n curl -fsSL https://bcode.sh/install | bash`,
})
}
log.info("upgraded", {
method: m,
target,
stdout: upgradeResult.stdout,
stderr: upgradeResult.stderr,
})
log.info("upgraded", { method: m, target, stdout: upgradeResult.stdout, stderr: upgradeResult.stderr })
yield* text([process.execPath, "--version"])
}),
}
Expand Down
Loading