Skip to content

Commit 9439958

Browse files
committed
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.
1 parent 6ce8d19 commit 9439958

6 files changed

Lines changed: 294 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,21 @@ Add to your `opencode.json` (or `opencode.jsonc` — both are supported):
5151
```
5252

5353
The `@latest` suffix makes opencode re-resolve to the newest release on each
54-
startup. Drop it (`"@stablekernel/opencode-cursor"`) or pin a version
55-
(`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer.
54+
startup. In practice opencode caches the `@latest` plugin install and does **not**
55+
auto-update it. If you see a stale-version warning from the plugin, or a version
56+
mismatch, exit opencode and clear the cached package:
57+
58+
```bash
59+
# macOS / Linux
60+
rm -rf ~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest
61+
# Windows
62+
rmdir /s "%LocalAppData%\opencode\cache\packages\@stablekernel\opencode-cursor@latest"
63+
```
64+
65+
Then restart opencode.
66+
67+
Drop `@latest` (`"@stablekernel/opencode-cursor"`) or pin a version
68+
(`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer deterministic installs.
5669

5770
The plugin injects the `provider` block automatically. If you need explicit control:
5871

@@ -267,9 +280,10 @@ Override with `OPENCODE_CURSOR_SIDECAR=1` (always sidecar) or `OPENCODE_CURSOR_S
267280
on your `PATH`. Install Node.js 22+, or force the sidecar with `OPENCODE_CURSOR_SIDECAR=1`.
268281
- **"Running under Bun without a usable Node sidecar" warning.** Install Node.js 22+, or set
269282
`OPENCODE_CURSOR_SIDECAR=0` to accept in-process behavior and silence the warning.
270-
- **Plugin enabled but no `cursor` provider/models appear.** Stale opencode plugin cache. Pin an
271-
exact version (`@stablekernel/opencode-cursor@<version>`) or delete
272-
`~/.cache/opencode/packages/` and restart.
283+
- **Plugin enabled but no `cursor` provider/models appear, or you see a stale-version warning.**
284+
opencode caches the `@latest` plugin install on first use and never refreshes it.
285+
Exit opencode, delete `~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest`
286+
(or the pinned version directory), and restart.
273287
- **Only the four fallback models appear.** The live catalog loads after the first authenticated
274288
use. Restart opencode once after login, or run `cursor_refresh_models`.
275289
- **Invalid or expired key.** Validated on first use — that's where the error surfaces.

‎package-lock.json‎

Lines changed: 22 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
},
5757
"dependencies": {
5858
"@cursor/sdk": "^1.0.18",
59-
"@opencode-ai/plugin": "^1.17.7"
59+
"@opencode-ai/plugin": "^1.17.7",
60+
"semver": "^7.8.4"
6061
},
6162
"overrides": {
6263
"undici": "^6.24.0",
@@ -70,6 +71,7 @@
7071
"@ai-sdk/provider": "^3.0.10",
7172
"@opencode-ai/sdk": "^1.17.1",
7273
"@types/node": "^25.9.2",
74+
"@types/semver": "^7.7.1",
7375
"tsup": "^8.5.1",
7476
"typescript": "^6.0.3",
7577
"vitest": "^4.1.8"

‎src/plugin/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
translateMcpServers,
1111
} from "./mcp-config.js";
1212
import { buildCursorTools } from "./cursor-tools.js";
13+
import { warnIfStale } from "../version-check.js";
1314

1415
function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
1516
return auth?.type === "api" ? auth.key : undefined;
@@ -28,6 +29,11 @@ function apiKeyFromAuth(auth: Auth | undefined): string | undefined {
2829
* - `tool.cursor_refresh_models`: force-refresh the model catalog.
2930
*/
3031
export const CursorPlugin: Plugin = async (input) => {
32+
// Warn once per day if the installed plugin is behind the npm `latest` tag.
33+
// opencode freezes `@latest` plugin installs on first use, so this keeps
34+
// users from silently running stale releases.
35+
void warnIfStale();
36+
3137
// The Cursor API key resolved by opencode's auth loader, captured so the
3238
// delegation tools (which don't receive auth directly) can reuse it. Falls
3339
// back to the CURSOR_API_KEY env var when the loader hasn't run.

‎src/version-check.ts‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { createRequire } from "node:module";
2+
import { homedir, tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { get } from "node:https";
5+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6+
import semver from "semver";
7+
8+
const PACKAGE_NAME = "@stablekernel/opencode-cursor";
9+
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
10+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
11+
const REQUEST_TIMEOUT_MS = 5000;
12+
13+
interface VersionCheckCache {
14+
checkedAt: number;
15+
latest: string | undefined;
16+
}
17+
18+
function cacheDir(): string {
19+
const base =
20+
process.env.XDG_CACHE_HOME?.trim() ||
21+
(homedir() ? join(homedir(), ".cache") : tmpdir());
22+
return join(base, "opencode-cursor");
23+
}
24+
25+
function cacheFile(): string {
26+
return join(cacheDir(), "version-check.json");
27+
}
28+
29+
function readCache(): VersionCheckCache | undefined {
30+
try {
31+
const parsed = JSON.parse(readFileSync(cacheFile(), "utf8")) as VersionCheckCache;
32+
if (typeof parsed.checkedAt === "number") return parsed;
33+
} catch {
34+
// ignore
35+
}
36+
return undefined;
37+
}
38+
39+
function writeCache(latest: string | undefined): void {
40+
try {
41+
mkdirSync(cacheDir(), { recursive: true });
42+
writeFileSync(
43+
cacheFile(),
44+
JSON.stringify({ checkedAt: Date.now(), latest }),
45+
"utf8",
46+
);
47+
} catch {
48+
// Best-effort; never block plugin init.
49+
}
50+
}
51+
52+
function getLocalVersion(): string | undefined {
53+
try {
54+
const require = createRequire(import.meta.url);
55+
const pkg = require("../package.json") as { version: string };
56+
return pkg.version;
57+
} catch {
58+
return undefined;
59+
}
60+
}
61+
62+
function fetchLatestVersion(): Promise<string | undefined> {
63+
return new Promise((resolve) => {
64+
const req = get(
65+
REGISTRY_URL,
66+
{ headers: { Accept: "application/json", Connection: "close" } },
67+
(res) => {
68+
let body = "";
69+
res.setEncoding("utf8");
70+
res.on("data", (chunk: string) => {
71+
body += chunk;
72+
});
73+
res.on("end", () => {
74+
try {
75+
const parsed = JSON.parse(body) as { version?: string };
76+
resolve(parsed.version);
77+
} catch {
78+
resolve(undefined);
79+
}
80+
});
81+
res.on("error", () => resolve(undefined));
82+
},
83+
);
84+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
85+
req.destroy();
86+
resolve(undefined);
87+
});
88+
req.on("error", () => resolve(undefined));
89+
});
90+
}
91+
92+
/** Return the cached latest version if fresh, else fetch from npm. */
93+
async function getLatestVersion(): Promise<string | undefined> {
94+
const cached = readCache();
95+
if (cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
96+
return cached.latest;
97+
}
98+
const latest = await fetchLatestVersion();
99+
writeCache(latest);
100+
return latest;
101+
}
102+
103+
/**
104+
* Print a one-time warning when this installed plugin is older than the
105+
* registry's `latest` tag. opencode resolves `@latest` once and then never
106+
* reinstalls the plugin, so users can silently stay on old versions. This
107+
* surfaces the staleness with actionable instructions.
108+
*
109+
* The check is throttled to once per 24h so plugin startup stays fast and
110+
* doesn't hit the registry repeatedly.
111+
*/
112+
export async function warnIfStale(): Promise<void> {
113+
const local = getLocalVersion();
114+
if (!local) return;
115+
const latest = await getLatestVersion();
116+
if (!latest) return;
117+
if (!semver.gt(latest, local)) return;
118+
119+
const cacheSpec = process.platform === "win32"
120+
? `%LocalAppData%\\opencode\\cache\\packages\\${PACKAGE_NAME}@latest`
121+
: `~/.cache/opencode/packages/${PACKAGE_NAME}@latest`;
122+
123+
console.warn(
124+
`\n⚠️ @stablekernel/opencode-cursor update available: v${local} → v${latest}.\n` +
125+
` opencode caches the @latest plugin on first install and never auto-updates it.\n` +
126+
` To upgrade, exit opencode, run:\n\n` +
127+
` rm -rf ${cacheSpec}\n\n` +
128+
` then restart opencode.\n`,
129+
);
130+
}

‎test/version-check.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { EventEmitter } from "node:events";
3+
import { join } from "node:path";
4+
5+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
6+
7+
const fsState: Record<string, string> = {};
8+
let lastRequestUrl: string | undefined;
9+
let requestHandlers: {
10+
onResponse?: (res: MockResponse) => void;
11+
onError?: () => void;
12+
onTimeout?: () => void;
13+
} = {};
14+
15+
class MockResponse extends EventEmitter {
16+
setEncoding = vi.fn();
17+
statusCode = 200;
18+
}
19+
20+
class MockRequest extends EventEmitter {
21+
setTimeout = vi.fn((_ms: number, cb: () => void) => {
22+
requestHandlers.onTimeout = cb;
23+
});
24+
destroy = vi.fn();
25+
}
26+
27+
const get = vi.fn((_url: string, _opts: object, cb: (res: MockResponse) => void) => {
28+
const req = new MockRequest();
29+
requestHandlers.onResponse = (res) => cb(res);
30+
return req;
31+
});
32+
33+
vi.mock("node:https", () => ({ get }));
34+
35+
vi.mock("node:fs", () => ({
36+
mkdirSync: vi.fn((_path: string, _opts?: object) => {}),
37+
readFileSync: vi.fn((path: string, _encoding: string) => {
38+
if (fsState[path]) return fsState[path];
39+
throw new Error("ENOENT");
40+
}),
41+
writeFileSync: vi.fn((path: string, data: string, _encoding: string) => {
42+
fsState[path] = data;
43+
}),
44+
rmSync: vi.fn(),
45+
}));
46+
47+
const { warnIfStale } = await import("../src/version-check.js");
48+
49+
function respondWith(version: string | undefined) {
50+
const res = new MockResponse();
51+
process.nextTick(() => {
52+
if (version === undefined) {
53+
res.emit("end");
54+
} else {
55+
res.emit("data", JSON.stringify({ version }));
56+
res.emit("end");
57+
}
58+
});
59+
requestHandlers.onResponse?.(res);
60+
}
61+
62+
describe("warnIfStale", () => {
63+
beforeEach(() => {
64+
consoleWarn.mockClear();
65+
Object.keys(fsState).forEach((k) => delete fsState[k]);
66+
lastRequestUrl = undefined;
67+
requestHandlers = {};
68+
get.mockClear();
69+
});
70+
71+
afterEach(() => {
72+
consoleWarn.mockReset();
73+
});
74+
75+
it("warns when registry latest is newer than local version", async () => {
76+
const promise = warnIfStale();
77+
respondWith("0.5.0");
78+
await promise;
79+
expect(consoleWarn).toHaveBeenCalledOnce();
80+
expect(String(consoleWarn.mock.calls[0]?.[0])).toContain("0.5.0");
81+
});
82+
83+
it("does not warn when local version equals latest", async () => {
84+
const promise = warnIfStale();
85+
respondWith("0.4.1");
86+
await promise;
87+
expect(consoleWarn).not.toHaveBeenCalled();
88+
});
89+
90+
it("does not warn when local version is newer", async () => {
91+
const promise = warnIfStale();
92+
respondWith("0.3.0");
93+
await promise;
94+
expect(consoleWarn).not.toHaveBeenCalled();
95+
});
96+
97+
it("does not warn when registry fetch fails", async () => {
98+
const promise = warnIfStale();
99+
const res = new MockResponse();
100+
requestHandlers.onResponse?.(res);
101+
process.nextTick(() => res.emit("error", new Error("network")));
102+
await promise;
103+
expect(consoleWarn).not.toHaveBeenCalled();
104+
});
105+
106+
it("uses cached result within 24h", async () => {
107+
// Seed cache with latest=9.9.9
108+
fsState[join(process.env.HOME || "/tmp", ".cache/opencode-cursor/version-check.json")] =
109+
JSON.stringify({ checkedAt: Date.now(), latest: "9.9.9" });
110+
await warnIfStale();
111+
expect(get).not.toHaveBeenCalled();
112+
expect(consoleWarn).toHaveBeenCalledOnce();
113+
});
114+
});

0 commit comments

Comments
 (0)