From 38879dee2ddfe5fa65fb4c274b8b167733d26a27 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 14 Jun 2025 22:05:41 -0400 Subject: [PATCH] beginning of upgrade command --- packages/opencode/AGENTS.md | 4 +- packages/opencode/src/cli/cmd/upgrade.ts | 184 +++++++++++++++++++++ packages/opencode/src/cli/router.ts | 193 ----------------------- packages/opencode/src/index.ts | 4 +- 4 files changed, 189 insertions(+), 196 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/upgrade.ts delete mode 100644 packages/opencode/src/cli/router.ts diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index b5bf0291..ebd03400 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -1,4 +1,4 @@ -# OpenCode Agent Guidelines +# opencode agent guidelines ## Build/Test Commands @@ -19,7 +19,7 @@ ## IMPORTANT -- Try to keep things in one function unless composable or reusable +- Try to keep things in one function unless composable or reusablte - DO NOT do unnecessary destructuring of variables - DO NOT use else statements unless necessary - DO NOT use try catch if it can be avoided diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts new file mode 100644 index 00000000..95159ce7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -0,0 +1,184 @@ +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 { + 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 { + 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 { + 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 +} + +export const UpgradeCommand = { + command: "upgrade [target]", + describe: "Upgrade opencode to the latest version or a specific version", + builder: (yargs: Argv) => { + return yargs.positional("target", { + describe: "Specific version to upgrade to (e.g., '0.1.48' or 'v0.1.48')", + type: "string", + }) + }, + handler: async (args: { target?: string }) => { + UI.empty() + UI.println(UI.logo(" ")) + UI.empty() + prompts.intro("upgrade") + + if (!process.execPath.includes(path.join(".opencode", "bin")) && false) { + prompts.log.error( + `opencode is installed to ${process.execPath} and seems to be managed by a package manager`, + ) + prompts.outro("Done") + return + } + + const release = args.target ? await specific(args.target) : await latest() + const target = release.tag_name + + prompts.log.info(`Upgrade ${VERSION} → ${target}`) + + if (VERSION !== "dev" && compare(VERSION, target) >= 0) { + prompts.log.success(`Already up to date`) + prompts.outro("Done") + return + } + + const name = asset() + const found = release.assets.find((a) => a.name === name) + + if (!found) { + prompts.log.error(`No binary found for platform: ${name}`) + prompts.outro("Done") + return + } + + const spinner = prompts.spinner() + spinner.start("Downloading update...") + + let downloadPath: string + try { + downloadPath = await download(found.browser_download_url) + spinner.stop("Download complete") + } catch (downloadError) { + spinner.stop("Download failed") + prompts.log.error( + `Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`, + ) + prompts.outro("Done") + return + } + + try { + await fs.rename(downloadPath, process.execPath) + prompts.log.success(`Successfully upgraded to ${target}`) + } catch (installError) { + prompts.log.error( + `Install failed: ${installError instanceof Error ? installError.message : String(installError)}`, + ) + // Clean up downloaded file + await fs.unlink(downloadPath).catch(() => {}) + } + + prompts.outro("Done") + }, +} diff --git a/packages/opencode/src/cli/router.ts b/packages/opencode/src/cli/router.ts deleted file mode 100644 index 247b82bc..00000000 --- a/packages/opencode/src/cli/router.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { createCli, type TrpcCliMeta } from "trpc-cli" -import { initTRPC } from "@trpc/server" -import { z } from "zod" -import { Server } from "../server/server" -import { AuthAnthropic } from "../auth/anthropic" -import { UI } from "./ui" -import { App } from "../app/app" -import { Bus } from "../bus" -import { Provider } from "../provider/provider" -import { Session } from "../session" -import { Share } from "../share/share" -import { Message } from "../session/message" -import { VERSION } from "./version" -import { LSP } from "../lsp" -import fs from "fs/promises" -import path from "path" - -const t = initTRPC.meta().create() - -export const router = t.router({ - generate: t.procedure - .meta({ - description: "Generate OpenAPI and event specs", - }) - .input(z.object({})) - .mutation(async () => { - const specs = await Server.openapi() - const dir = "gen" - await fs.rmdir(dir, { recursive: true }).catch(() => {}) - await fs.mkdir(dir, { recursive: true }) - await Bun.write( - path.join(dir, "openapi.json"), - JSON.stringify(specs, null, 2), - ) - return "Generated OpenAPI specs in gen/ directory" - }), - - run: t.procedure - .meta({ - description: "Run OpenCode with a message", - }) - .input( - z.object({ - message: z.array(z.string()).default([]).describe("Message to send"), - session: z.string().optional().describe("Session ID to continue"), - }), - ) - .mutation( - async ({ input }: { input: { message: string[]; session?: string } }) => { - const message = input.message.join(" ") - await App.provide( - { - cwd: process.cwd(), - version: "0.0.0", - }, - async () => { - await Share.init() - const session = input.session - ? await Session.get(input.session) - : await Session.create() - - UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + "◍ OpenCode", VERSION) - UI.empty() - UI.println(UI.Style.TEXT_NORMAL_BOLD + "> ", message) - UI.empty() - UI.println( - UI.Style.TEXT_INFO_BOLD + - "~ https://dev.opencode.ai/s?id=" + - session.id.slice(-8), - ) - UI.empty() - - function printEvent(color: string, type: string, title: string) { - UI.println( - color + `|`, - UI.Style.TEXT_NORMAL + - UI.Style.TEXT_DIM + - ` ${type.padEnd(7, " ")}`, - "", - UI.Style.TEXT_NORMAL + title, - ) - } - - Bus.subscribe(Message.Event.PartUpdated, async (message) => { - const part = message.properties.part - if ( - part.type === "tool-invocation" && - part.toolInvocation.state === "result" - ) { - if (part.toolInvocation.toolName === "opencode_todowrite") - return - - const args = part.toolInvocation.args as any - const tool = part.toolInvocation.toolName - - if (tool === "opencode_edit") - printEvent(UI.Style.TEXT_SUCCESS_BOLD, "Edit", args.filePath) - if (tool === "opencode_bash") - printEvent( - UI.Style.TEXT_WARNING_BOLD, - "Execute", - args.command, - ) - if (tool === "opencode_read") - printEvent(UI.Style.TEXT_INFO_BOLD, "Read", args.filePath) - if (tool === "opencode_write") - printEvent( - UI.Style.TEXT_SUCCESS_BOLD, - "Create", - args.filePath, - ) - if (tool === "opencode_list") - printEvent(UI.Style.TEXT_INFO_BOLD, "List", args.path) - if (tool === "opencode_glob") - printEvent( - UI.Style.TEXT_INFO_BOLD, - "Glob", - args.pattern + (args.path ? " in " + args.path : ""), - ) - } - - if (part.type === "text") { - if (part.text.includes("\n")) { - UI.empty() - UI.println(part.text) - UI.empty() - return - } - printEvent(UI.Style.TEXT_NORMAL_BOLD, "Text", part.text) - } - }) - - const { providerID, modelID } = await Provider.defaultModel() - await Session.chat({ - sessionID: session.id, - providerID, - modelID, - parts: [ - { - type: "text", - text: message, - }, - ], - }) - UI.empty() - }, - ) - return "Session completed" - }, - ), - - scrap: t.procedure - .meta({ - description: "Test command for scraping files", - }) - .input( - z.object({ - file: z.string().describe("File to process"), - }), - ) - .mutation(async ({ input }: { input: { file: string } }) => { - await App.provide({ cwd: process.cwd(), version: VERSION }, async () => { - await LSP.touchFile(input.file, true) - await LSP.diagnostics() - }) - return `Processed file: ${input.file}` - }), - - login: t.router({ - anthropic: t.procedure - .meta({ - description: "Login to Anthropic", - }) - .input(z.object({})) - .mutation(async () => { - const { url, verifier } = await AuthAnthropic.authorize() - - UI.println("Login to Anthropic") - UI.println("Open the following URL in your browser:") - UI.println(url) - UI.println("") - - const code = await UI.input("Paste the authorization code here: ") - await AuthAnthropic.exchange(code, verifier) - return "Successfully logged in to Anthropic" - }), - }), -}) - -export function createOpenCodeCli() { - return createCli({ router }) -} - diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 74f50e34..0a82a00a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -13,6 +13,7 @@ import { VERSION } from "./cli/version" import { ScrapCommand } from "./cli/cmd/scrap" import { Log } from "./util/log" import { AuthCommand, AuthLoginCommand } from "./cli/cmd/auth" +import { UpgradeCommand } from "./cli/cmd/upgrade" import { Provider } from "./provider/provider" import { UI } from "./cli/ui" @@ -33,7 +34,7 @@ const cli = yargs(hideBin(process.argv)) .usage("\n" + UI.logo()) .command({ command: "$0 [project]", - describe: "Start OpenCode TUI", + describe: "Start opencode TUI", builder: (yargs) => yargs.positional("project", { type: "string", @@ -102,6 +103,7 @@ const cli = yargs(hideBin(process.argv)) .command(GenerateCommand) .command(ScrapCommand) .command(AuthCommand) + .command(UpgradeCommand) .fail((msg, err) => { if ( msg.startsWith("Unknown argument") ||