Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
version: 1
delivery: fix-opencode-auto
context:
kind: branch
branch: feat/351-fix-opencode-auto
issues:
- 351
2 changes: 1 addition & 1 deletion packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(Schema.optional)
.annotate({
description: "Automatically update or notify when a new version is available",
description: "Notify when a new fork version is available on GitHub releases. Automatic updates are disabled; set to false to disable the notification",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
Expand Down
51 changes: 12 additions & 39 deletions packages/opencode/src/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,22 @@ import { Installation } from "@/installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { GlobalBus } from "@/bus/global"

// This fork never auto-updates: it only checks the fork's GitHub releases and
// notifies. `autoupdate: false` (or OPENCODE_DISABLE_AUTOUPDATE) silences the
// notification entirely.
export async function upgrade() {
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
const latest = await Installation.latest().catch(() => {})
if (!latest) return

if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
return
}
if (!Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE && InstallationVersion === latest) return

if (InstallationVersion === latest) return

const kind = Installation.getReleaseType(InstallationVersion, latest)

if (config.autoupdate === "notify" || kind !== "patch") {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
return
}

if (method === "unknown") return
await Installation.upgrade(method, latest)
.then(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: latest },
},
}),
)
.catch(() => {})
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.UpdateAvailable.type,
properties: { version: latest },
},
})
}
71 changes: 9 additions & 62 deletions packages/opencode/src/installation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import path from "path"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
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"
Expand Down Expand Up @@ -59,22 +58,18 @@ export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedErr
}
}

// This fork distributes exclusively through GitHub releases on its own repo;
// official npm/brew/choco/scoop channels never carry fork versions.
const ReleaseRepo = "LeXwDeX/OpenCode-GraphAgent"
const ReleaseTagPrefix = "graphagent-v"

// Response schemas for external version APIs
const GitHubRelease = Schema.Struct({ tag_name: Schema.String })
const NpmPackage = Schema.Struct({ version: Schema.String })
const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })
const BrewInfoV2 = Schema.Struct({
formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })),
})
const ChocoPackage = Schema.Struct({
d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }),
})
const ScoopManifest = NpmPackage

export interface Interface {
readonly info: () => Effect.Effect<Info>
readonly method: () => Effect.Effect<Method>
readonly latest: (method?: Method) => Effect.Effect<string>
readonly latest: () => Effect.Effect<string>
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
}

Expand Down Expand Up @@ -204,62 +199,14 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce

return "unknown" as Method
}),
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* result.method())

if (detectedMethod === "brew") {
const formula = yield* getBrewFormula()
if (formula.includes("/")) {
const infoJson = yield* text(["brew", "info", "--json=v2", formula])
const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson)
return info.formulae[0].versions.stable
}
const response = yield* httpOk.execute(
HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response)
return data.versions.stable
}

if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
`${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`,
).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}

if (detectedMethod === "choco") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response)
return data.d.results[0].Version
}

if (detectedMethod === "scoop") {
const response = yield* httpOk.execute(
HttpClientRequest.get(
"https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json",
).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })),
)
const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response)
return data.version
}

latest: Effect.fn("Installation.latest")(function* () {
const response = yield* httpOk.execute(
HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe(
HttpClientRequest.get(`https://api.github.com/repos/${ReleaseRepo}/releases/latest`).pipe(
HttpClientRequest.acceptJson,
),
)
const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response)
return data.tag_name.replace(/^v/, "")
return data.tag_name.replace(new RegExp(`^${ReleaseTagPrefix}`), "")
}, Effect.orDie),
upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let upgradeResult: { code: number; stdout: string; stderr: string } | undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
body: { success: false as const, error: "Unknown installation method" },
}
}
const target = ctx.payload.target || (yield* installation.latest(method))
const target = ctx.payload.target || (yield* installation.latest())
const result = yield* installation.upgrade(method, target).pipe(
Effect.as({ status: 200, body: { success: true as const, version: target } }),
Effect.catch((err) =>
Expand Down
112 changes: 11 additions & 101 deletions packages/opencode/test/installation/installation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 { AppProcess } from "@opencode-ai/core/process"
import { testEffect } from "../lib/effect"

Expand Down Expand Up @@ -58,117 +57,28 @@ function testLayer(

describe("installation", () => {
describe("latest", () => {
testEffect(testLayer(() => jsonResponse({ tag_name: "v1.2.3" }))).effect(
"reads release version from GitHub releases",
() =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("unknown")
expect(result).toBe("1.2.3")
}),
)

testEffect(testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" }))).effect(
"strips v prefix from GitHub release tag",
() =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("curl")
expect(result).toBe("4.0.0-beta.1")
}),
)

const npmCalls: string[] = []
testEffect(
testLayer((request) => {
npmCalls.push(request.url)
return jsonResponse({ version: "1.5.0" })
}),
).effect("reads npm versions via registry", () =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("npm")
expect(result).toBe("1.5.0")
expect(npmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
}),
)

const bunCalls: string[] = []
testEffect(
testLayer((request) => {
bunCalls.push(request.url)
return jsonResponse({ version: "1.6.0" })
}),
).effect("reads bun versions via registry", () =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("bun")
expect(result).toBe("1.6.0")
expect(bunCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
}),
)

const pnpmCalls: string[] = []
const urls: string[] = []
testEffect(
testLayer((request) => {
pnpmCalls.push(request.url)
return jsonResponse({ version: "1.7.0" })
urls.push(request.url)
return jsonResponse({ tag_name: "graphagent-v1.2.3" })
}),
).effect("reads pnpm versions via registry", () =>
).effect("reads release version from the fork GitHub releases", () =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("pnpm")
expect(result).toBe("1.7.0")
expect(pnpmCalls).toContain(`https://registry.npmjs.org/opencode-ai/${InstallationChannel}`)
const result = yield* Installation.use.latest()
expect(result).toBe("1.2.3")
expect(urls).toContain("https://api.github.com/repos/LeXwDeX/OpenCode-GraphAgent/releases/latest")
}),
)

testEffect(testLayer(() => jsonResponse({ version: "2.3.4" }))).effect("reads scoop manifest versions", () =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("scoop")
expect(result).toBe("2.3.4")
}),
)

testEffect(testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } }))).effect(
"reads chocolatey feed versions",
testEffect(testLayer(() => jsonResponse({ tag_name: "graphagent-v4.0.0-beta.1" }))).effect(
"strips the graphagent-v prefix from release tags",
() =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("choco")
expect(result).toBe("3.4.5")
const result = yield* Installation.use.latest()
expect(result).toBe("4.0.0-beta.1")
}),
)

testEffect(
testLayer(
() => jsonResponse({ versions: { stable: "2.0.0" } }),
(cmd, args) => {
// getBrewFormula: return core formula (no tap)
if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return ""
if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode"
return ""
},
),
).effect("reads brew formulae API versions", () =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("brew")
expect(result).toBe("2.0.0")
}),
)

const brewInfoJson = JSON.stringify({
formulae: [{ versions: { stable: "2.1.0" } }],
})
testEffect(
testLayer(
() => jsonResponse({}), // HTTP not used for tap formula
(cmd, args) => {
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
return ""
},
),
).effect("reads brew tap info JSON via CLI", () =>
Effect.gen(function* () {
const result = yield* Installation.use.latest("brew")
expect(result).toBe("2.1.0")
}),
)
})

describe("upgrade", () => {
Expand Down
Loading