From 05ebcac7691c33bad86d9e7e47331ce6e73767c2 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 2 Jan 2026 15:06:36 +0000 Subject: [PATCH] feat(cli): Add 24-hour caching to version update checker - Implement file-based cache for version check results - Cache stored in ~/.kilocode/cli/global/version-check-cache.json - Reduces npm registry API calls from every invocation to once per day - Gracefully handles cache corruption and network errors - Falls back to cached data on API failures - Add comprehensive test coverage for caching logic Resolves requirement for rate-limiting version checks --- .changeset/cli-version-check-caching.md | 5 + cli/src/utils/__tests__/auto-update.test.ts | 295 ++++++++++++++++++++ cli/src/utils/auto-update.ts | 115 +++++++- 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-version-check-caching.md create mode 100644 cli/src/utils/__tests__/auto-update.test.ts diff --git a/.changeset/cli-version-check-caching.md b/.changeset/cli-version-check-caching.md new file mode 100644 index 00000000000..ae437abf919 --- /dev/null +++ b/.changeset/cli-version-check-caching.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Added caching to version update checker to avoid checking npm registry on every CLI invocation. The CLI now caches version check results for 24 hours, reducing network requests and improving startup performance. diff --git a/cli/src/utils/__tests__/auto-update.test.ts b/cli/src/utils/__tests__/auto-update.test.ts new file mode 100644 index 00000000000..15e6eac3426 --- /dev/null +++ b/cli/src/utils/__tests__/auto-update.test.ts @@ -0,0 +1,295 @@ +// kilocode_change - new file +/** + * Tests for auto-update utilities + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { getAutoUpdateStatus, generateUpdateAvailableMessage } from "../auto-update.js" +import * as fs from "fs" +import * as path from "path" +import packageJson from "package-json" +import { KiloCodePaths } from "../paths.js" + +// Mock dependencies +vi.mock("package-json") +vi.mock("fs") +vi.mock("../paths.js") + +describe("auto-update utilities", () => { + const mockGlobalStorageDir = "/mock/global/storage" + const mockCacheFilePath = path.join(mockGlobalStorageDir, "version-check-cache.json") + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + // Mock KiloCodePaths + vi.mocked(KiloCodePaths.getGlobalStorageDir).mockReturnValue(mockGlobalStorageDir) + vi.mocked(KiloCodePaths.ensureDirectoryExists).mockImplementation(() => {}) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("getAutoUpdateStatus", () => { + it("should fetch latest version from npm when no cache exists", async () => { + // Mock no cache file + vi.mocked(fs.existsSync).mockReturnValue(false) + + // Mock npm registry response + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "1.0.0", + } as any) + + const result = await getAutoUpdateStatus() + + expect(result).toMatchObject({ + name: "@kilocode/cli", + latestVersion: "1.0.0", + }) + expect(packageJson).toHaveBeenCalledWith("@kilocode/cli") + }) + + it("should detect when current version is outdated", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + // Mock a newer version available + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "99.0.0", + } as any) + + const result = await getAutoUpdateStatus() + + expect(result.isOutdated).toBe(true) + expect(result.latestVersion).toBe("99.0.0") + }) + + it("should detect when current version is up to date", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + // Mock same version + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "0.18.1", // Current version from package.json + } as any) + + const result = await getAutoUpdateStatus() + + expect(result.isOutdated).toBe(false) + }) + + it("should use cached data when cache is valid (less than 24 hours old)", async () => { + const now = Date.now() + vi.setSystemTime(now) + + const mockCache = { + lastChecked: now - 1000 * 60 * 60, // 1 hour ago + latestVersion: "1.5.0", + isOutdated: true, + } + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockCache)) + + const result = await getAutoUpdateStatus() + + expect(result.latestVersion).toBe("1.5.0") + expect(result.isOutdated).toBe(true) + // Should not call npm registry + expect(packageJson).not.toHaveBeenCalled() + }) + + it("should fetch new data when cache is expired (more than 24 hours old)", async () => { + const now = Date.now() + vi.setSystemTime(now) + + const mockCache = { + lastChecked: now - 1000 * 60 * 60 * 25, // 25 hours ago + latestVersion: "1.0.0", + isOutdated: false, + } + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockCache)) + + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "2.0.0", + } as any) + + const result = await getAutoUpdateStatus() + + expect(result.latestVersion).toBe("2.0.0") + // Should call npm registry because cache is expired + expect(packageJson).toHaveBeenCalled() + }) + + it("should write cache after successful npm fetch", async () => { + const now = Date.now() + vi.setSystemTime(now) + + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "1.5.0", + } as any) + + await getAutoUpdateStatus() + + expect(fs.writeFileSync).toHaveBeenCalledWith( + mockCacheFilePath, + expect.stringContaining('"latestVersion":"1.5.0"'), + "utf-8", + ) + }) + + it("should handle npm registry errors gracefully and return default", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(packageJson).mockRejectedValue(new Error("Network error")) + + const result = await getAutoUpdateStatus() + + expect(result).toMatchObject({ + name: "@kilocode/cli", + isOutdated: false, + currentVersion: expect.any(String), + latestVersion: expect.any(String), + }) + }) + + it("should use cached data on npm error if cache exists", async () => { + const mockCache = { + lastChecked: Date.now() - 1000 * 60 * 60 * 25, // Expired cache + latestVersion: "1.2.0", + isOutdated: true, + } + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockCache)) + vi.mocked(packageJson).mockRejectedValue(new Error("Network error")) + + const result = await getAutoUpdateStatus() + + // Should fall back to cached data even though it's expired + expect(result.latestVersion).toBe("1.2.0") + expect(result.isOutdated).toBe(true) + }) + + it("should handle corrupted cache file gracefully", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue("invalid json{") + + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "1.0.0", + } as any) + + const result = await getAutoUpdateStatus() + + // Should fetch from npm when cache is corrupted + expect(packageJson).toHaveBeenCalled() + expect(result.latestVersion).toBe("1.0.0") + }) + + it("should handle cache with invalid structure", async () => { + const invalidCache = { + lastChecked: "not a number", + latestVersion: 123, // Should be string + isOutdated: "yes", // Should be boolean + } + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidCache)) + + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "1.0.0", + } as any) + + const result = await getAutoUpdateStatus() + + // Should fetch from npm when cache structure is invalid + expect(packageJson).toHaveBeenCalled() + expect(result.latestVersion).toBe("1.0.0") + }) + + it("should handle file system errors when writing cache", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error("Permission denied") + }) + + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "1.0.0", + } as any) + + // Should not throw error even if cache write fails + const result = await getAutoUpdateStatus() + + expect(result.latestVersion).toBe("1.0.0") + }) + + it("should ensure global storage directory exists before writing cache", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(packageJson).mockResolvedValue({ + name: "@kilocode/cli", + version: "1.0.0", + } as any) + + await getAutoUpdateStatus() + + expect(KiloCodePaths.ensureDirectoryExists).toHaveBeenCalledWith(mockGlobalStorageDir) + }) + }) + + describe("generateUpdateAvailableMessage", () => { + it("should generate correct update message", () => { + const status = { + name: "@kilocode/cli", + isOutdated: true, + currentVersion: "0.18.1", + latestVersion: "1.0.0", + } + + const message = generateUpdateAvailableMessage(status) + + expect(message.type).toBe("system") + expect(message.content).toContain("A new version of Kilo CLI is available!") + expect(message.content).toContain("v0.18.1") + expect(message.content).toContain("v1.0.0") + expect(message.content).toContain("npm install -g @kilocode/cli") + }) + + it("should include package name in install command", () => { + const status = { + name: "@kilocode/cli", + isOutdated: true, + currentVersion: "1.0.0", + latestVersion: "2.0.0", + } + + const message = generateUpdateAvailableMessage(status) + + expect(message.content).toContain("npm install -g @kilocode/cli") + }) + + it("should have proper message structure", () => { + const status = { + name: "@kilocode/cli", + isOutdated: true, + currentVersion: "1.0.0", + latestVersion: "2.0.0", + } + + const message = generateUpdateAvailableMessage(status) + + expect(message).toHaveProperty("type") + expect(message).toHaveProperty("content") + expect(message).toHaveProperty("ts") + }) + }) +}) diff --git a/cli/src/utils/auto-update.ts b/cli/src/utils/auto-update.ts index f81744a3ef6..7bbcadb5281 100644 --- a/cli/src/utils/auto-update.ts +++ b/cli/src/utils/auto-update.ts @@ -1,8 +1,15 @@ +// kilocode_change start +import fs from "fs" +import path from "path" +// kilocode_change end import packageJson from "package-json" import { Package } from "../constants/package.js" import { CliMessage } from "../types/cli.js" import semver from "semver" import { generateMessage } from "../ui/utils/messages.js" +// kilocode_change start +import { KiloCodePaths } from "./paths.js" +// kilocode_change end type AutoUpdateStatus = { name: string @@ -11,6 +18,79 @@ type AutoUpdateStatus = { latestVersion: string } +// kilocode_change start +type VersionCheckCache = { + lastChecked: number + latestVersion: string + isOutdated: boolean +} + +const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours + +/** + * Get the path to the version check cache file + */ +const getCacheFilePath = (): string => { + const globalStorageDir = KiloCodePaths.getGlobalStorageDir() + KiloCodePaths.ensureDirectoryExists(globalStorageDir) + return path.join(globalStorageDir, "version-check-cache.json") +} + +/** + * Read the version check cache from disk + */ +const readCache = (): VersionCheckCache | null => { + try { + const cacheFilePath = getCacheFilePath() + if (!fs.existsSync(cacheFilePath)) { + return null + } + + const cacheContent = fs.readFileSync(cacheFilePath, "utf-8") + const cache = JSON.parse(cacheContent) as VersionCheckCache + + // Validate cache structure + if ( + typeof cache.lastChecked !== "number" || + typeof cache.latestVersion !== "string" || + typeof cache.isOutdated !== "boolean" + ) { + return null + } + + return cache + } catch { + return null + } +} + +/** + * Write the version check cache to disk + */ +const writeCache = (cache: VersionCheckCache): void => { + try { + const cacheFilePath = getCacheFilePath() + fs.writeFileSync(cacheFilePath, JSON.stringify(cache, null, 2), "utf-8") + } catch { + // Silent fail - caching is optional + } +} + +/** + * Check if the cache is still valid (less than 24 hours old) + */ +const isCacheValid = (cache: VersionCheckCache | null): boolean => { + if (!cache) { + return false + } + + const now = Date.now() + const age = now - cache.lastChecked + + return age < CACHE_DURATION_MS +} +// kilocode_change end + export const getAutoUpdateStatus = async () => { const output = { name: Package.name, @@ -19,14 +99,47 @@ export const getAutoUpdateStatus = async () => { latestVersion: Package.version, } + // kilocode_change start + // Check cache first + const cache = readCache() + if (isCacheValid(cache)) { + return { + ...output, + isOutdated: cache.isOutdated, + latestVersion: cache.latestVersion, + } + } + // kilocode_change end + try { const latestPackage = await packageJson(Package.name) + // kilocode_change start + const isOutdated = semver.lt(Package.version, latestPackage.version) + + // Update cache + writeCache({ + lastChecked: Date.now(), + latestVersion: latestPackage.version, + isOutdated, + }) + return { ...output, - isOutdated: semver.lt(Package.version, latestPackage.version), + isOutdated, latestVersion: latestPackage.version, } + // kilocode_change end } catch { + // kilocode_change start + // On error, return cached data if available, otherwise return default + if (cache) { + return { + ...output, + isOutdated: cache.isOutdated, + latestVersion: cache.latestVersion, + } + } + // kilocode_change end return output } }