Skip to content

Commit 3a18ea4

Browse files
feat: warn when installed plugin lags registry latest (#33)
* 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. * fix(version-check): inline package version at build time, harden update check - Inject __PKG_VERSION__ via tsup define: in the published bundle, createRequire('../package.json') resolved inside dist/ and silently no-op'd the whole check. Un-bundled runs (tests) fall back to reading package.json. - Guard warnIfStale() call with .catch() and validate versions with semver.valid() before comparing, so invalid registry data can't throw. - Check res.statusCode === 200 before parsing the registry response. - Cache failed fetches for 1h instead of 24h. - Skip the check when CI or NO_UPDATE_NOTIFIER is set (documented in README). - Emit a platform-appropriate removal command (rmdir /s /q on win32), matching the README. - Decouple tests from the real package.json version via injected __PKG_VERSION__; add tests for malformed JSON, non-200, env skips, invalid versions, failure TTL, and platform-specific commands; drop dead lastRequestUrl.
1 parent da2a401 commit 3a18ea4

7 files changed

Lines changed: 458 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,24 @@ 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 /q "%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.
69+
70+
The stale-version check is skipped when the `CI` or `NO_UPDATE_NOTIFIER`
71+
environment variable is set.
5672

5773
The plugin injects the `provider` block automatically. If you need explicit control:
5874

@@ -267,9 +283,10 @@ Override with `OPENCODE_CURSOR_SIDECAR=1` (always sidecar) or `OPENCODE_CURSOR_S
267283
on your `PATH`. Install Node.js 22+, or force the sidecar with `OPENCODE_CURSOR_SIDECAR=1`.
268284
- **"Running under Bun without a usable Node sidecar" warning.** Install Node.js 22+, or set
269285
`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.
286+
- **Plugin enabled but no `cursor` provider/models appear, or you see a stale-version warning.**
287+
opencode caches the `@latest` plugin install on first use and never refreshes it.
288+
Exit opencode, delete `~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest`
289+
(or the pinned version directory), and restart.
273290
- **Only the four fallback models appear.** The live catalog loads after the first authenticated
274291
use. Restart opencode once after login, or run `cursor_refresh_models`.
275292
- **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
@@ -57,7 +57,8 @@
5757
"dependencies": {
5858
"@connectrpc/connect-node": "^2.1.2",
5959
"@cursor/sdk": "^1.0.20",
60-
"@opencode-ai/plugin": "^1.17.9"
60+
"@opencode-ai/plugin": "^1.17.9",
61+
"semver": "^7.8.4"
6162
},
6263
"overrides": {
6364
"undici": "^6.24.0",
@@ -71,6 +72,7 @@
7172
"@ai-sdk/provider": "^3.0.10",
7273
"@opencode-ai/sdk": "^1.17.9",
7374
"@types/node": "^26.0.0",
75+
"@types/semver": "^7.7.1",
7476
"tsup": "^8.5.1",
7577
"typescript": "^6.0.3",
7678
"vitest": "^4.1.8"

‎src/plugin/index.ts‎

Lines changed: 9 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,14 @@ 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 if the installed plugin is behind the npm `latest` tag. The registry
33+
// fetch is throttled to once per 24h via an on-disk cache, but while the
34+
// cached result says the install is stale the warning prints on each
35+
// startup. opencode freezes `@latest` plugin installs on first use, so this
36+
// keeps users from silently running stale releases. Fire-and-forget: never
37+
// block or fail plugin init.
38+
void warnIfStale().catch(() => {});
39+
3140
// The Cursor API key resolved by opencode's auth loader, captured so the
3241
// delegation tools (which don't receive auth directly) can reuse it. Falls
3342
// back to the CURSOR_API_KEY env var when the loader hasn't run.

‎src/version-check.ts‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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+
/**
9+
* Inlined by tsup's `define` option in the published bundle (see
10+
* tsup.config.ts). In the bundle, a relative require of `../package.json`
11+
* would resolve inside `dist/` where no package.json exists, so the version
12+
* must be baked in at build time. When running un-bundled (tests against
13+
* `src/`), this stays undefined and `getLocalVersion` falls back to reading
14+
* package.json.
15+
*/
16+
declare const __PKG_VERSION__: string | undefined;
17+
18+
const PACKAGE_NAME = "@stablekernel/opencode-cursor";
19+
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
20+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
21+
// Failed fetches are retried sooner than successful ones so a transient
22+
// network error doesn't suppress the check for a full day.
23+
const FAILURE_TTL_MS = 60 * 60 * 1000;
24+
const REQUEST_TIMEOUT_MS = 5000;
25+
26+
interface VersionCheckCache {
27+
checkedAt: number;
28+
latest: string | undefined;
29+
}
30+
31+
function cacheDir(): string {
32+
const base =
33+
process.env.XDG_CACHE_HOME?.trim() ||
34+
(homedir() ? join(homedir(), ".cache") : tmpdir());
35+
return join(base, "opencode-cursor");
36+
}
37+
38+
function cacheFile(): string {
39+
return join(cacheDir(), "version-check.json");
40+
}
41+
42+
function readCache(): VersionCheckCache | undefined {
43+
try {
44+
const parsed = JSON.parse(readFileSync(cacheFile(), "utf8")) as VersionCheckCache;
45+
if (typeof parsed.checkedAt === "number") return parsed;
46+
} catch {
47+
// ignore
48+
}
49+
return undefined;
50+
}
51+
52+
function writeCache(latest: string | undefined): void {
53+
try {
54+
mkdirSync(cacheDir(), { recursive: true });
55+
writeFileSync(
56+
cacheFile(),
57+
JSON.stringify({ checkedAt: Date.now(), latest }),
58+
"utf8",
59+
);
60+
} catch {
61+
// Best-effort; never block plugin init.
62+
}
63+
}
64+
65+
function getLocalVersion(): string | undefined {
66+
// Build-time inlined version (published bundle path).
67+
if (typeof __PKG_VERSION__ === "string") return __PKG_VERSION__;
68+
// Un-bundled fallback: resolve package.json relative to this source file.
69+
try {
70+
const require = createRequire(import.meta.url);
71+
const pkg = require("../package.json") as { version: string };
72+
return pkg.version;
73+
} catch {
74+
return undefined;
75+
}
76+
}
77+
78+
function fetchLatestVersion(): Promise<string | undefined> {
79+
return new Promise((resolve) => {
80+
const req = get(
81+
REGISTRY_URL,
82+
{ headers: { Accept: "application/json", Connection: "close" } },
83+
(res) => {
84+
if (res.statusCode !== 200) {
85+
res.resume();
86+
resolve(undefined);
87+
return;
88+
}
89+
let body = "";
90+
res.setEncoding("utf8");
91+
res.on("data", (chunk: string) => {
92+
body += chunk;
93+
});
94+
res.on("end", () => {
95+
try {
96+
const parsed = JSON.parse(body) as { version?: string };
97+
resolve(parsed.version);
98+
} catch {
99+
resolve(undefined);
100+
}
101+
});
102+
res.on("error", () => resolve(undefined));
103+
},
104+
);
105+
req.setTimeout(REQUEST_TIMEOUT_MS, () => {
106+
req.destroy();
107+
resolve(undefined);
108+
});
109+
req.on("error", () => resolve(undefined));
110+
});
111+
}
112+
113+
/** Return the cached latest version if fresh, else fetch from npm. */
114+
async function getLatestVersion(): Promise<string | undefined> {
115+
const cached = readCache();
116+
if (cached) {
117+
// Successful lookups are trusted for 24h; failures only briefly.
118+
const ttl = cached.latest ? CHECK_INTERVAL_MS : FAILURE_TTL_MS;
119+
if (Date.now() - cached.checkedAt < ttl) return cached.latest;
120+
}
121+
const latest = await fetchLatestVersion();
122+
writeCache(latest);
123+
return latest;
124+
}
125+
126+
/**
127+
* Print a warning when this installed plugin is older than the registry's
128+
* `latest` tag. opencode resolves `@latest` once and then never reinstalls
129+
* the plugin, so users can silently stay on old versions. This surfaces the
130+
* staleness with actionable instructions.
131+
*
132+
* The registry fetch is throttled to once per 24h via an on-disk cache;
133+
* while the cached result says the install is stale, the warning prints on
134+
* each startup until the user upgrades.
135+
*
136+
* Set CI or NO_UPDATE_NOTIFIER to skip the check entirely.
137+
*/
138+
export async function warnIfStale(): Promise<void> {
139+
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return;
140+
141+
const local = getLocalVersion();
142+
if (!local || !semver.valid(local)) return;
143+
const latest = await getLatestVersion();
144+
if (!latest || !semver.valid(latest)) return;
145+
if (!semver.gt(latest, local)) return;
146+
147+
const removeCommand = process.platform === "win32"
148+
? `rmdir /s /q "%LocalAppData%\\opencode\\cache\\packages\\@stablekernel\\opencode-cursor@latest"`
149+
: `rm -rf ~/.cache/opencode/packages/${PACKAGE_NAME}@latest`;
150+
151+
console.warn(
152+
`\n⚠️ @stablekernel/opencode-cursor update available: v${local} → v${latest}.\n` +
153+
` opencode caches the @latest plugin on first install and never auto-updates it.\n` +
154+
` To upgrade, exit opencode, run:\n\n` +
155+
` ${removeCommand}\n\n` +
156+
` then restart opencode.\n`,
157+
);
158+
}

0 commit comments

Comments
 (0)