From 4176903a1b15e80c13893c862bedfa4f8bf396cc Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:58:20 -0700 Subject: [PATCH 1/4] feat(opencode): support mise-managed upgrades --- packages/opencode/src/cli/cmd/upgrade.ts | 2 +- packages/opencode/src/installation/index.ts | 48 +++++++++++++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 3c1604a0b835..2f878de0b782 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -17,7 +17,7 @@ export const UpgradeCommand = { alias: "m", describe: "installation method to use", type: "string", - choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"], + choices: ["curl", "npm", "pnpm", "bun", "brew", "choco", "scoop", "mise"], }) }, handler: async (args: { target?: string; method?: string }) => { diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 4300220255b3..57a7c761ae13 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,7 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { Effect, Layer, Schema, Context, Stream } from "effect" +import { Effect, Layer, Schema, Context, Stream, Option } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" @@ -15,7 +15,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { NpmConfig } from "@opencode-ai/core/npm-config" import { InstallationEvent } from "@opencode-ai/schema/installation-event" -export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" +export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "mise" | "unknown" export type ReleaseType = "patch" | "minor" | "major" @@ -71,6 +71,13 @@ const ChocoPackage = Schema.Struct({ d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), }) const ScoopManifest = NpmPackage +const MiseTool = Schema.Array( + Schema.Struct({ + version: Schema.String, + requested_version: Schema.String, + source: Schema.Struct({ path: Schema.String }), + }), +) export interface Interface { readonly info: () => Effect.Effect @@ -164,6 +171,34 @@ const layer: Layer.Layer new UpgradeFailedError({ stderr: upgradeFailure("curl") })), ) + const upgradeMise = Effect.fnUntraced(function* (target: string) { + const current = yield* run(["mise", "ls", "--current", "--json", "opencode"]) + const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(MiseTool))(current.stdout).pipe( + Effect.option, + ) + const tool = Option.getOrUndefined(decoded)?.[0] + if (current.code !== 0 || !tool?.source.path || tool.version !== InstallationVersion) { + return yield* run(["mise", "install", `opencode@${target}`]) + } + + const requested = tool.requested_version + const version = semver.valid(requested) + ? target + : /^\d+$/.test(requested) + ? target.split(".")[0] + : /^\d+\.\d+$/.test(requested) + ? target.split(".").slice(0, 2).join(".") + : requested + return yield* run([ + "mise", + "use", + "--path", + tool.source.path, + semver.valid(requested) ? "--pin" : "--fuzzy", + `opencode@${version}`, + ]) + }) + const result: Interface = { info: Effect.fn("Installation.info")(function* () { return { @@ -172,9 +207,10 @@ const layer: Layer.Layer Effect.Effect }> = [ { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, @@ -198,11 +234,12 @@ const layer: Layer.Layer Date: Sun, 12 Jul 2026 14:58:25 -0700 Subject: [PATCH 2/4] test(opencode): cover mise upgrade handling --- .../test/installation/installation.test.ts | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 3d4b4cba31a6..790cef291423 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -6,9 +6,10 @@ import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation" -import { InstallationChannel } from "@opencode-ai/core/installation/version" +import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" +import path from "path" const encoder = new TextEncoder() @@ -67,6 +68,19 @@ function testLayer( } describe("installation", () => { + describe("method", () => { + testEffect(testLayer(() => jsonResponse({}))).effect("detects mise-managed executables", () => + Effect.gen(function* () { + const execPath = process.execPath + process.execPath = path.join("home", ".local", "share", "mise", "installs", "opencode", "1.2.3", "opencode") + const method = yield* Installation.use + .method() + .pipe(Effect.ensuring(Effect.sync(() => (process.execPath = execPath)))) + expect(method).toBe("mise") + }), + ) + }) + describe("latest", () => { testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect( "reads release version from GitHub releases", @@ -182,6 +196,58 @@ describe("installation", () => { }) describe("upgrade", () => { + const miseCalls: Array<{ cmd: string; args: readonly string[] }> = [] + testEffect( + testLayer( + () => jsonResponse({}), + (cmd, args) => { + miseCalls.push({ cmd, args }) + if (cmd === "mise" && args[0] === "ls") + return JSON.stringify([ + { + version: InstallationVersion, + requested_version: "1.2", + source: { path: "/workspace/parent/mise.staging.toml" }, + }, + ]) + return "" + }, + ), + ).effect("updates the active mise source while preserving a fuzzy pin", () => + Effect.gen(function* () { + yield* Installation.use.upgrade("mise", "9.9.9") + expect(miseCalls).toContainEqual({ + cmd: "mise", + args: ["use", "--path", "/workspace/parent/mise.staging.toml", "--fuzzy", "opencode@9.9"], + }) + }), + ) + + const miseExecCalls: Array<{ cmd: string; args: readonly string[] }> = [] + testEffect( + testLayer( + () => jsonResponse({}), + (cmd, args) => { + miseExecCalls.push({ cmd, args }) + if (cmd === "mise" && args[0] === "ls") + return JSON.stringify([ + { + version: "1.0.0", + requested_version: "1.0.0", + source: { path: "/workspace/mise.toml" }, + }, + ]) + return "" + }, + ), + ).effect("only installs when mise exec selected a version different from config", () => + Effect.gen(function* () { + yield* Installation.use.upgrade("mise", "9.9.9") + expect(miseExecCalls).toContainEqual({ cmd: "mise", args: ["install", "opencode@9.9.9"] }) + expect(miseExecCalls.some((call) => call.cmd === "mise" && call.args[0] === "use")).toBeFalse() + }), + ) + testEffect( testLayer( () => jsonResponse({}), From 6d15ef435ba3243fe6754d6783c80b6959b16055 Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:27:48 -0700 Subject: [PATCH 3/4] fix(opencode): fail ambiguous mise upgrades --- packages/opencode/src/installation/index.ts | 10 +++++----- .../opencode/test/installation/installation.test.ts | 9 +++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 57a7c761ae13..5d3cd1f3b714 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -173,12 +173,11 @@ const layer: Layer.Layer { }), ) - const miseExecCalls: Array<{ cmd: string; args: readonly string[] }> = [] testEffect( testLayer( () => jsonResponse({}), (cmd, args) => { - miseExecCalls.push({ cmd, args }) if (cmd === "mise" && args[0] === "ls") return JSON.stringify([ { @@ -240,11 +238,10 @@ describe("installation", () => { return "" }, ), - ).effect("only installs when mise exec selected a version different from config", () => + ).effect("fails when mise exec selected a version different from config", () => Effect.gen(function* () { - yield* Installation.use.upgrade("mise", "9.9.9") - expect(miseExecCalls).toContainEqual({ cmd: "mise", args: ["install", "opencode@9.9.9"] }) - expect(miseExecCalls.some((call) => call.cmd === "mise" && call.args[0] === "use")).toBeFalse() + const error = yield* Effect.flip(Installation.use.upgrade("mise", "9.9.9")) + expect(error.stderr).toBe("Could not identify the active mise configuration.") }), ) From 686f2ab1d0269429b3f513df49d7ecc8d233d1a4 Mon Sep 17 00:00:00 2001 From: Shane Bishop <71288697+shanebishop1@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:40:51 -0700 Subject: [PATCH 4/4] fix(opencode): validate mise upgrade targets --- packages/opencode/src/installation/index.ts | 25 ++++-------- .../test/installation/installation.test.ts | 38 +++++++++++++++++-- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 5d3cd1f3b714..561893edc652 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -173,29 +173,20 @@ const layer: Layer.Layer { }), ) + const miseAliasCalls: Array<{ cmd: string; args: readonly string[] }> = [] testEffect( testLayer( () => jsonResponse({}), (cmd, args) => { + miseAliasCalls.push({ cmd, args }) if (cmd === "mise" && args[0] === "ls") return JSON.stringify([ { - version: "1.0.0", - requested_version: "1.0.0", + version: InstallationVersion, + requested_version: "latest", source: { path: "/workspace/mise.toml" }, }, ]) return "" }, ), - ).effect("fails when mise exec selected a version different from config", () => + ).effect("honors the target when mise config uses an alias", () => + Effect.gen(function* () { + yield* Installation.use.upgrade("mise", "9.9.9") + expect(miseAliasCalls).toContainEqual({ + cmd: "mise", + args: ["use", "--path", "/workspace/mise.toml", "--pin", "opencode@9.9.9"], + }) + }), + ) + + testEffect( + testLayer( + () => jsonResponse({}), + (cmd, args) => { + if (cmd === "mise" && args[0] === "ls") + return JSON.stringify([ + { + version: InstallationVersion, + requested_version: InstallationVersion, + source: { path: "/workspace/mise.toml" }, + }, + { + version: InstallationVersion, + requested_version: InstallationVersion, + source: { path: "/workspace/other.toml" }, + }, + ]) + return "" + }, + ), + ).effect("fails when mise reports multiple active configurations", () => Effect.gen(function* () { const error = yield* Effect.flip(Installation.use.upgrade("mise", "9.9.9")) expect(error.stderr).toBe("Could not identify the active mise configuration.")