From 92bb0ed6e6ce6701c936e92e73d56bd8165a6592 Mon Sep 17 00:00:00 2001 From: anbeltra Date: Fri, 17 Apr 2026 09:53:44 -0600 Subject: [PATCH 1/4] Announce PromptKit version when bootstrap loads Add an instruction to bootstrap.md step 1 so the composition engine emits a one-line 'PromptKit v loaded.' banner after reading manifest.yaml. The version is read from the top-level 'version:' field; a 'version unknown' fallback is used if the field is missing or unreadable, and no version is ever fabricated. This applies to both manual loads and the 'promptkit interactive' (npx) flow, since the CLI stages the same bootstrap.md and manifest.yaml into a temp dir before instructing the LLM to read bootstrap.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bootstrap.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bootstrap.md b/bootstrap.md index cbe3e0a..fbe8123 100644 --- a/bootstrap.md +++ b/bootstrap.md @@ -28,6 +28,13 @@ You are the **composition engine** for PromptKit. Your job is to: ## How to Begin 1. **Read the manifest** at `manifest.yaml` to discover all available components. + Immediately after reading it, **announce the PromptKit version** to the + user by reading the top-level `version:` field from `manifest.yaml` and + emitting a one-line banner such as `PromptKit v loaded.` before + any other output. If the `version:` field is missing or unreadable, say + `PromptKit (version unknown) loaded.` instead — do not fabricate a version + number. Do the same (re-announce the current version) any time you + re-read `bootstrap.md` or `manifest.yaml` later in the session. 2. **Ask the user** what they want to accomplish. Examples: - "I need to write a requirements doc for a new authentication system." - "I need to investigate a memory leak in our C codebase." From 5ababc959f5f993792da284a87abf9aee49b3fea Mon Sep 17 00:00:00 2001 From: anbeltra Date: Fri, 17 Apr 2026 10:11:46 -0600 Subject: [PATCH 2/4] Notify users when a newer PromptKit CLI version is available Adds a best-effort daily update check to 'promptkit interactive'. The CLI queries https://registry.npmjs.org//latest (built-in https, ~1500ms timeout), caches the result in ~/.promptkit/update-check.json for 24h, and prints a boxed banner before spawning the LLM when a newer version exists. No new runtime dependencies. Suppressed by NO_UPDATE_NOTIFIER=1, CI, non-TTY stdout, --no-update-check, and for non-interactive subcommands (list/search/show/--version). Network, cache, and parse failures are silently swallowed and never surface to the user. Adds cli/tests/update-check.test.js with unit coverage for parseVersion, isNewer, formatBanner, and suppressionReason (no network I/O). Updates cli/tests/cli.test.js harness to copy the new lib/update-check.js into the temp CLI root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/bin/cli.js | 18 +++- cli/lib/update-check.js | 173 +++++++++++++++++++++++++++++++++ cli/package.json | 2 +- cli/tests/cli.test.js | 6 ++ cli/tests/update-check.test.js | 150 ++++++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 cli/lib/update-check.js create mode 100644 cli/tests/update-check.test.js diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 9e64730..816b07a 100644 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -6,6 +6,7 @@ const { Command } = require("commander"); const path = require("path"); const fs = require("fs"); const { launchInteractive } = require("../lib/launch"); +const { checkForUpdate, formatBanner } = require("../lib/update-check"); const { loadManifest, allComponents, @@ -61,8 +62,23 @@ program "--dry-run", "Print the spawn command and args without launching the LLM CLI" ) - .action((opts) => { + .option( + "--no-update-check", + "Skip checking the npm registry for a newer PromptKit version" + ) + .action(async (opts) => { ensureContent(); + if (opts.updateCheck !== false) { + try { + const result = await checkForUpdate(pkg.name, pkg.version); + if (result && result.isUpdate) { + console.log(formatBanner(pkg.name, pkg.version, result.latest)); + console.log(); + } + } catch { + // Update checks are strictly best-effort; never fail the CLI over them. + } + } launchInteractive(contentDir, opts.cli || null, { dryRun: !!opts.dryRun }); }); diff --git a/cli/lib/update-check.js b/cli/lib/update-check.js new file mode 100644 index 0000000..e63722e --- /dev/null +++ b/cli/lib/update-check.js @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) PromptKit Contributors + +// cli/lib/update-check.js — best-effort npm registry update check for the +// PromptKit CLI. All network and filesystem operations are wrapped so that +// any failure (timeout, DNS, bad JSON, unwritable cache dir, etc.) is +// swallowed — an update check must never block or break the CLI. + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const https = require("https"); + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 1500; +const REGISTRY_BASE = "https://registry.npmjs.org"; + +function cachePath() { + return path.join(os.homedir(), ".promptkit", "update-check.json"); +} + +function readCache() { + try { + return JSON.parse(fs.readFileSync(cachePath(), "utf8")); + } catch { + return null; + } +} + +function writeCache(data) { + try { + const file = cachePath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data)); + } catch { + // Best-effort only; cache failures must never surface. + } +} + +// Parse a version string into [major, minor, patch]. Strips an optional +// leading 'v' and ignores any prerelease/build suffix after the patch number. +// Returns null for unparseable input. +function parseVersion(v) { + const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "")); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function isNewer(candidate, current) { + const a = parseVersion(candidate); + const b = parseVersion(current); + if (!a || !b) return false; + for (let i = 0; i < 3; i++) { + if (a[i] > b[i]) return true; + if (a[i] < b[i]) return false; + } + return false; +} + +function formatBanner(pkgName, current, latest) { + const line1 = `Update available: ${current} -> ${latest}`; + const line2 = `Run: npm i -g ${pkgName}`; + const inner = Math.max(line1.length, line2.length); + const bar = "-".repeat(inner + 2); + return ( + `+${bar}+\n` + + `| ${line1.padEnd(inner)} |\n` + + `| ${line2.padEnd(inner)} |\n` + + `+${bar}+` + ); +} + +function fetchLatest(pkgName) { + return new Promise((resolve) => { + const url = `${REGISTRY_BASE}/${pkgName}/latest`; + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + try { + const req = https.get( + url, + { timeout: FETCH_TIMEOUT_MS, headers: { Accept: "application/json" } }, + (res) => { + if (res.statusCode !== 200) { + res.resume(); + return done(null); + } + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + // Hard cap to avoid unbounded memory on a misbehaving registry. + if (body.length > 64 * 1024) { + req.destroy(); + done(null); + } + }); + res.on("end", () => { + try { + const json = JSON.parse(body); + done(typeof json.version === "string" ? json.version : null); + } catch { + done(null); + } + }); + res.on("error", () => done(null)); + } + ); + req.on("timeout", () => { + req.destroy(); + done(null); + }); + req.on("error", () => done(null)); + } catch { + done(null); + } + }); +} + +// Decide whether update checking should be performed in this invocation. +// Returns a short string describing the suppression reason, or null if the +// check should proceed. +function suppressionReason({ force = false, ttyOverride } = {}) { + if (force) return null; + if (process.env.NO_UPDATE_NOTIFIER === "1") return "NO_UPDATE_NOTIFIER"; + if (process.env.CI) return "CI"; + const isTty = ttyOverride !== undefined ? ttyOverride : !!process.stdout.isTTY; + if (!isTty) return "non-tty"; + return null; +} + +async function checkForUpdate( + pkgName, + currentVersion, + { force = false, now = Date.now() } = {} +) { + if (suppressionReason({ force })) return null; + + const cache = readCache(); + let latest = null; + + if ( + !force && + cache && + cache.pkg === pkgName && + typeof cache.latest === "string" && + typeof cache.checkedAt === "number" && + now - cache.checkedAt < CACHE_TTL_MS + ) { + latest = cache.latest; + } else { + latest = await fetchLatest(pkgName); + if (latest) { + writeCache({ pkg: pkgName, latest, checkedAt: now }); + } + } + + if (!latest) return null; + return { latest, isUpdate: isNewer(latest, currentVersion) }; +} + +module.exports = { + checkForUpdate, + formatBanner, + isNewer, + parseVersion, + suppressionReason, + // Exported for tests that need to bypass the real paths. + _internals: { cachePath, readCache, writeCache, fetchLatest }, +}; diff --git a/cli/package.json b/cli/package.json index 0ddfe91..2e54c00 100644 --- a/cli/package.json +++ b/cli/package.json @@ -20,7 +20,7 @@ "prepublishOnly": "node scripts/copy-content.js", "prepare": "node scripts/copy-content.js", "pretest": "node scripts/copy-content.js", - "test": "node --test --test-concurrency=1 tests/cli.test.js tests/list.test.js tests/search-show.test.js tests/launch.test.js tests/copy-content.test.js" + "test": "node --test --test-concurrency=1 tests/cli.test.js tests/list.test.js tests/search-show.test.js tests/launch.test.js tests/copy-content.test.js tests/update-check.test.js" }, "dependencies": { "commander": "^12.0.0", diff --git a/cli/tests/cli.test.js b/cli/tests/cli.test.js index 29003bc..1d29654 100644 --- a/cli/tests/cli.test.js +++ b/cli/tests/cli.test.js @@ -71,6 +71,12 @@ function makeTempContent(removeFiles) { fs.copyFileSync(manifestJs, path.join(tmpLib, "manifest.js")); } + // Copy lib/update-check.js (required by bin/cli.js) + const updateCheckJs = path.resolve(__dirname, "..", "lib", "update-check.js"); + if (fs.existsSync(updateCheckJs)) { + fs.copyFileSync(updateCheckJs, path.join(tmpLib, "update-check.js")); + } + // Copy node_modules (symlink for speed) const srcModules = path.resolve(__dirname, "..", "node_modules"); const destModules = path.join(tmpCli, "node_modules"); diff --git a/cli/tests/update-check.test.js b/cli/tests/update-check.test.js new file mode 100644 index 0000000..b474b83 --- /dev/null +++ b/cli/tests/update-check.test.js @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) PromptKit Contributors + +// cli/tests/update-check.test.js — unit tests for the update-check module. +// Exercises pure functions (version parsing, comparison, banner formatting) +// and the suppression-reason logic. Does not hit the network. + +const { describe, it } = require("node:test"); +const assert = require("node:assert"); + +const { + parseVersion, + isNewer, + formatBanner, + suppressionReason, + checkForUpdate, +} = require("../lib/update-check"); + +describe("parseVersion", () => { + it("parses plain semver", () => { + assert.deepStrictEqual(parseVersion("1.2.3"), [1, 2, 3]); + }); + + it("strips leading 'v'", () => { + assert.deepStrictEqual(parseVersion("v0.6.1"), [0, 6, 1]); + }); + + it("ignores prerelease and build metadata after patch", () => { + assert.deepStrictEqual(parseVersion("1.2.3-rc.1"), [1, 2, 3]); + assert.deepStrictEqual(parseVersion("1.2.3+build.5"), [1, 2, 3]); + }); + + it("returns null for garbage input", () => { + assert.strictEqual(parseVersion(""), null); + assert.strictEqual(parseVersion("not-a-version"), null); + assert.strictEqual(parseVersion(undefined), null); + assert.strictEqual(parseVersion(null), null); + assert.strictEqual(parseVersion("1.2"), null); + }); +}); + +describe("isNewer", () => { + it("detects major, minor, and patch bumps", () => { + assert.strictEqual(isNewer("1.0.0", "0.9.9"), true); + assert.strictEqual(isNewer("0.7.0", "0.6.9"), true); + assert.strictEqual(isNewer("0.6.2", "0.6.1"), true); + }); + + it("returns false for equal versions", () => { + assert.strictEqual(isNewer("0.6.1", "0.6.1"), false); + }); + + it("returns false when candidate is older", () => { + assert.strictEqual(isNewer("0.5.0", "0.6.0"), false); + assert.strictEqual(isNewer("0.6.0", "0.6.1"), false); + assert.strictEqual(isNewer("0.9.9", "1.0.0"), false); + }); + + it("returns false on unparseable input", () => { + assert.strictEqual(isNewer("bogus", "0.6.1"), false); + assert.strictEqual(isNewer("0.6.1", "bogus"), false); + }); +}); + +describe("formatBanner", () => { + it("produces a boxed banner containing both versions and the package", () => { + const banner = formatBanner("@alan-jowett/promptkit", "0.6.1", "0.7.0"); + assert.match(banner, /Update available: 0\.6\.1 -> 0\.7\.0/); + assert.match(banner, /npm i -g @alan-jowett\/promptkit/); + // Four lines: top bar, two content lines, bottom bar. + assert.strictEqual(banner.split("\n").length, 4); + }); + + it("pads content lines to the same width", () => { + const banner = formatBanner("pkg", "1.0.0", "2.0.0"); + const lines = banner.split("\n"); + assert.strictEqual(lines[0].length, lines[3].length); + assert.strictEqual(lines[1].length, lines[2].length); + assert.strictEqual(lines[0].length, lines[1].length); + }); +}); + +describe("suppressionReason", () => { + function withEnv(overrides, fn) { + const saved = {}; + for (const key of Object.keys(overrides)) { + saved[key] = process.env[key]; + if (overrides[key] === undefined) delete process.env[key]; + else process.env[key] = overrides[key]; + } + try { + return fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } + } + + it("returns 'NO_UPDATE_NOTIFIER' when the env var is '1'", () => { + withEnv({ NO_UPDATE_NOTIFIER: "1", CI: undefined }, () => { + assert.strictEqual( + suppressionReason({ ttyOverride: true }), + "NO_UPDATE_NOTIFIER" + ); + }); + }); + + it("returns 'CI' when CI env var is set", () => { + withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: "true" }, () => { + assert.strictEqual(suppressionReason({ ttyOverride: true }), "CI"); + }); + }); + + it("returns 'non-tty' when stdout is not a TTY", () => { + withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: undefined }, () => { + assert.strictEqual(suppressionReason({ ttyOverride: false }), "non-tty"); + }); + }); + + it("returns null when checks should proceed", () => { + withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: undefined }, () => { + assert.strictEqual(suppressionReason({ ttyOverride: true }), null); + }); + }); + + it("force: true bypasses all suppressions", () => { + withEnv({ NO_UPDATE_NOTIFIER: "1", CI: "true" }, () => { + assert.strictEqual( + suppressionReason({ force: true, ttyOverride: false }), + null + ); + }); + }); +}); + +describe("checkForUpdate (suppression paths)", () => { + it("returns null immediately when suppressed, without network I/O", async () => { + const saved = process.env.NO_UPDATE_NOTIFIER; + process.env.NO_UPDATE_NOTIFIER = "1"; + try { + const result = await checkForUpdate("@alan-jowett/promptkit", "0.6.1"); + assert.strictEqual(result, null); + } finally { + if (saved === undefined) delete process.env.NO_UPDATE_NOTIFIER; + else process.env.NO_UPDATE_NOTIFIER = saved; + } + }); +}); From 4376f5f0009a3d2db9e9495c8e2d789ff59f0805 Mon Sep 17 00:00:00 2001 From: Andrew Beltrano <2082148+abeltrano@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:31:45 -0600 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- bootstrap.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bootstrap.md b/bootstrap.md index fbe8123..ae4c93d 100644 --- a/bootstrap.md +++ b/bootstrap.md @@ -31,10 +31,13 @@ You are the **composition engine** for PromptKit. Your job is to: Immediately after reading it, **announce the PromptKit version** to the user by reading the top-level `version:` field from `manifest.yaml` and emitting a one-line banner such as `PromptKit v loaded.` before - any other output. If the `version:` field is missing or unreadable, say - `PromptKit (version unknown) loaded.` instead — do not fabricate a version - number. Do the same (re-announce the current version) any time you - re-read `bootstrap.md` or `manifest.yaml` later in the session. + any other output. Use the **parsed YAML scalar value** for `version`, trim + surrounding whitespace, and do **not** preserve any YAML quoting characters + from the source text. If the parsed `version:` value is missing, unreadable, + empty, or whitespace-only, say `PromptKit (version unknown) loaded.` + instead — do not fabricate a version number or emit `PromptKit v loaded.` + Do the same (re-announce the current version) any time you re-read + `bootstrap.md` or `manifest.yaml` later in the session. 2. **Ask the user** what they want to accomplish. Examples: - "I need to write a requirements doc for a new authentication system." - "I need to investigate a memory leak in our C codebase." From 4b3f564c234c389f8628b592596d044362891e76 Mon Sep 17 00:00:00 2001 From: Andrew Beltrano Date: Wed, 6 May 2026 13:21:48 -0600 Subject: [PATCH 4/4] Address PR #249 review: hard fetch deadline, validate cached version, add cache tests - fetchLatest: add overall setTimeout that destroys the request so the 1500ms cap is a true deadline (not just socket-inactivity). - fetchLatest + checkForUpdate: validate 'latest' with parseVersion before caching or returning, so an unparseable registry response can never poison the 24h cache. - checkForUpdate: dispatch readCache/writeCache/fetchLatest through module.exports._internals so tests can stub them. - Add tests for cache TTL hit, expired-cache refetch, pkg-name mismatch, unparseable cached value, and unparseable fetched value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/lib/update-check.js | 28 +++++- cli/tests/update-check.test.js | 179 +++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/cli/lib/update-check.js b/cli/lib/update-check.js index e63722e..b96e409 100644 --- a/cli/lib/update-check.js +++ b/cli/lib/update-check.js @@ -74,9 +74,11 @@ function fetchLatest(pkgName) { return new Promise((resolve) => { const url = `${REGISTRY_BASE}/${pkgName}/latest`; let settled = false; + let hardTimer = null; const done = (value) => { if (settled) return; settled = true; + if (hardTimer) clearTimeout(hardTimer); resolve(value); }; try { @@ -101,7 +103,11 @@ function fetchLatest(pkgName) { res.on("end", () => { try { const json = JSON.parse(body); - done(typeof json.version === "string" ? json.version : null); + const version = + typeof json.version === "string" ? json.version : null; + // Only return parseable semver so we never cache or surface + // a malformed value (e.g., missing patch, garbage string). + done(version && parseVersion(version) ? version : null); } catch { done(null); } @@ -109,6 +115,15 @@ function fetchLatest(pkgName) { res.on("error", () => done(null)); } ); + // The { timeout } option above is only a socket-inactivity timeout — + // a server that trickles bytes can keep the request alive well past + // FETCH_TIMEOUT_MS. Add an overall hard deadline so interactive + // startup is never delayed longer than intended. + hardTimer = setTimeout(() => { + req.destroy(); + done(null); + }, FETCH_TIMEOUT_MS); + if (typeof hardTimer.unref === "function") hardTimer.unref(); req.on("timeout", () => { req.destroy(); done(null); @@ -139,6 +154,10 @@ async function checkForUpdate( ) { if (suppressionReason({ force })) return null; + // Dispatch through module.exports._internals so tests can stub these + // without depending on the real filesystem or network. + const { readCache, writeCache, fetchLatest } = module.exports._internals; + const cache = readCache(); let latest = null; @@ -147,14 +166,19 @@ async function checkForUpdate( cache && cache.pkg === pkgName && typeof cache.latest === "string" && + parseVersion(cache.latest) && typeof cache.checkedAt === "number" && now - cache.checkedAt < CACHE_TTL_MS ) { latest = cache.latest; } else { latest = await fetchLatest(pkgName); - if (latest) { + // fetchLatest already filters to parseable semver, but guard again so + // a future change to that contract can't poison the cache. + if (latest && parseVersion(latest)) { writeCache({ pkg: pkgName, latest, checkedAt: now }); + } else { + latest = null; } } diff --git a/cli/tests/update-check.test.js b/cli/tests/update-check.test.js index b474b83..8e4d342 100644 --- a/cli/tests/update-check.test.js +++ b/cli/tests/update-check.test.js @@ -148,3 +148,182 @@ describe("checkForUpdate (suppression paths)", () => { } }); }); + +describe("checkForUpdate (cache paths)", () => { + const updateCheck = require("../lib/update-check"); + const { _internals } = updateCheck; + + // Replace _internals with stubs for the duration of fn(). Tests run with + // force: true (or ttyOverride) to bypass suppression. + function withStubs(stubs, fn) { + const saved = { + readCache: _internals.readCache, + writeCache: _internals.writeCache, + fetchLatest: _internals.fetchLatest, + }; + Object.assign(_internals, stubs); + const savedNoNotifier = process.env.NO_UPDATE_NOTIFIER; + const savedCi = process.env.CI; + const savedIsTty = process.stdout.isTTY; + delete process.env.NO_UPDATE_NOTIFIER; + delete process.env.CI; + process.stdout.isTTY = true; + return Promise.resolve() + .then(fn) + .finally(() => { + Object.assign(_internals, saved); + if (savedNoNotifier === undefined) delete process.env.NO_UPDATE_NOTIFIER; + else process.env.NO_UPDATE_NOTIFIER = savedNoNotifier; + if (savedCi === undefined) delete process.env.CI; + else process.env.CI = savedCi; + process.stdout.isTTY = savedIsTty; + }); + } + + const PKG = "@alan-jowett/promptkit"; + + it("uses cached latest within TTL without calling fetchLatest", async () => { + let fetchCalls = 0; + const writes = []; + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => ({ + pkg: PKG, + latest: "0.7.0", + checkedAt: now - 1000, + }), + writeCache: (data) => writes.push(data), + fetchLatest: async () => { + fetchCalls++; + return "9.9.9"; + }, + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.deepStrictEqual(result, { latest: "0.7.0", isUpdate: true }); + assert.strictEqual(fetchCalls, 0); + assert.strictEqual(writes.length, 0); + }); + + it("expired cache triggers a fresh fetch and rewrites the cache", async () => { + let fetchCalls = 0; + const writes = []; + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => ({ + pkg: PKG, + latest: "0.7.0", + checkedAt: now - (CACHE_TTL_MS_FOR_TESTS + 1), + }), + writeCache: (data) => writes.push(data), + fetchLatest: async () => { + fetchCalls++; + return "0.8.0"; + }, + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.deepStrictEqual(result, { latest: "0.8.0", isUpdate: true }); + assert.strictEqual(fetchCalls, 1); + assert.deepStrictEqual(writes, [ + { pkg: PKG, latest: "0.8.0", checkedAt: now }, + ]); + }); + + it("returns isUpdate: false when cached latest is not newer", async () => { + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => ({ pkg: PKG, latest: "0.6.1", checkedAt: now - 1000 }), + writeCache: () => {}, + fetchLatest: async () => "9.9.9", + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.deepStrictEqual(result, { latest: "0.6.1", isUpdate: false }); + }); + + it("ignores cache when pkg name does not match", async () => { + let fetchCalls = 0; + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => ({ + pkg: "other-pkg", + latest: "9.9.9", + checkedAt: now - 1000, + }), + writeCache: () => {}, + fetchLatest: async () => { + fetchCalls++; + return "0.8.0"; + }, + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.strictEqual(fetchCalls, 1); + assert.deepStrictEqual(result, { latest: "0.8.0", isUpdate: true }); + }); + + it("treats unparseable cached latest as a miss and refetches", async () => { + let fetchCalls = 0; + const writes = []; + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => ({ + pkg: PKG, + latest: '"0.7.0"', + checkedAt: now - 1000, + }), + writeCache: (data) => writes.push(data), + fetchLatest: async () => { + fetchCalls++; + return "0.8.0"; + }, + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.strictEqual(fetchCalls, 1); + assert.deepStrictEqual(result, { latest: "0.8.0", isUpdate: true }); + assert.deepStrictEqual(writes, [ + { pkg: PKG, latest: "0.8.0", checkedAt: now }, + ]); + }); + + it("does not cache or return an unparseable latest from fetchLatest", async () => { + const writes = []; + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => null, + writeCache: (data) => writes.push(data), + fetchLatest: async () => "garbage", + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.strictEqual(result, null); + assert.strictEqual(writes.length, 0); + }); + + it("returns null when fetchLatest returns null and writes nothing", async () => { + const writes = []; + const now = 1_700_000_000_000; + const result = await withStubs( + { + readCache: () => null, + writeCache: (data) => writes.push(data), + fetchLatest: async () => null, + }, + () => checkForUpdate(PKG, "0.6.1", { now }) + ); + assert.strictEqual(result, null); + assert.strictEqual(writes.length, 0); + }); +}); + +// Mirror of the constant in update-check.js. Kept inline so the test file +// stays self-contained and does not reach into module-private state. +const CACHE_TTL_MS_FOR_TESTS = 24 * 60 * 60 * 1000;