Skip to content

Commit d054f88

Browse files
authored
Improve upgrade command with installation method detection (anomalyco#158)
1 parent b929b4f commit d054f88

6 files changed

Lines changed: 153 additions & 122 deletions

File tree

opencode.json

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
11
{
22
"$schema": "https://opencode.ai/config.json",
3-
"provider": {
4-
"ollama": {
5-
"npm": "@ai-sdk/openai-compatible",
6-
"options": {
7-
"baseURL": "http://localhost:11434/v1"
8-
},
9-
"models": {
10-
"qwen3": {},
11-
"deepseek-r1": {},
12-
"llama2": {}
13-
}
14-
}
15-
}
3+
"mcp": {},
4+
"provider": {}
165
}
Lines changed: 25 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,8 @@
11
import type { Argv } from "yargs"
22
import { UI } from "../ui"
33
import { VERSION } from "../version"
4-
import path from "path"
5-
import fs from "fs/promises"
6-
import os from "os"
74
import * as prompts from "@clack/prompts"
8-
import { Global } from "../../global"
9-
10-
const API = "https://api.github.com/repos/sst/opencode"
11-
12-
interface Release {
13-
tag_name: string
14-
name: string
15-
assets: Array<{
16-
name: string
17-
browser_download_url: string
18-
}>
19-
}
20-
21-
function asset(): string {
22-
const platform = os.platform()
23-
const arch = os.arch()
24-
25-
if (platform === "darwin") {
26-
return arch === "arm64"
27-
? "opencode-darwin-arm64.zip"
28-
: "opencode-darwin-x64.zip"
29-
}
30-
if (platform === "linux") {
31-
return arch === "arm64"
32-
? "opencode-linux-arm64.zip"
33-
: "opencode-linux-x64.zip"
34-
}
35-
if (platform === "win32") {
36-
return "opencode-windows-x64.zip"
37-
}
38-
39-
throw new Error(`Unsupported platform: ${platform}-${arch}`)
40-
}
41-
42-
function compare(current: string, latest: string): number {
43-
const a = current.replace(/^v/, "")
44-
const b = latest.replace(/^v/, "")
45-
46-
const aParts = a.split(".").map(Number)
47-
const bParts = b.split(".").map(Number)
48-
49-
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
50-
const aPart = aParts[i] || 0
51-
const bPart = bParts[i] || 0
52-
53-
if (aPart < bPart) return -1
54-
if (aPart > bPart) return 1
55-
}
56-
57-
return 0
58-
}
59-
60-
async function latest(): Promise<Release> {
61-
const response = await fetch(`${API}/releases/latest`)
62-
if (!response.ok) {
63-
throw new Error(`Failed to fetch latest release: ${response.statusText}`)
64-
}
65-
return response.json()
66-
}
67-
68-
async function specific(version: string): Promise<Release> {
69-
const tag = version.startsWith("v") ? version : `v${version}`
70-
const response = await fetch(`${API}/releases/tags/${tag}`)
71-
if (!response.ok) {
72-
throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
73-
}
74-
return response.json()
75-
}
76-
77-
async function download(url: string): Promise<string> {
78-
const response = await fetch(url)
79-
if (!response.ok) {
80-
throw new Error(`Failed to download: ${response.statusText}`)
81-
}
82-
83-
const buffer = await response.arrayBuffer()
84-
const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
85-
86-
await Bun.write(temp, buffer)
87-
88-
const extractDir = path.join(
89-
Global.Path.cache,
90-
`opencode-extract-${Date.now()}`,
91-
)
92-
await fs.mkdir(extractDir, { recursive: true })
93-
94-
const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
95-
stdout: "pipe",
96-
stderr: "pipe",
97-
})
98-
99-
const result = await proc.exited
100-
if (result !== 0) {
101-
throw new Error("Failed to extract update")
102-
}
103-
104-
await fs.unlink(temp)
105-
106-
const binary = path.join(extractDir, "opencode")
107-
await fs.chmod(binary, 0o755)
108-
109-
return binary
110-
}
5+
import { Installation } from "../../installation"
1116

1127
export const UpgradeCommand = {
1138
command: "upgrade [target]",
@@ -123,14 +18,35 @@ export const UpgradeCommand = {
12318
UI.println(UI.logo(" "))
12419
UI.empty()
12520
prompts.intro("Upgrade")
126-
127-
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
21+
const method = await Installation.method()
22+
if (method === "unknown") {
12823
prompts.log.error(
12924
`opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
13025
)
13126
prompts.outro("Done")
13227
return
13328
}
29+
const target = args.target ?? (await Installation.latest())
30+
prompts.log.info(`From ${VERSION}${target}`)
31+
const spinner = prompts.spinner()
32+
spinner.start("Upgrading...")
33+
const err = await Installation.upgrade(method, target).catch((err) => err)
34+
if (err) {
35+
spinner.stop("Upgrade failed")
36+
if (err instanceof Installation.UpgradeFailedError)
37+
prompts.log.error(err.data.stderr)
38+
else if (err instanceof Error) prompts.log.error(err.message)
39+
prompts.outro("Done")
40+
return
41+
}
42+
spinner.stop("Upgrade complete")
43+
prompts.outro("Done")
44+
return
45+
46+
/*
47+
if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
48+
return
49+
}
13450
13551
const release = args.target
13652
? await specific(args.target).catch(() => {})
@@ -188,5 +104,6 @@ export const UpgradeCommand = {
188104
189105
prompts.log.success(`Successfully upgraded to ${target}`)
190106
prompts.outro("Done")
107+
*/
191108
},
192109
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export namespace GlobalConfig {}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import path from "path"
2+
import { $ } from "bun"
3+
import { z } from "zod"
4+
import { NamedError } from "../util/error"
5+
6+
export namespace Installation {
7+
export type Method = Awaited<ReturnType<typeof method>>
8+
9+
export const Info = z
10+
.object({
11+
version: z.string(),
12+
latest: z.string(),
13+
})
14+
.openapi({
15+
ref: "InstallationInfo",
16+
})
17+
export type Info = z.infer<typeof Info>
18+
19+
export async function info() {
20+
return {
21+
version: VERSION,
22+
latest: await latest(),
23+
}
24+
}
25+
26+
export async function method() {
27+
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
28+
const exec = process.execPath.toLowerCase()
29+
30+
const checks = [
31+
{
32+
name: "npm" as const,
33+
command: () => $`npm list -g --depth=0`.throws(false).text(),
34+
},
35+
{
36+
name: "yarn" as const,
37+
command: () => $`yarn global list`.throws(false).text(),
38+
},
39+
{
40+
name: "pnpm" as const,
41+
command: () => $`pnpm list -g --depth=0`.throws(false).text(),
42+
},
43+
{
44+
name: "bun" as const,
45+
command: () => $`bun pm ls -g`.throws(false).text(),
46+
},
47+
]
48+
49+
checks.sort((a, b) => {
50+
const aMatches = exec.includes(a.name)
51+
const bMatches = exec.includes(b.name)
52+
if (aMatches && !bMatches) return -1
53+
if (!aMatches && bMatches) return 1
54+
return 0
55+
})
56+
57+
for (const check of checks) {
58+
const output = await check.command()
59+
if (output.includes("opencode-ai")) {
60+
return check.name
61+
}
62+
}
63+
64+
return "unknown"
65+
}
66+
67+
export const UpgradeFailedError = NamedError.create(
68+
"UpgradeFailedError",
69+
z.object({
70+
stderr: z.string(),
71+
}),
72+
)
73+
74+
export async function upgrade(method: Method, target: string) {
75+
const cmd = (() => {
76+
switch (method) {
77+
case "curl":
78+
return $`curl -fsSL https://opencode.ai/install | bash`
79+
case "npm":
80+
return $`npm install -g opencode-ai@${target}`
81+
case "pnpm":
82+
return $`pnpm install -g opencode-ai@${target}`
83+
case "bun":
84+
return $`bun install -g opencode-ai@${target}`
85+
default:
86+
throw new Error(`Unknown method: ${method}`)
87+
}
88+
})()
89+
const result = await cmd.quiet().throws(false)
90+
if (result.exitCode !== 0)
91+
throw new UpgradeFailedError({
92+
stderr: result.stderr.toString("utf8"),
93+
})
94+
}
95+
96+
export const VERSION =
97+
typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
98+
99+
export async function latest() {
100+
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
101+
.then((res) => res.json())
102+
.then((data) => data.tag_name.slice(1))
103+
}
104+
}

packages/opencode/src/server/server.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NamedError } from "../util/error"
1515
import { Fzf } from "../external/fzf"
1616
import { ModelsDev } from "../provider/models"
1717
import { Ripgrep } from "../external/ripgrep"
18+
import { Installation } from "../installation"
1819

1920
const ERRORS = {
2021
400: {
@@ -466,6 +467,25 @@ export namespace Server {
466467
return c.json(result)
467468
},
468469
)
470+
.post(
471+
"installation_info",
472+
describeRoute({
473+
description: "Get installation info",
474+
responses: {
475+
200: {
476+
description: "Get installation info",
477+
content: {
478+
"application/json": {
479+
schema: resolver(Installation.Info),
480+
},
481+
},
482+
},
483+
},
484+
}),
485+
async (c) => {
486+
return c.json(Installation.info())
487+
},
488+
)
469489

470490
return result
471491
}

packages/web/src/components/Share.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1518,7 +1518,7 @@ export default function Share(props: {
15181518
desc={desc}
15191519
data-size="sm"
15201520
text={
1521-
command + (result() ? `\n${result}` : "")
1521+
command + (result() ? `\n${result()}` : "")
15221522
}
15231523
/>
15241524
</div>

0 commit comments

Comments
 (0)