Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<leader>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
Expand Down
23 changes: 23 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ Open the board with `/kagan`, the `kagan` palette command, or `<leader>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 `<leader>k`.
Expand Down
3 changes: 3 additions & 0 deletions src/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ function Footer(props: { api: TuiPluginApi; store: BoardStore; hints: () => { ke
<box flexDirection="row" flexShrink={0} paddingLeft={2} paddingRight={2} justifyContent="space-between">
<text wrapMode="none" truncate={true} fg={theme().textMuted}>
kagan v{version}
<Show when={props.store.updateAvailable()}>
{(latest) => <span style={{ fg: theme().info }}>{` → v${latest()} available`}</span>}
</Show>
<Show when={filter()}>{` · filter: ${filter()}`}</Show>
</text>
<box flexDirection="row" gap={2} flexShrink={0}>
Expand Down
3 changes: 3 additions & 0 deletions src/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ export function createBoardStore(api: TuiPluginApi, options?: Record<string, unk
const scopes = configuredScopes(options)
const sendBackThreshold = sendBackStopThreshold(options)
const [sessions, setSessions] = createSignal<BoardSession[]>([])
const [updateAvailable, setUpdateAvailable] = createSignal<string | undefined>()
const [selectedID, setSelectedID] = createSignal<string | undefined>()
const [selectedColumn, setSelectedColumn] = createSignal<ColumnType>("backlog")
const [filter, setFilterSignal] = createSignal(getFilter(api))
Expand Down Expand Up @@ -559,6 +560,8 @@ export function createBoardStore(api: TuiPluginApi, options?: Record<string, unk
columns,
notices,
notify,
updateAvailable,
setUpdateAvailable,
select,
selectNext,
selectPrevious,
Expand Down
9 changes: 9 additions & 0 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { Board } from "./board"
import { Settings } from "./settings"
import { showOnboarding } from "./onboarding"
import { createBoardStore, createSessionEventSubscription, createSessionStatusSubscription } from "./store"
import { checkForUpdate } from "./update-check"
import { ROUTE, SETTINGS_ROUTE } from "./types"
import { version } from "../package.json"

const tui: TuiPlugin = async (api, options) => {
const store = createRoot(() => createBoardStore(api, options))
Expand All @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions src/update-check.ts
Original file line number Diff line number Diff line change
@@ -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: <Value = unknown>(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<string | undefined> {
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<string | undefined> {
const ttl = deps.ttlMs ?? CHECK_TTL_MS
const cached = kv.get<string | undefined>(LATEST_KEY, undefined)
const lastCheck = kv.get<number>(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<void> {
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)
}
13 changes: 13 additions & 0 deletions test/board.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => <Board api={api} store={store} />, { 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: [
Expand Down
157 changes: 157 additions & 0 deletions test/update-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, test } from "bun:test"
import { checkForUpdate, isNewerRelease, parseRelease, resolveLatestVersion } from "../src/update-check"

function mockKv(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial }
return {
store,
get: <Value>(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([])
})
})