Skip to content

Commit 2015fba

Browse files
author
bcode
committed
feat(installation): enable curl-based self-upgrade
Re-enables `bcode upgrade` and the TUI auto-upgrade-on-launch flow for curl-installed users. The hosted `https://bcode.sh/install` script is re-run with VERSION=<target>, which writes the new binary to ~/.bcode/bin/bcode in place — same flow as the original install. Changes from the previous BCODE_UPGRADE_DISABLED stub: - `method()`: only returns "curl" (~/.bcode/bin or ~/.local/bin) or "unknown". The package-manager detection logic for npm/brew/scoop/choco is removed, since BrowserCode doesn't publish to those registries yet and running those checks would either be no-ops or (worse) match an installed `opencode-ai` and try to upgrade the wrong binary. - `latest()`: GitHub releases lookup repointed to `browser-use/browsercode`. For non-curl methods returns the current version so the TUI auto-upgrade check stays silent for devs running from source. - `upgrade()`: curl branch only. Other methods return a clear error pointing at https://bcode.sh/install. Net effect for users: `bcode upgrade` works, and the TUI notifies on new releases (gated by config.autoupdate, same as upstream). When we ship to npm/brew/etc., we'll re-introduce per-method branches from upstream `anomalyco/opencode`.
1 parent a3487ef commit 2015fba

1 file changed

Lines changed: 24 additions & 185 deletions

File tree

  • packages/opencode/src/installation

packages/opencode/src/installation/index.ts

Lines changed: 24 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import * as Log from "@opencode-ai/core/util/log"
1111
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
1212
import semver from "semver"
1313
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
14-
import { NpmConfig } from "@opencode-ai/core/npm-config"
1514

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

@@ -69,17 +68,12 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedErr
6968
stderr: Schema.String,
7069
}) {}
7170

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

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

117-
const run = Effect.fnUntraced(
118-
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
119-
const proc = ChildProcess.make(cmd[0], cmd.slice(1), {
120-
cwd: opts?.cwd,
121-
env: opts?.env,
122-
extendEnv: true,
123-
})
124-
const handle = yield* spawner.spawn(proc)
125-
const [stdout, stderr] = yield* Effect.all(
126-
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
127-
{ concurrency: 2 },
128-
)
129-
const code = yield* handle.exitCode
130-
return { code, stdout, stderr }
131-
},
132-
Effect.scoped,
133-
Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })),
134-
)
135-
136-
const getBrewFormula = Effect.fnUntraced(function* () {
137-
const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
138-
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
139-
const coreFormula = yield* text(["brew", "list", "--formula", "opencode"])
140-
if (coreFormula.includes("opencode")) return "opencode"
141-
return "opencode"
142-
})
143-
111+
// Re-runs the hosted install script with VERSION=<target>, which writes
112+
// the new binary to ~/.bcode/bin/bcode in place. Same flow as the
113+
// user's original `curl https://bcode.sh/install | bash` install.
144114
const upgradeCurl = Effect.fnUntraced(
145115
function* (target: string) {
146116
const response = yield* httpOk.execute(HttpClientRequest.get("https://bcode.sh/install"))
147117
const body = yield* response.text
148-
const bodyBytes = new TextEncoder().encode(body)
149118
const proc = ChildProcess.make("bash", [], {
150-
stdin: Stream.make(bodyBytes),
119+
stdin: Stream.make(new TextEncoder().encode(body)),
151120
env: { VERSION: target },
152121
extendEnv: true,
153122
})
@@ -163,178 +132,48 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildPro
163132
Effect.orDie,
164133
)
165134

166-
// BrowserCode self-upgrade is intentionally disabled. The upstream
167-
// `Installation` service queries opencode-ai's npm/brew/scoop/choco
168-
// registries and the anomalyco/opencode GitHub releases — running
169-
// any of those against a `bcode` binary would replace it with
170-
// `opencode`. Instead we report `latest === current` so the
171-
// auto-upgrade in `cli/upgrade.ts` is a no-op, and `bcode upgrade`
172-
// prints a friendly "not yet supported" error.
173-
const BCODE_UPGRADE_DISABLED = true
174-
175135
const result: Interface = {
176136
info: Effect.fn("Installation.info")(function* () {
177137
return {
178138
version: InstallationVersion,
179139
latest: yield* result.latest(),
180140
}
181141
}),
142+
// Until BrowserCode ships beyond the curl installer, we only detect
143+
// the curl path. Anything else is "unknown" — `upgrade()` handles
144+
// that with a clear error pointing at the install script.
182145
method: Effect.fn("Installation.method")(function* () {
183146
if (process.execPath.includes(path.join(".bcode", "bin"))) return "curl" as Method
184147
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
185-
const exec = process.execPath.toLowerCase()
186-
187-
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
188-
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
189-
{ name: "yarn", command: () => text(["yarn", "global", "list"]) },
190-
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
191-
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
192-
{ name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) },
193-
{ name: "scoop", command: () => text(["scoop", "list", "opencode"]) },
194-
{ name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) },
195-
]
196-
197-
checks.sort((a, b) => {
198-
const aMatches = exec.includes(a.name)
199-
const bMatches = exec.includes(b.name)
200-
if (aMatches && !bMatches) return -1
201-
if (!aMatches && bMatches) return 1
202-
return 0
203-
})
204-
205-
for (const check of checks) {
206-
const output = yield* check.command()
207-
const installedName =
208-
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
209-
if (output.includes(installedName)) {
210-
return check.name
211-
}
212-
}
213-
214148
return "unknown" as Method
215149
}),
216150
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
217-
if (BCODE_UPGRADE_DISABLED) {
218-
return InstallationVersion
219-
}
220151
const detectedMethod = installMethod || (yield* result.method())
221-
222-
if (detectedMethod === "brew") {
223-
const formula = yield* getBrewFormula()
224-
if (formula.includes("/")) {
225-
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
226-
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
227-
return info.formulae[0].versions.stable
228-
}
229-
const response = yield* httpOk.execute(
230-
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
231-
HttpClientRequest.acceptJson,
232-
),
233-
)
234-
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
235-
return data.versions.stable
236-
}
237-
238-
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
239-
const response = yield* httpOk.execute(
240-
HttpClientRequest.get(
241-
`${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
242-
).pipe(HttpClientRequest.acceptJson),
243-
)
244-
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
245-
return data.version
246-
}
247-
248-
if (detectedMethod === "choco") {
249-
const response = yield* httpOk.execute(
250-
HttpClientRequest.get(
251-
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
252-
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
253-
)
254-
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
255-
return data.d.results[0].Version
256-
}
257-
258-
if (detectedMethod === "scoop") {
259-
const response = yield* httpOk.execute(
260-
HttpClientRequest.get(
261-
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
262-
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
263-
)
264-
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
265-
return data.version
266-
}
267-
152+
// No-op for unsupported methods so the TUI auto-upgrade check stays
153+
// silent for devs running from source.
154+
if (detectedMethod !== "curl") return InstallationVersion
268155
const response = yield* httpOk.execute(
269-
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
156+
HttpClientRequest.get("https://api.github.com/repos/browser-use/browsercode/releases/latest").pipe(
270157
HttpClientRequest.acceptJson,
271158
),
272159
)
273160
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
274161
return data.tag_name.replace(/^v/, "")
275162
}, Effect.orDie),
276163
upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
277-
if (BCODE_UPGRADE_DISABLED) {
164+
if (m !== "curl") {
278165
return yield* new UpgradeFailedError({
279166
stderr:
280-
"BrowserCode auto-upgrade is not yet supported. Download a release from https://github.com/browser-use/browsercode/releases or rebuild from source.",
167+
"Auto-upgrade currently supports only curl-installed bcode. " +
168+
"Reinstall via https://bcode.sh/install or download a release " +
169+
"from https://github.com/browser-use/browsercode/releases.",
281170
})
282171
}
283-
let upgradeResult: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined
284-
switch (m) {
285-
case "curl":
286-
upgradeResult = yield* upgradeCurl(target)
287-
break
288-
case "npm":
289-
upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`])
290-
break
291-
case "pnpm":
292-
upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`])
293-
break
294-
case "bun":
295-
upgradeResult = yield* run(["bun", "install", "-g", `opencode-ai@${target}`])
296-
break
297-
case "brew": {
298-
const formula = yield* getBrewFormula()
299-
const env = { HOMEBREW_NO_AUTO_UPDATE: "1" }
300-
if (formula.includes("/")) {
301-
const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env })
302-
if (tap.code !== 0) {
303-
upgradeResult = tap
304-
break
305-
}
306-
const repo = yield* text(["brew", "--repo", "anomalyco/tap"])
307-
const dir = repo.trim()
308-
if (dir) {
309-
const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env })
310-
if (pull.code !== 0) {
311-
upgradeResult = pull
312-
break
313-
}
314-
}
315-
}
316-
upgradeResult = yield* run(["brew", "upgrade", formula], { env })
317-
break
318-
}
319-
case "choco":
320-
upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"])
321-
break
322-
case "scoop":
323-
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
324-
break
325-
default:
326-
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
172+
const upgradeResult = yield* upgradeCurl(target)
173+
if (upgradeResult.code !== 0) {
174+
return yield* new UpgradeFailedError({ stderr: upgradeResult.stderr })
327175
}
328-
if (!upgradeResult || upgradeResult.code !== 0) {
329-
const stderr = m === "choco" ? "not running from an elevated command shell" : upgradeResult?.stderr || ""
330-
return yield* new UpgradeFailedError({ stderr })
331-
}
332-
log.info("upgraded", {
333-
method: m,
334-
target,
335-
stdout: upgradeResult.stdout,
336-
stderr: upgradeResult.stderr,
337-
})
176+
log.info("upgraded", { method: m, target, stdout: upgradeResult.stdout, stderr: upgradeResult.stderr })
338177
yield* text([process.execPath, "--version"])
339178
}),
340179
}

0 commit comments

Comments
 (0)