-
Notifications
You must be signed in to change notification settings - Fork 7
Make PromptKit version visible: session banner + CLI update check #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); | ||
| } | ||
|
Comment on lines
+155
to
+158
|
||
| } | ||
|
Comment on lines
+142
to
+159
|
||
|
|
||
| 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 }, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FETCH_TIMEOUT_MSis described as a hard ~1500ms cap, buthttps.get({ timeout })only sets a socket inactivity timeout (it can exceed 1500ms if the server trickles data). If you need a true hard deadline, add an explicit overall timer (e.g.,setTimeoutthat destroys the request) so interactive startup can’t be delayed longer than intended.