From 02e2fa8b0a7c3e09dfd83392d5cb23c8b2d24c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8D=95?= Date: Fri, 5 Jun 2026 14:46:53 +0800 Subject: [PATCH] feat: implement update check mechanism and notify users of available updates --- src/assets/prompts/konteks-warm-up.md | 2 + src/entrypoints/cli/commands/_base-command.ts | 20 ++ src/entrypoints/mcp/tools/warm-up-mcp-tool.ts | 22 +- src/env.d.ts | 11 + src/support/update-check.ts | 207 ++++++++++++++++++ tests/features/commands/update-notice.test.ts | 79 +++++++ tests/features/mcp/prompts.test.ts | 3 + .../providers/protocol/warm-up.test.ts | 34 +++ tests/features/support/update-check.test.ts | 117 ++++++++++ 9 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 src/support/update-check.ts create mode 100644 tests/features/commands/update-notice.test.ts create mode 100644 tests/features/support/update-check.test.ts diff --git a/src/assets/prompts/konteks-warm-up.md b/src/assets/prompts/konteks-warm-up.md index f000c5cf..0d385f21 100644 --- a/src/assets/prompts/konteks-warm-up.md +++ b/src/assets/prompts/konteks-warm-up.md @@ -12,4 +12,6 @@ Optional focus: {{focus}} If the optional focus is non-empty, pass it to `konteks_warm_up` as a single item in the `focus` array. If it is empty, omit `focus`. +If the warm-up result includes an `update` object, mention the available Konteks update and its command before the ready message. If no `update` object is present, do not mention updates. + After context is loaded, do not summarize or re-explain what you found unless the user explicitly asks. Reply only: `Konteks is warmed up and ready for the task.` no further action is required. diff --git a/src/entrypoints/cli/commands/_base-command.ts b/src/entrypoints/cli/commands/_base-command.ts index cfa739a3..a7e2a687 100644 --- a/src/entrypoints/cli/commands/_base-command.ts +++ b/src/entrypoints/cli/commands/_base-command.ts @@ -1,6 +1,7 @@ import type { Command as CommanderCommand } from 'commander' import consoleOutput from '@/support/console-output' import getVersion from '@/support/get-version' +import { checkForKonteksUpdate } from '@/support/update-check' export type Command = CommanderCommand @@ -78,6 +79,10 @@ export default abstract class BaseCommand< args, options: commandOptions, }) + + if (this.name !== 'mcp') { + await printUpdateNotice() + } }) } @@ -108,6 +113,21 @@ export default abstract class BaseCommand< } } +async function printUpdateNotice(): Promise { + const update = await checkForKonteksUpdate() + if (!update) { + return + } + + consoleOutput.print(color => + [ + '', + `${color.warning('Update available')}: konteks-cli ${update.current} -> ${update.latest}`, + `${color.dim('Run')}: ${color.primary(update.command)}`, + ].join('\n'), + ) +} + function stripCommanderActionValues( values: unknown[], options: Options, diff --git a/src/entrypoints/mcp/tools/warm-up-mcp-tool.ts b/src/entrypoints/mcp/tools/warm-up-mcp-tool.ts index 5cc1e912..0532aef0 100644 --- a/src/entrypoints/mcp/tools/warm-up-mcp-tool.ts +++ b/src/entrypoints/mcp/tools/warm-up-mcp-tool.ts @@ -7,6 +7,7 @@ import { updateChangedProjectMemorySilently, } from '@/modules/memory/runtime' import { estimateCharacterTokens } from '@/support/format/tokens' +import { checkForKonteksUpdate } from '@/support/update-check' import type { MemorySearchResult, RecallPackage, @@ -62,15 +63,24 @@ export default class WarmUpMcpTool extends BaseMcpTool { ) } - return toWarmUpOutput({ recall, warmUp }) + return toWarmUpOutput({ + recall, + update: await checkForKonteksUpdate(), + warmUp, + }) } } function toWarmUpOutput(input: { warmUp: WarmUpContext recall?: RecallPackage + update?: { + command: string + current: string + latest: string + } }): object { - const { warmUp, recall } = input + const { warmUp, recall, update } = input return { description: warmUp.description, entryPoints: warmUp.entryPoints, @@ -78,6 +88,14 @@ function toWarmUpOutput(input: { highlights: warmUp.highlights.map(toWarmUpHighlightOutput), recall: recall ? toFocusedRecallOutput(recall) : undefined, technologies: warmUp.technologies, + update: update + ? { + available: true, + command: update.command, + current: update.current, + latest: update.latest, + } + : undefined, } } diff --git a/src/env.d.ts b/src/env.d.ts index f09e639d..b36128e5 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -31,3 +31,14 @@ declare module '*.wasm' { const path: string export default path } + +declare var __konteksCheckForUpdateForTests: + | (() => Promise< + | { + command: string + current: string + latest: string + } + | undefined + >) + | undefined diff --git a/src/support/update-check.ts b/src/support/update-check.ts new file mode 100644 index 00000000..f113d0b6 --- /dev/null +++ b/src/support/update-check.ts @@ -0,0 +1,207 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import getVersion from './get-version' + +const PACKAGE_NAME = 'konteks-cli' +const REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest` +const CACHE_TTL_MS = 24 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 750 + +export type UpdateNotice = { + command: string + current: string + latest: string +} + +type UpdateCache = { + checkedAt: string + latest?: string +} + +type UpdateCheckOptions = { + cacheDir?: string + currentVersion?: string + fetchLatestVersion?: () => Promise + now?: Date + ttlMs?: number +} + +export async function checkForKonteksUpdate( + options: UpdateCheckOptions = {}, +): Promise { + if (globalThis.__konteksCheckForUpdateForTests) { + return globalThis.__konteksCheckForUpdateForTests() + } + if ( + process.env.KONTEKS_SQLITE_TEST_DATABASE && + !options.fetchLatestVersion + ) { + return undefined + } + + try { + const current = options.currentVersion ?? getVersion() + const now = options.now ?? new Date() + const ttlMs = options.ttlMs ?? CACHE_TTL_MS + const cacheDir = options.cacheDir ?? updateCacheDir() + const cached = await readCachedUpdate(cacheDir) + + if (cached && now.getTime() - Date.parse(cached.checkedAt) < ttlMs) { + return cached.latest + ? noticeFromVersions(current, cached.latest) + : undefined + } + + const latest = await ( + options.fetchLatestVersion ?? fetchLatestVersion + )().catch(() => undefined) + if (!latest) { + await writeCachedUpdate(cacheDir, { + checkedAt: now.toISOString(), + }) + return undefined + } + + await writeCachedUpdate(cacheDir, { + checkedAt: now.toISOString(), + latest, + }) + + return noticeFromVersions(current, latest) + } catch { + return undefined + } +} + +function noticeFromVersions( + current: string, + latest: string, +): UpdateNotice | undefined { + return isVersionNewer(latest, current) + ? { + command: updateCommand(), + current, + latest, + } + : undefined +} + +async function fetchLatestVersion(): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) + + try { + const response = await fetch(REGISTRY_LATEST_URL, { + headers: { accept: 'application/json' }, + signal: controller.signal, + }) + if (!response.ok) { + return undefined + } + + const payload = (await response.json()) as { version?: unknown } + return typeof payload.version === 'string' ? payload.version : undefined + } catch { + return undefined + } finally { + clearTimeout(timeout) + } +} + +async function readCachedUpdate( + cacheDir: string, +): Promise { + try { + const parsed = JSON.parse( + await readFile(updateCachePath(cacheDir), 'utf8'), + ) as { + checkedAt?: unknown + latest?: unknown + } + + if (typeof parsed.checkedAt === 'string') { + return { + checkedAt: parsed.checkedAt, + latest: + typeof parsed.latest === 'string' + ? parsed.latest + : undefined, + } + } + } catch { + return undefined + } + + return undefined +} + +async function writeCachedUpdate( + cacheDir: string, + cache: UpdateCache, +): Promise { + try { + await mkdir(cacheDir, { recursive: true }) + await writeFile( + updateCachePath(cacheDir), + JSON.stringify(cache), + 'utf8', + ) + } catch { + // Cache writes are best-effort. + } +} + +function updateCachePath(cacheDir: string): string { + return join(cacheDir, 'update-check.json') +} + +function updateCacheDir(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME + if (xdgCacheHome) { + return join(xdgCacheHome, 'konteks') + } + + const localAppData = process.env.LOCALAPPDATA + if (localAppData) { + return join(localAppData, 'konteks', 'cache') + } + + return join(homedir(), '.cache', 'konteks') +} + +function updateCommand(): string { + const userAgent = process.env.npm_config_user_agent?.toLowerCase() ?? '' + return userAgent.includes('bun') || process.versions.bun + ? 'bun add -g konteks-cli' + : 'npm install -g konteks-cli' +} + +function isVersionNewer(candidate: string, current: string): boolean { + const candidateParts = parseVersion(candidate) + const currentParts = parseVersion(current) + + if (!candidateParts || !currentParts) { + return false + } + + for (let index = 0; index < candidateParts.length; index += 1) { + if (candidateParts[index] > currentParts[index]) { + return true + } + if (candidateParts[index] < currentParts[index]) { + return false + } + } + + return false +} + +function parseVersion(version: string): [number, number, number] | undefined { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/u.exec(version) + if (!match) { + return undefined + } + + return [Number(match[1]), Number(match[2]), Number(match[3])] +} diff --git a/tests/features/commands/update-notice.test.ts b/tests/features/commands/update-notice.test.ts new file mode 100644 index 00000000..a049bc74 --- /dev/null +++ b/tests/features/commands/update-notice.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test' +import { Command } from 'commander' +import BaseCommand from '@/entrypoints/cli/commands/_base-command' +import consoleOutput from '@/support/console-output' + +class FixtureCommand extends BaseCommand { + public readonly description = 'Fixture command.' + public readonly name: string + + public constructor(name = 'status') { + super() + this.name = name + } + + public handle(): void { + this.consoleOutput.print('command complete') + } +} + +describe('CLI update notices', () => { + afterEach(() => { + globalThis.__konteksCheckForUpdateForTests = undefined + }) + + it('prints an update notice after successful human CLI commands', async () => { + globalThis.__konteksCheckForUpdateForTests = async () => ({ + command: 'bun add -g konteks-cli', + current: '1.0.0', + latest: '1.1.0', + }) + const output = await runFixtureCommand('status') + + expect(output).toContain('command complete') + expect(output).toContain('Update available') + expect(output).toContain('konteks-cli 1.0.0 -> 1.1.0') + expect(output).toContain('bun add -g konteks-cli') + expect(output.indexOf('command complete')).toBeLessThan( + output.indexOf('Update available'), + ) + }) + + it('does not print an update notice for the MCP server command', async () => { + globalThis.__konteksCheckForUpdateForTests = async () => ({ + command: 'bun add -g konteks-cli', + current: '1.0.0', + latest: '1.1.0', + }) + const output = await runFixtureCommand('mcp') + + expect(output).toContain('command complete') + expect(output).not.toContain('Update available') + }) +}) + +async function runFixtureCommand(name: string): Promise { + const messages: string[] = [] + const printSpy = spyOn(consoleOutput, 'print').mockImplementation( + message => { + messages.push( + typeof message === 'function' + ? message(consoleOutput.colorPalette) + : String(message), + ) + return consoleOutput + }, + ) + + try { + const program = new Command() + new FixtureCommand(name).register(program, { + runInitializationGuard: async () => {}, + }) + await program.parseAsync([name], { from: 'user' }) + } finally { + printSpy.mockRestore() + } + + return messages.join('\n') +} diff --git a/tests/features/mcp/prompts.test.ts b/tests/features/mcp/prompts.test.ts index 02ec7d0f..2a15d520 100644 --- a/tests/features/mcp/prompts.test.ts +++ b/tests/features/mcp/prompts.test.ts @@ -67,6 +67,9 @@ describe('mcp/prompts', () => { }, ]) expect(result?.messages[0]?.content.text).toContain('konteks_warm_up') + expect(result?.messages[0]?.content.text).toContain( + 'If the warm-up result includes an `update` object', + ) }) it('renders missing optional placeholders as empty strings', () => { diff --git a/tests/features/providers/protocol/warm-up.test.ts b/tests/features/providers/protocol/warm-up.test.ts index 1365f0e2..ad2aa674 100644 --- a/tests/features/providers/protocol/warm-up.test.ts +++ b/tests/features/providers/protocol/warm-up.test.ts @@ -41,10 +41,12 @@ describe('konteks_warm_up', () => { previousDisableSharedEmbeddingProvider = process.env.KONTEKS_DISABLE_SHARED_EMBEDDING_PROVIDER process.env.KONTEKS_DISABLE_SHARED_EMBEDDING_PROVIDER = '1' + globalThis.__konteksCheckForUpdateForTests = async () => undefined }) afterEach(async () => { await globalThis.__konteksWaitForMemoryMaintenanceForTests?.() + globalThis.__konteksCheckForUpdateForTests = undefined if (previousDisableSharedEmbeddingProvider === undefined) { delete process.env.KONTEKS_DISABLE_SHARED_EMBEDDING_PROVIDER } else { @@ -123,6 +125,38 @@ describe('konteks_warm_up', () => { ) }) + it('includes an update notice only when an update is available', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'konteks-warm-up-')) + tempDirs.push(projectRoot) + await mkdir(join(projectRoot, 'src')) + await mkdir(join(projectRoot, '.git')) + await writeFile(join(projectRoot, 'src', 'index.txt'), 'export {}\n') + + const context = await withProjectRoot(projectRoot, () => + loadProjectContext(), + ) + await withProjectRoot(projectRoot, () => + extractProject(context, 'full', extractionOptions()), + ) + + globalThis.__konteksCheckForUpdateForTests = async () => ({ + command: 'bun add -g konteks-cli', + current: '1.0.0', + latest: '1.1.0', + }) + + const result = await withProjectRoot(projectRoot, () => + callKonteksTool('konteks_warm_up', {}), + ) + const text = extractText(result) + + expect(text).toContain('update:') + expect(text).toContain('available: true') + expect(text).toContain('current: 1.0.0') + expect(text).toContain('latest: 1.1.0') + expect(text).toContain('command: bun add -g konteks-cli') + }) + it('updates changed project memory before warming up', async () => { const projectRoot = await mkdtemp(join(tmpdir(), 'konteks-warm-up-')) tempDirs.push(projectRoot) diff --git a/tests/features/support/update-check.test.ts b/tests/features/support/update-check.test.ts new file mode 100644 index 00000000..80e9e328 --- /dev/null +++ b/tests/features/support/update-check.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { rm } from '@/support/file-manager' +import { checkForKonteksUpdate } from '@/support/update-check' + +describe('support/update-check', () => { + const tempDirs: string[] = [] + + afterEach(async () => { + globalThis.__konteksCheckForUpdateForTests = undefined + for (const dir of tempDirs) { + await rm(dir) + } + tempDirs.splice(0) + }) + + it('returns an update notice when the registry version is newer', async () => { + const cacheDir = await makeCacheDir() + + const update = await checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.2.3', + fetchLatestVersion: async () => '1.3.0', + now: new Date('2026-06-05T00:00:00.000Z'), + }) + + expect(update).toEqual({ + command: 'bun add -g konteks-cli', + current: '1.2.3', + latest: '1.3.0', + }) + }) + + it('does not return a notice when the registry version is not newer', async () => { + const cacheDir = await makeCacheDir() + + const update = await checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.2.3', + fetchLatestVersion: async () => '1.2.3', + now: new Date('2026-06-05T00:00:00.000Z'), + }) + + expect(update).toBeUndefined() + }) + + it('uses a fresh cached version without fetching again', async () => { + const cacheDir = await makeCacheDir() + await checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.0.0', + fetchLatestVersion: async () => '1.1.0', + now: new Date('2026-06-05T00:00:00.000Z'), + }) + + const update = await checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.0.0', + fetchLatestVersion: async () => { + throw new Error('should not fetch') + }, + now: new Date('2026-06-05T01:00:00.000Z'), + }) + + expect(update?.latest).toBe('1.1.0') + expect( + await readFile(join(cacheDir, 'update-check.json'), 'utf8'), + ).toContain('"latest":"1.1.0"') + }) + + it('swallows fetch failures', async () => { + const cacheDir = await makeCacheDir() + + await expect( + checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.0.0', + fetchLatestVersion: async () => { + throw new Error('registry unavailable') + }, + now: new Date('2026-06-05T00:00:00.000Z'), + }), + ).resolves.toBeUndefined() + expect( + await readFile(join(cacheDir, 'update-check.json'), 'utf8'), + ).toContain('"checkedAt":"2026-06-05T00:00:00.000Z"') + }) + + it('uses a fresh cached failed check without fetching again', async () => { + const cacheDir = await makeCacheDir() + await checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.0.0', + fetchLatestVersion: async () => undefined, + now: new Date('2026-06-05T00:00:00.000Z'), + }) + + const update = await checkForKonteksUpdate({ + cacheDir, + currentVersion: '1.0.0', + fetchLatestVersion: async () => { + throw new Error('should not fetch') + }, + now: new Date('2026-06-05T01:00:00.000Z'), + }) + + expect(update).toBeUndefined() + }) + + async function makeCacheDir(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'konteks-update-check-')) + tempDirs.push(dir) + return dir + } +})