Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
39 changes: 35 additions & 4 deletions packages/opencode/src/installation/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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<Info>
Expand Down Expand Up @@ -164,6 +171,24 @@ const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProcess.Serv
Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })),
)

const upgradeMise = Effect.fnUntraced(function* (target: string) {
const current = yield* run(["mise", "ls", "--current", "--json", "opencode"])
const tools = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.fromJsonString(MiseTool))(current.stdout))
const tool = tools?.length === 1 ? tools[0] : undefined
if (current.code !== 0 || !tool?.source.path || tool.version !== InstallationVersion) {
return yield* new UpgradeFailedError({ stderr: "Could not identify the active mise configuration." })
}

const requested = tool.requested_version
const fuzzy = /^\d+(?:\.\d+)?$/.test(requested)
const version = fuzzy
? /^\d+$/.test(requested)
? target.split(".")[0]
: target.split(".").slice(0, 2).join(".")
: target
return yield* run(["mise", "use", "--path", tool.source.path, fuzzy ? "--fuzzy" : "--pin", `opencode@${version}`])
})

const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
return {
Expand All @@ -172,9 +197,10 @@ const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProcess.Serv
}
}),
method: Effect.fn("Installation.method")(function* () {
const exec = process.execPath.toLowerCase()
const mise = exec.includes(path.join("mise", "installs", "opencode"))
if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method
if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method
const exec = process.execPath.toLowerCase()

const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
Expand All @@ -198,11 +224,13 @@ const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProcess.Serv
const output = yield* check.command()
const installedName =
check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai"
if (output.includes(installedName)) {
if (!mise && output.includes(installedName)) {
return check.name
}
}

// Preserve existing startup ordering so the TUI is subscribed before update events are emitted.
if (mise) return "mise" as Method
return "unknown" as Method
}),
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
Expand Down Expand Up @@ -305,6 +333,9 @@ const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProcess.Serv
case "scoop":
upgradeResult = yield* run(["scoop", "install", `opencode@${target}`])
break
case "mise":
upgradeResult = yield* upgradeMise(target)
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` })
}
Expand Down
97 changes: 96 additions & 1 deletion packages/opencode/test/installation/installation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -182,6 +196,87 @@ 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 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: InstallationVersion,
requested_version: "latest",
source: { path: "/workspace/mise.toml" },
},
])
return ""
},
),
).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.")
}),
)

testEffect(
testLayer(
() => jsonResponse({}),
Expand Down
Loading