diff --git a/packages/core/src/installation/version.ts b/packages/core/src/installation/version.ts index 25d9cd9..192f77f 100644 --- a/packages/core/src/installation/version.ts +++ b/packages/core/src/installation/version.ts @@ -6,3 +6,17 @@ declare global { export const InstallationVersion = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const InstallationChannel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" export const InstallationLocal = InstallationChannel === "local" + +/** + * Version pin for installing `@opencode-ai/plugin` on non-local builds. + * + * npm-package-arg treats any string containing `/` as a github `owner/repo` + * remote. Preview builds stamped from slash-containing branch names + * (`0.0.0-agent/m4-…`) therefore hang on `git ls-remote` during config + * dependency install. Skip the pin when the version is not npm-safe. + */ +export function pluginDependencyVersion(version: string | undefined = InstallationVersion) { + if (!version) return + if (version.includes("/")) return + return version +} diff --git a/packages/core/test/installation-version.test.ts b/packages/core/test/installation-version.test.ts new file mode 100644 index 0000000..4e833e2 --- /dev/null +++ b/packages/core/test/installation-version.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import npa from "npm-package-arg" +import { pluginDependencyVersion } from "../src/installation/version" + +describe("pluginDependencyVersion", () => { + test("pins ordinary semver and preview versions", () => { + expect(pluginDependencyVersion("1.2.3")).toBe("1.2.3") + expect(pluginDependencyVersion("0.0.0-agent-m4-verdict-202607080429")).toBe( + "0.0.0-agent-m4-verdict-202607080429", + ) + }) + + test("skips slash-containing versions that npm-package-arg treats as github remotes", () => { + const bad = "0.0.0-agent/m4-verdict-opencode-runtime-202607080429" + // Documents the hang class: npa rewrites the pin into github:user/project + // and arborist then blocks on git ls-remote. + const parsed = npa(`@opencode-ai/plugin@${bad}`) + expect(parsed.type).toBe("git") + expect(parsed.hosted?.user).toBe("0.0.0-agent") + expect(parsed.hosted?.project).toBe("m4-verdict-opencode-runtime-202607080429") + + expect(pluginDependencyVersion(bad)).toBeUndefined() + expect(pluginDependencyVersion("")).toBeUndefined() + }) + + test("sanitized branch-style versions stay npm version pins", () => { + const good = "0.0.0-agent-m4-verdict-opencode-runtime-202607080429" + const parsed = npa(`@opencode-ai/plugin@${good}`) + expect(parsed.type).toBe("version") + expect(pluginDependencyVersion(good)).toBe(good) + }) +}) diff --git a/packages/core/test/plugin-dependency-version.test.ts b/packages/core/test/plugin-dependency-version.test.ts new file mode 100644 index 0000000..612d268 --- /dev/null +++ b/packages/core/test/plugin-dependency-version.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { pluginDependencyVersion } from "../src/installation/version" + +describe("pluginDependencyVersion", () => { + test("returns npm-safe versions unchanged", () => { + expect(pluginDependencyVersion("1.2.3")).toBe("1.2.3") + expect(pluginDependencyVersion("0.0.0-main-202607050502")).toBe("0.0.0-main-202607050502") + expect(pluginDependencyVersion("local")).toBe("local") + }) + + test("skips slash-containing versions that npm treats as github owner/repo", () => { + // Branch-stamped preview builds like 0.0.0-agent/m4-… hang on git ls-remote + // when npm-package-arg parses the version as a remote. + expect(pluginDependencyVersion("0.0.0-agent/m4-verdict-opencode-runtime-202607080429")).toBeUndefined() + expect(pluginDependencyVersion("agent/m4-feature")).toBeUndefined() + }) + + test("skips empty string versions", () => { + expect(pluginDependencyVersion("")).toBeUndefined() + }) +} +) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 35bd2e0..eb4bde3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -11,7 +11,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" -import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { InstallationLocal, pluginDependencyVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { Account } from "@/account/account" import { isRecord } from "@/util/record" @@ -439,7 +439,7 @@ const layer = Layer.effect( add: [ { name: "@opencode-ai/plugin", - version: InstallationLocal ? undefined : InstallationVersion, + version: InstallationLocal ? undefined : pluginDependencyVersion(), }, ], }) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index 8f50375..a1b262f 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -16,7 +16,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { CurrentWorkingDirectory } from "./tui-cwd" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "@opencode-ai/tui/config/keybind" -import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" +import { InstallationLocal, pluginDependencyVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import { ConfigVariable } from "@/config/variable" @@ -239,7 +239,7 @@ const layer = Layer.effect( add: [ { name: "@opencode-ai/plugin", - version: InstallationLocal ? undefined : InstallationVersion, + version: InstallationLocal ? undefined : pluginDependencyVersion(), }, ], }) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index d148ce0..fe6a03c 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -23,29 +23,39 @@ const env = { OPENCODE_VERSION: process.env["OPENCODE_VERSION"], OPENCODE_RELEASE: process.env["OPENCODE_RELEASE"], } -const CHANNEL = await (async () => { - if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL - if (env.OPENCODE_BUMP) return "latest" - if (env.OPENCODE_VERSION && !env.OPENCODE_VERSION.startsWith("0.0.0-")) return "latest" - return await $`git branch --show-current`.text().then((x) => x.trim()) -})() +// npm-package-arg treats "owner/repo" (and version strings containing "/") as +// github remotes and spawns blocking `git ls-remote`. Branch names like +// agent/m4-… must not be embedded raw into OPENCODE_CHANNEL / OPENCODE_VERSION. +function sanitizeNpmLabel(value: string) { + return value.replaceAll("/", "-") +} +const CHANNEL = sanitizeNpmLabel( + await (async () => { + if (env.OPENCODE_CHANNEL) return env.OPENCODE_CHANNEL + if (env.OPENCODE_BUMP) return "latest" + if (env.OPENCODE_VERSION && !env.OPENCODE_VERSION.startsWith("0.0.0-")) return "latest" + return await $`git branch --show-current`.text().then((x) => x.trim()) + })(), +) const IS_PREVIEW = CHANNEL !== "latest" -const VERSION = await (async () => { - if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION - if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` - const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - const [major, minor, patch] = version.split(".").map((x: string) => Number(x) || 0) - const t = env.OPENCODE_BUMP?.toLowerCase() - if (t === "major") return `${major + 1}.0.0` - if (t === "minor") return `${major}.${minor + 1}.0` - return `${major}.${minor}.${patch + 1}` -})() +const VERSION = sanitizeNpmLabel( + await (async () => { + if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION + if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` + const version = await fetch("https://registry.npmjs.org/opencode-ai/latest") + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.version) + const [major, minor, patch] = version.split(".").map((x: string) => Number(x) || 0) + const t = env.OPENCODE_BUMP?.toLowerCase() + if (t === "major") return `${major + 1}.0.0` + if (t === "minor") return `${major}.${minor + 1}.0` + return `${major}.${minor}.${patch + 1}` + })(), +) const bot = ["actions-user", "opencode", "opencode-agent[bot]"] const teamPath = path.resolve(import.meta.dir, "../../../.github/TEAM_MEMBERS")