Sfoglia il codice sorgente

Improve upgrade command with installation method detection (#158)

Dax 8 mesi fa
parent
commit
d054f88130

+ 2 - 13
opencode.json

@@ -1,16 +1,5 @@
 {
   "$schema": "https://opencode.ai/config.json",
-  "provider": {
-    "ollama": {
-      "npm": "@ai-sdk/openai-compatible",
-      "options": {
-        "baseURL": "http://localhost:11434/v1"
-      },
-      "models": {
-        "qwen3": {},
-        "deepseek-r1": {},
-        "llama2": {}
-      }
-    }
-  }
+  "mcp": {},
+  "provider": {}
 }

+ 25 - 108
packages/opencode/src/cli/cmd/upgrade.ts

@@ -1,113 +1,8 @@
 import type { Argv } from "yargs"
 import { UI } from "../ui"
 import { VERSION } from "../version"
-import path from "path"
-import fs from "fs/promises"
-import os from "os"
 import * as prompts from "@clack/prompts"
-import { Global } from "../../global"
-
-const API = "https://api.github.com/repos/sst/opencode"
-
-interface Release {
-  tag_name: string
-  name: string
-  assets: Array<{
-    name: string
-    browser_download_url: string
-  }>
-}
-
-function asset(): string {
-  const platform = os.platform()
-  const arch = os.arch()
-
-  if (platform === "darwin") {
-    return arch === "arm64"
-      ? "opencode-darwin-arm64.zip"
-      : "opencode-darwin-x64.zip"
-  }
-  if (platform === "linux") {
-    return arch === "arm64"
-      ? "opencode-linux-arm64.zip"
-      : "opencode-linux-x64.zip"
-  }
-  if (platform === "win32") {
-    return "opencode-windows-x64.zip"
-  }
-
-  throw new Error(`Unsupported platform: ${platform}-${arch}`)
-}
-
-function compare(current: string, latest: string): number {
-  const a = current.replace(/^v/, "")
-  const b = latest.replace(/^v/, "")
-
-  const aParts = a.split(".").map(Number)
-  const bParts = b.split(".").map(Number)
-
-  for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
-    const aPart = aParts[i] || 0
-    const bPart = bParts[i] || 0
-
-    if (aPart < bPart) return -1
-    if (aPart > bPart) return 1
-  }
-
-  return 0
-}
-
-async function latest(): Promise<Release> {
-  const response = await fetch(`${API}/releases/latest`)
-  if (!response.ok) {
-    throw new Error(`Failed to fetch latest release: ${response.statusText}`)
-  }
-  return response.json()
-}
-
-async function specific(version: string): Promise<Release> {
-  const tag = version.startsWith("v") ? version : `v${version}`
-  const response = await fetch(`${API}/releases/tags/${tag}`)
-  if (!response.ok) {
-    throw new Error(`Failed to fetch release ${tag}: ${response.statusText}`)
-  }
-  return response.json()
-}
-
-async function download(url: string): Promise<string> {
-  const response = await fetch(url)
-  if (!response.ok) {
-    throw new Error(`Failed to download: ${response.statusText}`)
-  }
-
-  const buffer = await response.arrayBuffer()
-  const temp = path.join(Global.Path.cache, `opencode-update-${Date.now()}.zip`)
-
-  await Bun.write(temp, buffer)
-
-  const extractDir = path.join(
-    Global.Path.cache,
-    `opencode-extract-${Date.now()}`,
-  )
-  await fs.mkdir(extractDir, { recursive: true })
-
-  const proc = Bun.spawn(["unzip", "-o", temp, "-d", extractDir], {
-    stdout: "pipe",
-    stderr: "pipe",
-  })
-
-  const result = await proc.exited
-  if (result !== 0) {
-    throw new Error("Failed to extract update")
-  }
-
-  await fs.unlink(temp)
-
-  const binary = path.join(extractDir, "opencode")
-  await fs.chmod(binary, 0o755)
-
-  return binary
-}
+import { Installation } from "../../installation"
 
 export const UpgradeCommand = {
   command: "upgrade [target]",
@@ -123,14 +18,35 @@ export const UpgradeCommand = {
     UI.println(UI.logo("  "))
     UI.empty()
     prompts.intro("Upgrade")
-
-    if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
+    const method = await Installation.method()
+    if (method === "unknown") {
       prompts.log.error(
         `opencode is installed to ${process.execPath} and seems to be managed by a package manager`,
       )
       prompts.outro("Done")
       return
     }
+    const target = args.target ?? (await Installation.latest())
+    prompts.log.info(`From ${VERSION} → ${target}`)
+    const spinner = prompts.spinner()
+    spinner.start("Upgrading...")
+    const err = await Installation.upgrade(method, target).catch((err) => err)
+    if (err) {
+      spinner.stop("Upgrade failed")
+      if (err instanceof Installation.UpgradeFailedError)
+        prompts.log.error(err.data.stderr)
+      else if (err instanceof Error) prompts.log.error(err.message)
+      prompts.outro("Done")
+      return
+    }
+    spinner.stop("Upgrade complete")
+    prompts.outro("Done")
+    return
+
+    /*
+    if (!process.execPath.includes(path.join(".opencode", "bin")) && false) {
+      return
+    }
 
     const release = args.target
       ? await specific(args.target).catch(() => {})
@@ -188,5 +104,6 @@ export const UpgradeCommand = {
 
     prompts.log.success(`Successfully upgraded to ${target}`)
     prompts.outro("Done")
+    */
   },
 }

+ 1 - 0
packages/opencode/src/global/config.ts

@@ -0,0 +1 @@
+export namespace GlobalConfig {}

+ 104 - 0
packages/opencode/src/installation/index.ts

@@ -0,0 +1,104 @@
+import path from "path"
+import { $ } from "bun"
+import { z } from "zod"
+import { NamedError } from "../util/error"
+
+export namespace Installation {
+  export type Method = Awaited<ReturnType<typeof method>>
+
+  export const Info = z
+    .object({
+      version: z.string(),
+      latest: z.string(),
+    })
+    .openapi({
+      ref: "InstallationInfo",
+    })
+  export type Info = z.infer<typeof Info>
+
+  export async function info() {
+    return {
+      version: VERSION,
+      latest: await latest(),
+    }
+  }
+
+  export async function method() {
+    if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl"
+    const exec = process.execPath.toLowerCase()
+
+    const checks = [
+      {
+        name: "npm" as const,
+        command: () => $`npm list -g --depth=0`.throws(false).text(),
+      },
+      {
+        name: "yarn" as const,
+        command: () => $`yarn global list`.throws(false).text(),
+      },
+      {
+        name: "pnpm" as const,
+        command: () => $`pnpm list -g --depth=0`.throws(false).text(),
+      },
+      {
+        name: "bun" as const,
+        command: () => $`bun pm ls -g`.throws(false).text(),
+      },
+    ]
+
+    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 = await check.command()
+      if (output.includes("opencode-ai")) {
+        return check.name
+      }
+    }
+
+    return "unknown"
+  }
+
+  export const UpgradeFailedError = NamedError.create(
+    "UpgradeFailedError",
+    z.object({
+      stderr: z.string(),
+    }),
+  )
+
+  export async function upgrade(method: Method, target: string) {
+    const cmd = (() => {
+      switch (method) {
+        case "curl":
+          return $`curl -fsSL https://opencode.ai/install | bash`
+        case "npm":
+          return $`npm install -g opencode-ai@${target}`
+        case "pnpm":
+          return $`pnpm install -g opencode-ai@${target}`
+        case "bun":
+          return $`bun install -g opencode-ai@${target}`
+        default:
+          throw new Error(`Unknown method: ${method}`)
+      }
+    })()
+    const result = await cmd.quiet().throws(false)
+    if (result.exitCode !== 0)
+      throw new UpgradeFailedError({
+        stderr: result.stderr.toString("utf8"),
+      })
+  }
+
+  export const VERSION =
+    typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "dev"
+
+  export async function latest() {
+    return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+      .then((res) => res.json())
+      .then((data) => data.tag_name.slice(1))
+  }
+}

+ 20 - 0
packages/opencode/src/server/server.ts

@@ -15,6 +15,7 @@ import { NamedError } from "../util/error"
 import { Fzf } from "../external/fzf"
 import { ModelsDev } from "../provider/models"
 import { Ripgrep } from "../external/ripgrep"
+import { Installation } from "../installation"
 
 const ERRORS = {
   400: {
@@ -466,6 +467,25 @@ export namespace Server {
           return c.json(result)
         },
       )
+      .post(
+        "installation_info",
+        describeRoute({
+          description: "Get installation info",
+          responses: {
+            200: {
+              description: "Get installation info",
+              content: {
+                "application/json": {
+                  schema: resolver(Installation.Info),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          return c.json(Installation.info())
+        },
+      )
 
     return result
   }

+ 1 - 1
packages/web/src/components/Share.tsx

@@ -1518,7 +1518,7 @@ export default function Share(props: {
                                       desc={desc}
                                       data-size="sm"
                                       text={
-                                        command + (result() ? `\n${result}` : "")
+                                        command + (result() ? `\n${result()}` : "")
                                       }
                                     />
                                   </div>