From 6a48a171e1f5beb9dfee8a64e911d6ce59e132f7 Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Wed, 17 Jun 2026 11:18:54 -0400 Subject: [PATCH 1/2] 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 d5c1418..e5cb266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,14 @@ "dependencies": { "@connectrpc/connect-node": "^2.1.2", "@cursor/sdk": "^1.0.20", - "@opencode-ai/plugin": "^1.17.9" + "@opencode-ai/plugin": "^1.17.9", + "semver": "^7.8.4" }, "devDependencies": { "@ai-sdk/provider": "^3.0.10", "@opencode-ai/sdk": "^1.17.9", "@types/node": "^26.0.0", + "@types/semver": "^7.7.1", "tsup": "^8.5.1", "typescript": "^6.0.3", "vitest": "^4.1.8" @@ -1621,6 +1623,13 @@ "undici-types": "~8.3.0" } }, + "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", @@ -2779,6 +2788,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "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 c765fb8..13e1363 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "dependencies": { "@connectrpc/connect-node": "^2.1.2", "@cursor/sdk": "^1.0.20", - "@opencode-ai/plugin": "^1.17.9" + "@opencode-ai/plugin": "^1.17.9", + "semver": "^7.8.4" }, "overrides": { "undici": "^6.24.0", @@ -71,6 +72,7 @@ "@ai-sdk/provider": "^3.0.10", "@opencode-ai/sdk": "^1.17.9", "@types/node": "^26.0.0", + "@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(); + }); +}); From 651884d4a77342ad03b81988b536856c104b503b Mon Sep 17 00:00:00 2001 From: Justin Carper Date: Mon, 6 Jul 2026 14:57:28 -0500 Subject: [PATCH 2/2] 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. --- README.md | 5 +- src/plugin/index.ts | 11 ++- src/version-check.ts | 56 ++++++++++---- test/version-check.test.ts | 153 +++++++++++++++++++++++++++++++++---- tsup.config.ts | 9 +++ 5 files changed, 199 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index a58d687..86a1ba8 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ mismatch, exit opencode and clear the cached package: # macOS / Linux rm -rf ~/.cache/opencode/packages/@stablekernel/opencode-cursor@latest # Windows -rmdir /s "%LocalAppData%\opencode\cache\packages\@stablekernel\opencode-cursor@latest" +rmdir /s /q "%LocalAppData%\opencode\cache\packages\@stablekernel\opencode-cursor@latest" ``` Then restart opencode. @@ -67,6 +67,9 @@ Then restart opencode. Drop `@latest` (`"@stablekernel/opencode-cursor"`) or pin a version (`"@stablekernel/opencode-cursor@1.2.3"`) if you prefer deterministic installs. +The stale-version check is skipped when the `CI` or `NO_UPDATE_NOTIFIER` +environment variable is set. + The plugin injects the `provider` block automatically. If you need explicit control: ```json diff --git a/src/plugin/index.ts b/src/plugin/index.ts index 8db3cdf..9a7011c 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -29,10 +29,13 @@ 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(); + // Warn if the installed plugin is behind the npm `latest` tag. The registry + // fetch is throttled to once per 24h via an on-disk cache, but while the + // cached result says the install is stale the warning prints on each + // startup. opencode freezes `@latest` plugin installs on first use, so this + // keeps users from silently running stale releases. Fire-and-forget: never + // block or fail plugin init. + void warnIfStale().catch(() => {}); // 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 diff --git a/src/version-check.ts b/src/version-check.ts index 32647ce..772d215 100644 --- a/src/version-check.ts +++ b/src/version-check.ts @@ -5,9 +5,22 @@ import { get } from "node:https"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import semver from "semver"; +/** + * Inlined by tsup's `define` option in the published bundle (see + * tsup.config.ts). In the bundle, a relative require of `../package.json` + * would resolve inside `dist/` where no package.json exists, so the version + * must be baked in at build time. When running un-bundled (tests against + * `src/`), this stays undefined and `getLocalVersion` falls back to reading + * package.json. + */ +declare const __PKG_VERSION__: string | undefined; + 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; +// Failed fetches are retried sooner than successful ones so a transient +// network error doesn't suppress the check for a full day. +const FAILURE_TTL_MS = 60 * 60 * 1000; const REQUEST_TIMEOUT_MS = 5000; interface VersionCheckCache { @@ -50,6 +63,9 @@ function writeCache(latest: string | undefined): void { } function getLocalVersion(): string | undefined { + // Build-time inlined version (published bundle path). + if (typeof __PKG_VERSION__ === "string") return __PKG_VERSION__; + // Un-bundled fallback: resolve package.json relative to this source file. try { const require = createRequire(import.meta.url); const pkg = require("../package.json") as { version: string }; @@ -65,6 +81,11 @@ function fetchLatestVersion(): Promise { REGISTRY_URL, { headers: { Accept: "application/json", Connection: "close" } }, (res) => { + if (res.statusCode !== 200) { + res.resume(); + resolve(undefined); + return; + } let body = ""; res.setEncoding("utf8"); res.on("data", (chunk: string) => { @@ -92,8 +113,10 @@ function fetchLatestVersion(): Promise { /** 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; + if (cached) { + // Successful lookups are trusted for 24h; failures only briefly. + const ttl = cached.latest ? CHECK_INTERVAL_MS : FAILURE_TTL_MS; + if (Date.now() - cached.checkedAt < ttl) return cached.latest; } const latest = await fetchLatestVersion(); writeCache(latest); @@ -101,30 +124,35 @@ async function getLatestVersion(): Promise { } /** - * 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. + * Print a 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. + * The registry fetch is throttled to once per 24h via an on-disk cache; + * while the cached result says the install is stale, the warning prints on + * each startup until the user upgrades. + * + * Set CI or NO_UPDATE_NOTIFIER to skip the check entirely. */ export async function warnIfStale(): Promise { + if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return; + const local = getLocalVersion(); - if (!local) return; + if (!local || !semver.valid(local)) return; const latest = await getLatestVersion(); - if (!latest) return; + if (!latest || !semver.valid(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`; + const removeCommand = process.platform === "win32" + ? `rmdir /s /q "%LocalAppData%\\opencode\\cache\\packages\\@stablekernel\\opencode-cursor@latest"` + : `rm -rf ~/.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` + + ` ${removeCommand}\n\n` + ` then restart opencode.\n`, ); } diff --git a/test/version-check.test.ts b/test/version-check.test.ts index b9ae038..aa24c7d 100644 --- a/test/version-check.test.ts +++ b/test/version-check.test.ts @@ -1,11 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; import { join } from "node:path"; +import semver from "semver"; const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); +const realPkg = createRequire(import.meta.url)("../package.json") as { + version: string; +}; + +// Local version injected for tests so expectations don't couple to the real +// package.json version. `getLocalVersion` reads the free identifier +// `__PKG_VERSION__`, which resolves to this globalThis property when tsup's +// build-time `define` hasn't inlined it. +const LOCAL_VERSION = "1.0.0"; +const NEWER_VERSION = "2.0.0"; +const OLDER_VERSION = "0.1.0"; + const fsState: Record = {}; -let lastRequestUrl: string | undefined; let requestHandlers: { onResponse?: (res: MockResponse) => void; onError?: () => void; @@ -14,6 +27,7 @@ let requestHandlers: { class MockResponse extends EventEmitter { setEncoding = vi.fn(); + resume = vi.fn(); statusCode = 200; } @@ -46,54 +60,80 @@ vi.mock("node:fs", () => ({ const { warnIfStale } = await import("../src/version-check.js"); -function respondWith(version: string | undefined) { +function respondWithRaw(body: string | undefined, statusCode = 200) { const res = new MockResponse(); + res.statusCode = statusCode; process.nextTick(() => { - if (version === undefined) { - res.emit("end"); - } else { - res.emit("data", JSON.stringify({ version })); - res.emit("end"); - } + if (body !== undefined) res.emit("data", body); + res.emit("end"); }); requestHandlers.onResponse?.(res); } +function respondWith(version: string | undefined, statusCode = 200) { + respondWithRaw( + version === undefined ? undefined : JSON.stringify({ version }), + statusCode, + ); +} + +function cachePath(): string { + return join( + process.env.HOME || "/tmp", + ".cache/opencode-cursor/version-check.json", + ); +} + describe("warnIfStale", () => { beforeEach(() => { consoleWarn.mockClear(); Object.keys(fsState).forEach((k) => delete fsState[k]); - lastRequestUrl = undefined; requestHandlers = {}; get.mockClear(); + // Neutralize real CI env so the escape hatch doesn't skip the check. + vi.stubEnv("CI", ""); + vi.stubEnv("NO_UPDATE_NOTIFIER", ""); + (globalThis as Record).__PKG_VERSION__ = LOCAL_VERSION; }); afterEach(() => { consoleWarn.mockReset(); + vi.unstubAllEnvs(); + delete (globalThis as Record).__PKG_VERSION__; }); it("warns when registry latest is newer than local version", async () => { const promise = warnIfStale(); - respondWith("0.5.0"); + respondWith(NEWER_VERSION); await promise; expect(consoleWarn).toHaveBeenCalledOnce(); - expect(String(consoleWarn.mock.calls[0]?.[0])).toContain("0.5.0"); + expect(String(consoleWarn.mock.calls[0]?.[0])).toContain(NEWER_VERSION); }); it("does not warn when local version equals latest", async () => { const promise = warnIfStale(); - respondWith("0.4.1"); + respondWith(LOCAL_VERSION); await promise; expect(consoleWarn).not.toHaveBeenCalled(); }); it("does not warn when local version is newer", async () => { const promise = warnIfStale(); - respondWith("0.3.0"); + respondWith(OLDER_VERSION); await promise; expect(consoleWarn).not.toHaveBeenCalled(); }); + it("falls back to the real package.json version when no build-time version is defined", async () => { + delete (globalThis as Record).__PKG_VERSION__; + const newer = semver.inc(realPkg.version, "major"); + const promise = warnIfStale(); + respondWith(newer ?? "99.0.0"); + await promise; + expect(consoleWarn).toHaveBeenCalledOnce(); + expect(String(consoleWarn.mock.calls[0]?.[0])).toContain(realPkg.version); + }); + it("does not warn when registry fetch fails", async () => { const promise = warnIfStale(); const res = new MockResponse(); @@ -103,12 +143,93 @@ describe("warnIfStale", () => { expect(consoleWarn).not.toHaveBeenCalled(); }); + it("does not warn or throw on a malformed JSON body", async () => { + const promise = warnIfStale(); + respondWithRaw("not json"); + await expect(promise).resolves.toBeUndefined(); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("does not warn on a non-200 registry response", async () => { + const promise = warnIfStale(); + respondWith(NEWER_VERSION, 500); + await promise; + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("does not warn or throw when the registry returns an invalid version string", async () => { + const promise = warnIfStale(); + respondWith("not-a-version"); + await expect(promise).resolves.toBeUndefined(); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("skips the check entirely when CI is set", async () => { + vi.stubEnv("CI", "true"); + await warnIfStale(); + expect(get).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("skips the check entirely when NO_UPDATE_NOTIFIER is set", async () => { + vi.stubEnv("NO_UPDATE_NOTIFIER", "1"); + await warnIfStale(); + expect(get).not.toHaveBeenCalled(); + 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" }); + fsState[cachePath()] = JSON.stringify({ + checkedAt: Date.now(), + latest: "9.9.9", + }); await warnIfStale(); expect(get).not.toHaveBeenCalled(); expect(consoleWarn).toHaveBeenCalledOnce(); }); + + it("re-fetches when a cached failure is older than the failure TTL", async () => { + fsState[cachePath()] = JSON.stringify({ + checkedAt: Date.now() - 2 * 60 * 60 * 1000, // 2h ago + latest: undefined, + }); + const promise = warnIfStale(); + respondWith(NEWER_VERSION); + await promise; + expect(get).toHaveBeenCalledOnce(); + expect(consoleWarn).toHaveBeenCalledOnce(); + }); + + it("does not re-fetch a recent cached failure", async () => { + fsState[cachePath()] = JSON.stringify({ + checkedAt: Date.now() - 30 * 60 * 1000, // 30min ago + latest: undefined, + }); + await warnIfStale(); + expect(get).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it("emits a windows removal command on win32", async () => { + const original = process.platform; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + const promise = warnIfStale(); + respondWith(NEWER_VERSION); + await promise; + expect(consoleWarn).toHaveBeenCalledOnce(); + const message = String(consoleWarn.mock.calls[0]?.[0]); + expect(message).toContain("rmdir /s /q"); + expect(message).not.toContain("rm -rf"); + } finally { + Object.defineProperty(process, "platform", { value: original }); + } + }); + + it("emits rm -rf on non-windows platforms", async () => { + const promise = warnIfStale(); + respondWith(NEWER_VERSION); + await promise; + expect(String(consoleWarn.mock.calls[0]?.[0])).toContain("rm -rf"); + }); }); diff --git a/tsup.config.ts b/tsup.config.ts index 2376ca1..0256a9e 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,10 @@ +import { readFileSync } from "node:fs"; import { defineConfig } from "tsup"; +const pkg = JSON.parse( + readFileSync(new URL("./package.json", import.meta.url), "utf8"), +) as { version: string }; + export default defineConfig({ // Emit config (src-only rootDir + declaration); the root tsconfig.json is the // broad editor/typecheck project that also covers test/. @@ -16,6 +21,10 @@ export default defineConfig({ dts: true, clean: true, sourcemap: true, + // Bake the package version into the bundle: in dist/, version-check.ts can't + // resolve ../package.json (it would point inside dist/), so the version is + // inlined at build time instead. + define: { __PKG_VERSION__: JSON.stringify(pkg.version) }, // @cursor/sdk is heavy and resolved at runtime; keep these external. external: ["@cursor/sdk", "@ai-sdk/provider", "@opencode-ai/plugin"], });