From 87a9c64bfa29a3c52efffb1802f8109ccc87abce Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 9 Jul 2026 03:01:06 +0200 Subject: [PATCH] feat: flag when a newer Kagan release is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode installs a plugin once and caches it forever — it never re-checks npm, so users are never told a newer Kagan exists. Check the npm dist-tags endpoint at most once a day (cached in api.kv across restarts), and show `→ vX.Y.Z available` in the board footer when a newer release is out. The check runs off the render path from the TUI entry, skips dev/prerelease builds so local installs never nag, and never crashes or hangs the board on a network failure. Docs cover the two real ways to update, since re-running the install command reuses the same cache and does nothing. --- README.md | 2 + docs/quickstart.md | 23 ++++++ src/board.tsx | 3 + src/store.tsx | 3 + src/tui.tsx | 9 +++ src/update-check.ts | 83 ++++++++++++++++++++ test/board.test.tsx | 13 ++++ test/update-check.test.ts | 157 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 293 insertions(+) create mode 100644 src/update-check.ts create mode 100644 test/update-check.test.ts diff --git a/README.md b/README.md index 11b890f..6e9e32c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ Or add a local clone to both OpenCode config files: Open the board with `/kagan` from the command palette, the `kagan` palette command, or `k` (the leader key defaults to `ctrl+x`). +OpenCode caches the version it first installs and never re-checks, so the board footer flags when a newer release is out — see [Updating](https://docs.kagan.sh/quickstart/#updating) to move to it. + Pass options by using the array-of-array form, or open `/kagan-settings` from the project — see the [configuration reference](https://docs.kagan.sh/reference/configuration/). ## Docs diff --git a/docs/quickstart.md b/docs/quickstart.md index 58bc395..061ae80 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -24,6 +24,29 @@ Open the board with `/kagan`, the `kagan` palette command, or `k` (leade To configure options such as `commands.check` or `inProgressLimit`, use the array-of-array plugin entry shown in the [configuration reference](/reference/configuration). +## Updating + +When a newer version is published, the board footer shows `→ vX.Y.Z available` (the check hits npm at +most once a day). OpenCode does **not** update plugins on its own: the +first version it installs is cached and reused on every launch, and re-running `opencode plugin +@kagan-sh/kagan` reuses that same cache. To move to a newer version, use one of these: + +**Refresh the cached install** — remove Kagan's cached package, then restart OpenCode. It reinstalls +the latest on the next launch. + +```bash +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/opencode/packages/@kagan-sh/kagan@latest" +``` + +**Pin a version and bump it** — set an explicit version in `opencode.json` and `tui.json`. OpenCode +caches each exact version separately, so changing the number forces a clean install: + +```json +{ + "plugin": ["@kagan-sh/kagan@0.1.3"] +} +``` + ## Your first task 1. **Open the board** — use `/kagan`, the `kagan` palette command, or `k`. diff --git a/src/board.tsx b/src/board.tsx index 05d90d6..a10cb88 100644 --- a/src/board.tsx +++ b/src/board.tsx @@ -107,6 +107,9 @@ function Footer(props: { api: TuiPluginApi; store: BoardStore; hints: () => { ke kagan v{version} + + {(latest) => {` → v${latest()} available`}} + {` · filter: ${filter()}`} diff --git a/src/store.tsx b/src/store.tsx index 5015ac0..234339c 100644 --- a/src/store.tsx +++ b/src/store.tsx @@ -269,6 +269,7 @@ export function createBoardStore(api: TuiPluginApi, options?: Record([]) + const [updateAvailable, setUpdateAvailable] = createSignal() const [selectedID, setSelectedID] = createSignal() const [selectedColumn, setSelectedColumn] = createSignal("backlog") const [filter, setFilterSignal] = createSignal(getFilter(api)) @@ -559,6 +560,8 @@ export function createBoardStore(api: TuiPluginApi, options?: Record { const store = createRoot(() => createBoardStore(api, options)) @@ -14,6 +16,13 @@ const tui: TuiPlugin = async (api, options) => { api.lifecycle.onDispose(() => disposeEvents()) api.lifecycle.onDispose(() => disposeStatusEvents()) + checkForUpdate({ + kv: api.kv, + currentVersion: version, + now: Date.now(), + onUpdate: (latest) => store.setUpdateAvailable(latest), + }).catch(() => {}) + api.route.register([ { name: ROUTE, diff --git a/src/update-check.ts b/src/update-check.ts new file mode 100644 index 0000000..afd54fa --- /dev/null +++ b/src/update-check.ts @@ -0,0 +1,83 @@ +const REGISTRY_DIST_TAGS = "https://registry.npmjs.org/-/package/@kagan-sh/kagan/dist-tags" +const CHECK_TTL_MS = 24 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 3000 +const LAST_CHECK_KEY = "kagan:update:lastCheck" +const LATEST_KEY = "kagan:update:latest" + +type UpdateKv = { + get: (key: string, fallback?: Value) => Value + set: (key: string, value: unknown) => void +} + +// Only clean numeric releases (x.y.z) are comparable. Dev/prerelease builds — the repo's own +// "0.0.0-development" before semantic-release rewrites it, or any "-beta" tag — return undefined so +// local development installs never surface an update banner and a prerelease `latest` can't false-positive. +export function parseRelease(version: string): [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim()) + if (!match) return undefined + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +export function isNewerRelease(latest: string, current: string): boolean { + const next = parseRelease(latest) + const now = parseRelease(current) + if (!next || !now) return false + const [nextMajor, nextMinor, nextPatch] = next + const [nowMajor, nowMinor, nowPatch] = now + if (nextMajor !== nowMajor) return nextMajor > nowMajor + if (nextMinor !== nowMinor) return nextMinor > nowMinor + return nextPatch > nowPatch +} + +async function fetchLatestVersion(fetchImpl: typeof fetch, timeoutMs: number): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetchImpl(REGISTRY_DIST_TAGS, { signal: controller.signal }) + if (!response.ok) return undefined + const data = (await response.json()) as { latest?: unknown } + return typeof data.latest === "string" ? data.latest : undefined + } catch { + return undefined + } finally { + clearTimeout(timer) + } +} + +// Returns the latest published version, hitting npm at most once per TTL window and caching the +// result in kv across restarts. On a failed fetch it keeps serving the cached value and leaves the +// timestamp untouched so the next board load retries rather than going dark for a full day. +export async function resolveLatestVersion( + kv: UpdateKv, + now: number, + deps: { fetchImpl?: typeof fetch; ttlMs?: number } = {}, +): Promise { + const ttl = deps.ttlMs ?? CHECK_TTL_MS + const cached = kv.get(LATEST_KEY, undefined) + const lastCheck = kv.get(LAST_CHECK_KEY, 0) + // A future lastCheck means the clock moved backward since the last check; refetch rather than + // trust it, otherwise the stale cache would be served until wall-clock time catches back up. + if (lastCheck <= now && now - lastCheck < ttl) return cached + + const latest = await fetchLatestVersion(deps.fetchImpl ?? fetch, FETCH_TIMEOUT_MS) + if (latest === undefined) return cached + kv.set(LATEST_KEY, latest) + kv.set(LAST_CHECK_KEY, now) + return latest +} + +export async function checkForUpdate(input: { + kv: UpdateKv + currentVersion: string + now: number + onUpdate: (latest: string) => void + fetchImpl?: typeof fetch + ttlMs?: number +}): Promise { + if (!parseRelease(input.currentVersion)) return + const latest = await resolveLatestVersion(input.kv, input.now, { + fetchImpl: input.fetchImpl, + ttlMs: input.ttlMs, + }) + if (latest && isNewerRelease(latest, input.currentVersion)) input.onUpdate(latest) +} diff --git a/test/board.test.tsx b/test/board.test.tsx index ef5aeb2..31a6204 100644 --- a/test/board.test.tsx +++ b/test/board.test.tsx @@ -137,6 +137,19 @@ describe("Board", () => { expect(frame).not.toContain("d delete") }) + test("shows the update indicator in the footer once a newer version is available", async () => { + const api = mockBoardApi() + const store = createRoot(() => createBoardStore(api)) + await store.refresh() + renderSetup = await testRender(() => , { width: 120, height: 20 }) + await renderSetup.flush() + expect(renderSetup.captureCharFrame()).not.toContain("available") + + store.setUpdateAvailable("9.9.9") + await renderSetup.flush() + expect(renderSetup.captureCharFrame()).toContain("v9.9.9 available") + }) + test("keeps the footer trimmed once a card is selected", async () => { const api = mockBoardApi({ sessions: [ diff --git a/test/update-check.test.ts b/test/update-check.test.ts new file mode 100644 index 0000000..5bbe172 --- /dev/null +++ b/test/update-check.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, test } from "bun:test" +import { checkForUpdate, isNewerRelease, parseRelease, resolveLatestVersion } from "../src/update-check" + +function mockKv(initial: Record = {}) { + const store: Record = { ...initial } + return { + store, + get: (key: string, fallback?: Value) => (key in store ? (store[key] as Value) : (fallback as Value)), + set: (key: string, value: unknown) => { + store[key] = value + }, + } +} + +function fetchReturning(latest: unknown, ok = true): typeof fetch { + return (async () => ({ + ok, + json: async () => ({ latest }), + })) as unknown as typeof fetch +} + +const fetchThatThrows: typeof fetch = (async () => { + throw new Error("network down") +}) as unknown as typeof fetch + +const HOUR = 60 * 60 * 1000 +const DAY = 24 * HOUR + +describe("parseRelease", () => { + test("accepts clean numeric releases", () => { + expect(parseRelease("0.1.3")).toEqual([0, 1, 3]) + expect(parseRelease(" 12.0.45 ")).toEqual([12, 0, 45]) + }) + + test("rejects dev, prerelease, and garbage", () => { + for (const raw of ["0.0.0-development", "0.3.0-beta.1", "1.2", "v1.2.3", "latest", ""]) { + expect(parseRelease(raw)).toBeUndefined() + } + }) +}) + +describe("isNewerRelease", () => { + test("compares segments numerically, not lexically", () => { + expect(isNewerRelease("0.1.10", "0.1.3")).toBe(true) + expect(isNewerRelease("0.2.0", "0.1.99")).toBe(true) + expect(isNewerRelease("1.0.0", "0.9.9")).toBe(true) + }) + + test("is false for equal or older", () => { + expect(isNewerRelease("0.1.3", "0.1.3")).toBe(false) + expect(isNewerRelease("0.1.2", "0.1.3")).toBe(false) + }) + + test("is false when either side is not a clean release", () => { + expect(isNewerRelease("0.2.0", "0.0.0-development")).toBe(false) + expect(isNewerRelease("0.3.0-beta.1", "0.1.3")).toBe(false) + }) +}) + +describe("resolveLatestVersion", () => { + test("fetches and caches when no prior check exists", async () => { + const kv = mockKv() + const latest = await resolveLatestVersion(kv, DAY + 1, { fetchImpl: fetchReturning("0.2.0") }) + expect(latest).toBe("0.2.0") + expect(kv.store["kagan:update:latest"]).toBe("0.2.0") + expect(kv.store["kagan:update:lastCheck"]).toBe(DAY + 1) + }) + + test("serves cached value without fetching inside the TTL window", async () => { + const kv = mockKv({ "kagan:update:latest": "0.2.0", "kagan:update:lastCheck": DAY }) + let called = false + const spyFetch = (async () => { + called = true + return { ok: true, json: async () => ({ latest: "0.9.9" }) } + }) as unknown as typeof fetch + const latest = await resolveLatestVersion(kv, DAY + HOUR, { fetchImpl: spyFetch }) + expect(latest).toBe("0.2.0") + expect(called).toBe(false) + }) + + test("refetches once the TTL has elapsed", async () => { + const kv = mockKv({ "kagan:update:latest": "0.2.0", "kagan:update:lastCheck": 0 }) + const latest = await resolveLatestVersion(kv, DAY + 1, { fetchImpl: fetchReturning("0.3.0") }) + expect(latest).toBe("0.3.0") + expect(kv.store["kagan:update:lastCheck"]).toBe(DAY + 1) + }) + + test("refetches when the stored timestamp is in the future (clock moved backward)", async () => { + const kv = mockKv({ "kagan:update:latest": "0.2.0", "kagan:update:lastCheck": DAY * 10 }) + const latest = await resolveLatestVersion(kv, DAY, { fetchImpl: fetchReturning("0.3.0") }) + expect(latest).toBe("0.3.0") + expect(kv.store["kagan:update:lastCheck"]).toBe(DAY) + }) + + test("keeps the cached value and leaves the timestamp untouched on fetch failure", async () => { + const kv = mockKv({ "kagan:update:latest": "0.2.0", "kagan:update:lastCheck": 0 }) + const latest = await resolveLatestVersion(kv, DAY + 1, { fetchImpl: fetchThatThrows }) + expect(latest).toBe("0.2.0") + expect(kv.store["kagan:update:lastCheck"]).toBe(0) + }) + + test("treats a non-ok response and a malformed body as a miss", async () => { + const kvNotOk = mockKv() + expect(await resolveLatestVersion(kvNotOk, DAY + 1, { fetchImpl: fetchReturning("0.2.0", false) })).toBeUndefined() + expect(kvNotOk.store["kagan:update:lastCheck"]).toBeUndefined() + + const kvBadBody = mockKv() + expect(await resolveLatestVersion(kvBadBody, DAY + 1, { fetchImpl: fetchReturning(42) })).toBeUndefined() + }) +}) + +describe("checkForUpdate", () => { + test("notifies once when a newer release is published", async () => { + const kv = mockKv() + const seen: string[] = [] + await checkForUpdate({ + kv, + currentVersion: "0.1.3", + now: DAY + 1, + onUpdate: (latest) => seen.push(latest), + fetchImpl: fetchReturning("0.1.10"), + }) + expect(seen).toEqual(["0.1.10"]) + }) + + test("stays silent when already on the latest release", async () => { + const kv = mockKv() + const seen: string[] = [] + await checkForUpdate({ + kv, + currentVersion: "0.1.3", + now: DAY + 1, + onUpdate: (latest) => seen.push(latest), + fetchImpl: fetchReturning("0.1.3"), + }) + expect(seen).toEqual([]) + }) + + test("never fetches for a dev/prerelease install", async () => { + const kv = mockKv() + let called = false + const spyFetch = (async () => { + called = true + return { ok: true, json: async () => ({ latest: "9.9.9" }) } + }) as unknown as typeof fetch + const seen: string[] = [] + await checkForUpdate({ + kv, + currentVersion: "0.0.0-development", + now: DAY + 1, + onUpdate: (latest) => seen.push(latest), + fetchImpl: spyFetch, + }) + expect(called).toBe(false) + expect(seen).toEqual([]) + }) +})