From 13a4cd712f2b522e3dc85ac5c24b64249f03b04e Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Tue, 29 Sep 2026 10:59:16 -0500 Subject: [PATCH] fix(v2): load the provider package at the plugin's own version v2 registers the provider as `aisdk:`. The bare `@stablekernel/opencode-cursor` spec resolved to `latest`, so a plugin installed from `@next` loaded the `latest` provider (verified with a clean cache: 0.9.0 was loaded). Pin the spec to the running plugin's version; OPENCODE_CURSOR_PROVIDER_NPM still overrides. v1 is unchanged. --- CHANGELOG.md | 11 +++++++++++ src/plugin/model-v2.ts | 4 ++-- src/plugin/v2.ts | 23 +++++++++++++++++++--- test/plugin-v2.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6cf6d..7d8e4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Fix: opencode v2 loads the provider package at the plugin's own version.** + v2 registered the provider with the bare package name, which opencode + resolves to `latest` — so a plugin installed from `@next` (or any other + channel) silently loaded the `latest` provider instead of the one it + shipped with. The v2 registration now pins the specifier to + `@stablekernel/opencode-cursor@` (falling back to the + bare name only when the version can't be resolved to a valid semver). + `OPENCODE_CURSOR_PROVIDER_NPM` still overrides everything, so local + `file://` builds keep working. v1 is unaffected (its provider is loaded + in-process, not via the package specifier). + ## [0.10.0-next.0] — 2026-09-29 (pre-release) Adds opencode v2 support alongside v1 (#127). Not on `latest`; install with diff --git a/src/plugin/model-v2.ts b/src/plugin/model-v2.ts index 4fdfd53..cb0485a 100644 --- a/src/plugin/model-v2.ts +++ b/src/plugin/model-v2.ts @@ -19,8 +19,8 @@ export const NPM_PACKAGE = "@stablekernel/opencode-cursor"; * `OPENCODE_CURSOR_PROVIDER_NPM` — useful for local development and CI before * the package is published. */ -export function providerNpm(): string { - return process.env.OPENCODE_CURSOR_PROVIDER_NPM?.trim() || NPM_PACKAGE; +export function providerNpm(fallback = NPM_PACKAGE): string { + return process.env.OPENCODE_CURSOR_PROVIDER_NPM?.trim() || fallback; } /** diff --git a/src/plugin/v2.ts b/src/plugin/v2.ts index 45e3614..87daa97 100644 --- a/src/plugin/v2.ts +++ b/src/plugin/v2.ts @@ -48,7 +48,9 @@ import { tool } from "@opencode-ai/plugin"; import { discoverModels } from "../model-discovery.js"; import { defaultModelParams } from "../model-variants.js"; -import { PROVIDER_ID, providerNpm } from "./model-v2.js"; +import { NPM_PACKAGE, PROVIDER_ID, providerNpm } from "./model-v2.js"; +import { getLocalVersion } from "../version-check.js"; +import semver from "semver"; import { resolveContextLimit, resolveOutputLimit, @@ -241,6 +243,21 @@ export type CredentialValueV2 = type Registration = { dispose: () => Promise }; +/** + * The npm specifier v2 registers for the provider package, pinned to this + * plugin's own version: without the pin opencode resolves the bare name to + * `latest`, decoupling the provider from the plugin that shipped it (a + * `@next` plugin would silently load the `latest` provider). Falls back to + * the bare name when the local version is missing or not valid semver. + * `OPENCODE_CURSOR_PROVIDER_NPM` still overrides everything (via + * `providerNpm`). + */ +const pinnedProviderNpm = (() => { + const version = getLocalVersion(); + return version && semver.valid(version) + ? `${NPM_PACKAGE}@${version}` + : NPM_PACKAGE; +})(); /** * Shape one discovered Cursor model as a v2 `Model.Info`. Carries the v1 * variant/param channels on the settings rest so the picker and per-request @@ -288,7 +305,7 @@ function toModelInfo( variants: variantList, time: { released: 0 }, ...(Object.keys(params).length > 0 ? { settings: { params } } : {}), - package: `aisdk:${providerNpm()}`, + package: `aisdk:${providerNpm(pinnedProviderNpm)}`, }; } @@ -344,7 +361,7 @@ export async function cursorV2Setup( id: PROVIDER_ID, name: "Cursor", activation: "auto", - package: `aisdk:${providerNpm()}`, + package: `aisdk:${providerNpm(pinnedProviderNpm)}`, settings: { cwd: resolvedCwd }, }, models: modelInfos, diff --git a/test/plugin-v2.test.ts b/test/plugin-v2.test.ts index a1eb9fd..c676e36 100644 --- a/test/plugin-v2.test.ts +++ b/test/plugin-v2.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, writeFileSync, } from "node:fs"; @@ -94,16 +95,26 @@ function makeCtx(directory = "/work") { return ctx; } +// package.json version, for pin assertions (single source of truth). +const pkgVersion = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +).version as string; + let savedEnvKey: string | undefined; +let savedProviderNpm: string | undefined; beforeEach(() => { savedEnvKey = process.env.CURSOR_API_KEY; delete process.env.CURSOR_API_KEY; + savedProviderNpm = process.env.OPENCODE_CURSOR_PROVIDER_NPM; + delete process.env.OPENCODE_CURSOR_PROVIDER_NPM; }); afterEach(() => { if (savedEnvKey === undefined) delete process.env.CURSOR_API_KEY; else process.env.CURSOR_API_KEY = savedEnvKey; + if (savedProviderNpm === undefined) delete process.env.OPENCODE_CURSOR_PROVIDER_NPM; + else process.env.OPENCODE_CURSOR_PROVIDER_NPM = savedProviderNpm; }); describe("dual default export", () => { @@ -178,7 +189,9 @@ describe("cursorV2Setup", () => { }; expect(info["id"]).toBe("cursor"); expect(info["name"]).toBe("Cursor"); - expect(String(info["package"])).toMatch(/^aisdk:/); + expect(String(info["package"])).toBe( + `aisdk:@stablekernel/opencode-cursor@${pkgVersion}`, + ); expect(info["activation"]).toBe("auto"); // The provider settings carry the session directory so the v2 runner // hands createCursor the right cwd (it may run from anywhere). @@ -186,6 +199,34 @@ describe("cursorV2Setup", () => { const model = models.find((m) => m.id === "test-model"); expect(model).toBeDefined(); expect(model?.providerID).toBe("cursor"); + // Every model carries the same pinned specifier (opencode loads the + // provider package per model, so an unpinned one would resolve + // `latest` and decouple the provider from this plugin's version). + for (const m of models) { + expect(m["package"]).toBe( + `aisdk:@stablekernel/opencode-cursor@${pkgVersion}`, + ); + } + }); + + it("honors OPENCODE_CURSOR_PROVIDER_NPM over the pinned spec", async () => { + process.env.OPENCODE_CURSOR_PROVIDER_NPM = + "file:///tmp/opencode-cursor-test-build"; + const ctx = makeCtx(); + await cursorV2Setup(ctx as never); + const cb = ctx.provider.transform.mock.calls[0]![0] as (editor: { + add: (input: unknown) => void; + }) => void; + const add = vi.fn(); + cb({ add }); + const { info, models } = add.mock.calls[0]![0] as { + info: Record; + models: Array>; + }; + expect(info["package"]).toBe("aisdk:file:///tmp/opencode-cursor-test-build"); + for (const m of models) { + expect(m["package"]).toBe("aisdk:file:///tmp/opencode-cursor-test-build"); + } }); it("emits defaultModelParams as the model's settings.params", async () => {