From 94399585e39c5bd7ba462b4e4ae3e4fe9246aac5 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 17 Jun 2026 11:18:54 -0400 Subject: [PATCH] feat: warn when installed plugin lags registry latest opencode caches the @latest plugin install on first use and never re-resolves the dist tag, so users silently stay on stale releases. On plugin init, fetch the registry's latest version (throttled to once per 24h via ~/.cache/opencode-cursor/version-check.json) and print a one-time warning with the cache dir to delete and restart. --- README.md | 24 +++++-- package-lock.json | 23 ++++++- package.json | 4 +- src/plugin/index.ts | 6 ++ src/version-check.ts | 130 +++++++++++++++++++++++++++++++++++++ test/version-check.test.ts | 114 ++++++++++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 src/version-check.ts create mode 100644 test/version-check.test.ts diff --git a/README.md b/README.md index 228ead0..a58d687 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,21 @@ Add to your `opencode.json` (or `opencode.jsonc` — both are supported): ``` The `@latest` suffix makes opencode re-resolve to the newest release on each -startup. Drop it (`"@stablekernel/opencode-cursor"`) or pin a version -(`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer. +startup. In practice opencode caches the `@latest` plugin install and does **not** +auto-update it. If you see a stale-version warning from the plugin, or a version +mismatch, exit opencode and clear the cached package: + +```bash +# macOS / Linux +rm -rf ~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest +# Windows +rmdir /s "%LocalAppData%\opencode\cache\packages\@stablekernel\opencode-cursor@latest" +``` + +Then restart opencode. + +Drop `@latest` (`"@stablekernel/opencode-cursor"`) or pin a version +(`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer deterministic installs. The plugin injects the `provider` block automatically. If you need explicit control: @@ -267,9 +280,10 @@ Override with `OPENCODE_CURSOR_SIDECAR=1` (always sidecar) or `OPENCODE_CURSOR_S on your `PATH`. Install Node.js 22+, or force the sidecar with `OPENCODE_CURSOR_SIDECAR=1`. - **"Running under Bun without a usable Node sidecar" warning.** Install Node.js 22+, or set `OPENCODE_CURSOR_SIDECAR=0` to accept in-process behavior and silence the warning. -- **Plugin enabled but no `cursor` provider/models appear.** Stale opencode plugin cache. Pin an - exact version (`@stablekernel/opencode-cursor@`) or delete - `~/.cache/opencode/packages/` and restart. +- **Plugin enabled but no `cursor` provider/models appear, or you see a stale-version warning.** + opencode caches the `@latest` plugin install on first use and never refreshes it. + Exit opencode, delete `~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest` + (or the pinned version directory), and restart. - **Only the four fallback models appear.** The live catalog loads after the first authenticated use. Restart opencode once after login, or run `cursor_refresh_models`. - **Invalid or expired key.** Validated on first use — that's where the error surfaces. diff --git a/package-lock.json b/package-lock.json index 011313f..9cdc8c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,14 @@ "license": "MIT", "dependencies": { "@cursor/sdk": "^1.0.18", - "@opencode-ai/plugin": "^1.17.7" + "@opencode-ai/plugin": "^1.17.7", + "semver": "^7.8.4" }, "devDependencies": { "@ai-sdk/provider": "^3.0.10", "@opencode-ai/sdk": "^1.17.1", "@types/node": "^25.9.2", + "@types/semver": "^7.7.1", "tsup": "^8.5.1", "typescript": "^6.0.3", "vitest": "^4.1.8" @@ -1573,6 +1575,13 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -2731,6 +2740,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 57ec740..286cd92 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,8 @@ }, "dependencies": { "@cursor/sdk": "^1.0.18", - "@opencode-ai/plugin": "^1.17.7" + "@opencode-ai/plugin": "^1.17.7", + "semver": "^7.8.4" }, "overrides": { "undici": "^6.24.0", @@ -70,6 +71,7 @@ "@ai-sdk/provider": "^3.0.10", "@opencode-ai/sdk": "^1.17.1", "@types/node": "^25.9.2", + "@types/semver": "^7.7.1", "tsup": "^8.5.1", "typescript": "^6.0.3", "vitest": "^4.1.8" diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 644ce51..8db3cdf 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -10,6 +10,7 @@ import { translateMcpServers, } from "./mcp-config.js"; import { buildCursorTools } from "./cursor-tools.js"; +import { warnIfStale } from "../version-check.js"; function apiKeyFromAuth(auth: Auth | undefined): string | undefined { return auth?.type === "api" ? auth.key : undefined; @@ -28,6 +29,11 @@ function apiKeyFromAuth(auth: Auth | undefined): string | undefined { * - `tool.cursor_refresh_models`: force-refresh the model catalog. */ export const CursorPlugin: Plugin = async (input) => { + // Warn once per day if the installed plugin is behind the npm `latest` tag. + // opencode freezes `@latest` plugin installs on first use, so this keeps + // users from silently running stale releases. + void warnIfStale(); + // The Cursor API key resolved by opencode's auth loader, captured so the // delegation tools (which don't receive auth directly) can reuse it. Falls // back to the CURSOR_API_KEY env var when the loader hasn't run. diff --git a/src/version-check.ts b/src/version-check.ts new file mode 100644 index 0000000..32647ce --- /dev/null +++ b/src/version-check.ts @@ -0,0 +1,130 @@ +import { createRequire } from "node:module"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { get } from "node:https"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import semver from "semver"; + +const PACKAGE_NAME = "@stablekernel/opencode-cursor"; +const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`; +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 5000; + +interface VersionCheckCache { + checkedAt: number; + latest: string | undefined; +} + +function cacheDir(): string { + const base = + process.env.XDG_CACHE_HOME?.trim() || + (homedir() ? join(homedir(), ".cache") : tmpdir()); + return join(base, "opencode-cursor"); +} + +function cacheFile(): string { + return join(cacheDir(), "version-check.json"); +} + +function readCache(): VersionCheckCache | undefined { + try { + const parsed = JSON.parse(readFileSync(cacheFile(), "utf8")) as VersionCheckCache; + if (typeof parsed.checkedAt === "number") return parsed; + } catch { + // ignore + } + return undefined; +} + +function writeCache(latest: string | undefined): void { + try { + mkdirSync(cacheDir(), { recursive: true }); + writeFileSync( + cacheFile(), + JSON.stringify({ checkedAt: Date.now(), latest }), + "utf8", + ); + } catch { + // Best-effort; never block plugin init. + } +} + +function getLocalVersion(): string | undefined { + try { + const require = createRequire(import.meta.url); + const pkg = require("../package.json") as { version: string }; + return pkg.version; + } catch { + return undefined; + } +} + +function fetchLatestVersion(): Promise { + return new Promise((resolve) => { + const req = get( + REGISTRY_URL, + { headers: { Accept: "application/json", Connection: "close" } }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk: string) => { + body += chunk; + }); + res.on("end", () => { + try { + const parsed = JSON.parse(body) as { version?: string }; + resolve(parsed.version); + } catch { + resolve(undefined); + } + }); + res.on("error", () => resolve(undefined)); + }, + ); + req.setTimeout(REQUEST_TIMEOUT_MS, () => { + req.destroy(); + resolve(undefined); + }); + req.on("error", () => resolve(undefined)); + }); +} + +/** Return the cached latest version if fresh, else fetch from npm. */ +async function getLatestVersion(): Promise { + const cached = readCache(); + if (cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) { + return cached.latest; + } + const latest = await fetchLatestVersion(); + writeCache(latest); + return latest; +} + +/** + * Print a one-time warning when this installed plugin is older than the + * registry's `latest` tag. opencode resolves `@latest` once and then never + * reinstalls the plugin, so users can silently stay on old versions. This + * surfaces the staleness with actionable instructions. + * + * The check is throttled to once per 24h so plugin startup stays fast and + * doesn't hit the registry repeatedly. + */ +export async function warnIfStale(): Promise { + const local = getLocalVersion(); + if (!local) return; + const latest = await getLatestVersion(); + if (!latest) return; + if (!semver.gt(latest, local)) return; + + const cacheSpec = process.platform === "win32" + ? `%LocalAppData%\\opencode\\cache\\packages\\${PACKAGE_NAME}@latest` + : `~/.cache/opencode/packages/${PACKAGE_NAME}@latest`; + + console.warn( + `\n⚠️ @stablekernel/opencode-cursor update available: v${local} → v${latest}.\n` + + ` opencode caches the @latest plugin on first install and never auto-updates it.\n` + + ` To upgrade, exit opencode, run:\n\n` + + ` rm -rf ${cacheSpec}\n\n` + + ` then restart opencode.\n`, + ); +} diff --git a/test/version-check.test.ts b/test/version-check.test.ts new file mode 100644 index 0000000..b9ae038 --- /dev/null +++ b/test/version-check.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { join } from "node:path"; + +const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + +const fsState: Record = {}; +let lastRequestUrl: string | undefined; +let requestHandlers: { + onResponse?: (res: MockResponse) => void; + onError?: () => void; + onTimeout?: () => void; +} = {}; + +class MockResponse extends EventEmitter { + setEncoding = vi.fn(); + statusCode = 200; +} + +class MockRequest extends EventEmitter { + setTimeout = vi.fn((_ms: number, cb: () => void) => { + requestHandlers.onTimeout = cb; + }); + destroy = vi.fn(); +} + +const get = vi.fn((_url: string, _opts: object, cb: (res: MockResponse) => void) => { + const req = new MockRequest(); + requestHandlers.onResponse = (res) => cb(res); + return req; +}); + +vi.mock("node:https", () => ({ get })); + +vi.mock("node:fs", () => ({ + mkdirSync: vi.fn((_path: string, _opts?: object) => {}), + readFileSync: vi.fn((path: string, _encoding: string) => { + if (fsState[path]) return fsState[path]; + throw new Error("ENOENT"); + }), + writeFileSync: vi.fn((path: string, data: string, _encoding: string) => { + fsState[path] = data; + }), + rmSync: vi.fn(), +})); + +const { warnIfStale } = await import("../src/version-check.js"); + +function respondWith(version: string | undefined) { + const res = new MockResponse(); + process.nextTick(() => { + if (version === undefined) { + res.emit("end"); + } else { + res.emit("data", JSON.stringify({ version })); + res.emit("end"); + } + }); + requestHandlers.onResponse?.(res); +} + +describe("warnIfStale", () => { + beforeEach(() => { + consoleWarn.mockClear(); + Object.keys(fsState).forEach((k) => delete fsState[k]); + lastRequestUrl = undefined; + requestHandlers = {}; + get.mockClear(); + }); + + afterEach(() => { + consoleWarn.mockReset(); + }); + + it("warns when registry latest is newer than local version", async () => { + const promise = warnIfStale(); + respondWith("0.5.0"); + await promise; + expect(consoleWarn).toHaveBeenCalledOnce(); + expect(String(consoleWarn.mock.calls[0]?.[0])).toContain("0.5.0"); + }); + + it("does not warn when local version equals latest", async () => { + const promise = warnIfStale(); + respondWith("0.4.1"); + await promise; + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("does not warn when local version is newer", async () => { + const promise = warnIfStale(); + respondWith("0.3.0"); + await promise; + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("does not warn when registry fetch fails", async () => { + const promise = warnIfStale(); + const res = new MockResponse(); + requestHandlers.onResponse?.(res); + process.nextTick(() => res.emit("error", new Error("network"))); + await promise; + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("uses cached result within 24h", async () => { + // Seed cache with latest=9.9.9 + fsState[join(process.env.HOME || "/tmp", ".cache/opencode-cursor/version-check.json")] = + JSON.stringify({ checkedAt: Date.now(), latest: "9.9.9" }); + await warnIfStale(); + expect(get).not.toHaveBeenCalled(); + expect(consoleWarn).toHaveBeenCalledOnce(); + }); +});