Skip to content

Commit 5ababc9

Browse files
anbeltraCopilot
andcommitted
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/<pkg>/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>
1 parent 92bb0ed commit 5ababc9

5 files changed

Lines changed: 347 additions & 2 deletions

File tree

cli/bin/cli.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { Command } = require("commander");
66
const path = require("path");
77
const fs = require("fs");
88
const { launchInteractive } = require("../lib/launch");
9+
const { checkForUpdate, formatBanner } = require("../lib/update-check");
910
const {
1011
loadManifest,
1112
allComponents,
@@ -61,8 +62,23 @@ program
6162
"--dry-run",
6263
"Print the spawn command and args without launching the LLM CLI"
6364
)
64-
.action((opts) => {
65+
.option(
66+
"--no-update-check",
67+
"Skip checking the npm registry for a newer PromptKit version"
68+
)
69+
.action(async (opts) => {
6570
ensureContent();
71+
if (opts.updateCheck !== false) {
72+
try {
73+
const result = await checkForUpdate(pkg.name, pkg.version);
74+
if (result && result.isUpdate) {
75+
console.log(formatBanner(pkg.name, pkg.version, result.latest));
76+
console.log();
77+
}
78+
} catch {
79+
// Update checks are strictly best-effort; never fail the CLI over them.
80+
}
81+
}
6682
launchInteractive(contentDir, opts.cli || null, { dryRun: !!opts.dryRun });
6783
});
6884

cli/lib/update-check.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) PromptKit Contributors
3+
4+
// cli/lib/update-check.js — best-effort npm registry update check for the
5+
// PromptKit CLI. All network and filesystem operations are wrapped so that
6+
// any failure (timeout, DNS, bad JSON, unwritable cache dir, etc.) is
7+
// swallowed — an update check must never block or break the CLI.
8+
9+
const fs = require("fs");
10+
const os = require("os");
11+
const path = require("path");
12+
const https = require("https");
13+
14+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
15+
const FETCH_TIMEOUT_MS = 1500;
16+
const REGISTRY_BASE = "https://registry.npmjs.org";
17+
18+
function cachePath() {
19+
return path.join(os.homedir(), ".promptkit", "update-check.json");
20+
}
21+
22+
function readCache() {
23+
try {
24+
return JSON.parse(fs.readFileSync(cachePath(), "utf8"));
25+
} catch {
26+
return null;
27+
}
28+
}
29+
30+
function writeCache(data) {
31+
try {
32+
const file = cachePath();
33+
fs.mkdirSync(path.dirname(file), { recursive: true });
34+
fs.writeFileSync(file, JSON.stringify(data));
35+
} catch {
36+
// Best-effort only; cache failures must never surface.
37+
}
38+
}
39+
40+
// Parse a version string into [major, minor, patch]. Strips an optional
41+
// leading 'v' and ignores any prerelease/build suffix after the patch number.
42+
// Returns null for unparseable input.
43+
function parseVersion(v) {
44+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || ""));
45+
if (!match) return null;
46+
return [Number(match[1]), Number(match[2]), Number(match[3])];
47+
}
48+
49+
function isNewer(candidate, current) {
50+
const a = parseVersion(candidate);
51+
const b = parseVersion(current);
52+
if (!a || !b) return false;
53+
for (let i = 0; i < 3; i++) {
54+
if (a[i] > b[i]) return true;
55+
if (a[i] < b[i]) return false;
56+
}
57+
return false;
58+
}
59+
60+
function formatBanner(pkgName, current, latest) {
61+
const line1 = `Update available: ${current} -> ${latest}`;
62+
const line2 = `Run: npm i -g ${pkgName}`;
63+
const inner = Math.max(line1.length, line2.length);
64+
const bar = "-".repeat(inner + 2);
65+
return (
66+
`+${bar}+\n` +
67+
`| ${line1.padEnd(inner)} |\n` +
68+
`| ${line2.padEnd(inner)} |\n` +
69+
`+${bar}+`
70+
);
71+
}
72+
73+
function fetchLatest(pkgName) {
74+
return new Promise((resolve) => {
75+
const url = `${REGISTRY_BASE}/${pkgName}/latest`;
76+
let settled = false;
77+
const done = (value) => {
78+
if (settled) return;
79+
settled = true;
80+
resolve(value);
81+
};
82+
try {
83+
const req = https.get(
84+
url,
85+
{ timeout: FETCH_TIMEOUT_MS, headers: { Accept: "application/json" } },
86+
(res) => {
87+
if (res.statusCode !== 200) {
88+
res.resume();
89+
return done(null);
90+
}
91+
let body = "";
92+
res.setEncoding("utf8");
93+
res.on("data", (chunk) => {
94+
body += chunk;
95+
// Hard cap to avoid unbounded memory on a misbehaving registry.
96+
if (body.length > 64 * 1024) {
97+
req.destroy();
98+
done(null);
99+
}
100+
});
101+
res.on("end", () => {
102+
try {
103+
const json = JSON.parse(body);
104+
done(typeof json.version === "string" ? json.version : null);
105+
} catch {
106+
done(null);
107+
}
108+
});
109+
res.on("error", () => done(null));
110+
}
111+
);
112+
req.on("timeout", () => {
113+
req.destroy();
114+
done(null);
115+
});
116+
req.on("error", () => done(null));
117+
} catch {
118+
done(null);
119+
}
120+
});
121+
}
122+
123+
// Decide whether update checking should be performed in this invocation.
124+
// Returns a short string describing the suppression reason, or null if the
125+
// check should proceed.
126+
function suppressionReason({ force = false, ttyOverride } = {}) {
127+
if (force) return null;
128+
if (process.env.NO_UPDATE_NOTIFIER === "1") return "NO_UPDATE_NOTIFIER";
129+
if (process.env.CI) return "CI";
130+
const isTty = ttyOverride !== undefined ? ttyOverride : !!process.stdout.isTTY;
131+
if (!isTty) return "non-tty";
132+
return null;
133+
}
134+
135+
async function checkForUpdate(
136+
pkgName,
137+
currentVersion,
138+
{ force = false, now = Date.now() } = {}
139+
) {
140+
if (suppressionReason({ force })) return null;
141+
142+
const cache = readCache();
143+
let latest = null;
144+
145+
if (
146+
!force &&
147+
cache &&
148+
cache.pkg === pkgName &&
149+
typeof cache.latest === "string" &&
150+
typeof cache.checkedAt === "number" &&
151+
now - cache.checkedAt < CACHE_TTL_MS
152+
) {
153+
latest = cache.latest;
154+
} else {
155+
latest = await fetchLatest(pkgName);
156+
if (latest) {
157+
writeCache({ pkg: pkgName, latest, checkedAt: now });
158+
}
159+
}
160+
161+
if (!latest) return null;
162+
return { latest, isUpdate: isNewer(latest, currentVersion) };
163+
}
164+
165+
module.exports = {
166+
checkForUpdate,
167+
formatBanner,
168+
isNewer,
169+
parseVersion,
170+
suppressionReason,
171+
// Exported for tests that need to bypass the real paths.
172+
_internals: { cachePath, readCache, writeCache, fetchLatest },
173+
};

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"prepublishOnly": "node scripts/copy-content.js",
2121
"prepare": "node scripts/copy-content.js",
2222
"pretest": "node scripts/copy-content.js",
23-
"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"
23+
"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"
2424
},
2525
"dependencies": {
2626
"commander": "^12.0.0",

cli/tests/cli.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ function makeTempContent(removeFiles) {
7171
fs.copyFileSync(manifestJs, path.join(tmpLib, "manifest.js"));
7272
}
7373

74+
// Copy lib/update-check.js (required by bin/cli.js)
75+
const updateCheckJs = path.resolve(__dirname, "..", "lib", "update-check.js");
76+
if (fs.existsSync(updateCheckJs)) {
77+
fs.copyFileSync(updateCheckJs, path.join(tmpLib, "update-check.js"));
78+
}
79+
7480
// Copy node_modules (symlink for speed)
7581
const srcModules = path.resolve(__dirname, "..", "node_modules");
7682
const destModules = path.join(tmpCli, "node_modules");

cli/tests/update-check.test.js

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) PromptKit Contributors
3+
4+
// cli/tests/update-check.test.js — unit tests for the update-check module.
5+
// Exercises pure functions (version parsing, comparison, banner formatting)
6+
// and the suppression-reason logic. Does not hit the network.
7+
8+
const { describe, it } = require("node:test");
9+
const assert = require("node:assert");
10+
11+
const {
12+
parseVersion,
13+
isNewer,
14+
formatBanner,
15+
suppressionReason,
16+
checkForUpdate,
17+
} = require("../lib/update-check");
18+
19+
describe("parseVersion", () => {
20+
it("parses plain semver", () => {
21+
assert.deepStrictEqual(parseVersion("1.2.3"), [1, 2, 3]);
22+
});
23+
24+
it("strips leading 'v'", () => {
25+
assert.deepStrictEqual(parseVersion("v0.6.1"), [0, 6, 1]);
26+
});
27+
28+
it("ignores prerelease and build metadata after patch", () => {
29+
assert.deepStrictEqual(parseVersion("1.2.3-rc.1"), [1, 2, 3]);
30+
assert.deepStrictEqual(parseVersion("1.2.3+build.5"), [1, 2, 3]);
31+
});
32+
33+
it("returns null for garbage input", () => {
34+
assert.strictEqual(parseVersion(""), null);
35+
assert.strictEqual(parseVersion("not-a-version"), null);
36+
assert.strictEqual(parseVersion(undefined), null);
37+
assert.strictEqual(parseVersion(null), null);
38+
assert.strictEqual(parseVersion("1.2"), null);
39+
});
40+
});
41+
42+
describe("isNewer", () => {
43+
it("detects major, minor, and patch bumps", () => {
44+
assert.strictEqual(isNewer("1.0.0", "0.9.9"), true);
45+
assert.strictEqual(isNewer("0.7.0", "0.6.9"), true);
46+
assert.strictEqual(isNewer("0.6.2", "0.6.1"), true);
47+
});
48+
49+
it("returns false for equal versions", () => {
50+
assert.strictEqual(isNewer("0.6.1", "0.6.1"), false);
51+
});
52+
53+
it("returns false when candidate is older", () => {
54+
assert.strictEqual(isNewer("0.5.0", "0.6.0"), false);
55+
assert.strictEqual(isNewer("0.6.0", "0.6.1"), false);
56+
assert.strictEqual(isNewer("0.9.9", "1.0.0"), false);
57+
});
58+
59+
it("returns false on unparseable input", () => {
60+
assert.strictEqual(isNewer("bogus", "0.6.1"), false);
61+
assert.strictEqual(isNewer("0.6.1", "bogus"), false);
62+
});
63+
});
64+
65+
describe("formatBanner", () => {
66+
it("produces a boxed banner containing both versions and the package", () => {
67+
const banner = formatBanner("@alan-jowett/promptkit", "0.6.1", "0.7.0");
68+
assert.match(banner, /Update available: 0\.6\.1 -> 0\.7\.0/);
69+
assert.match(banner, /npm i -g @alan-jowett\/promptkit/);
70+
// Four lines: top bar, two content lines, bottom bar.
71+
assert.strictEqual(banner.split("\n").length, 4);
72+
});
73+
74+
it("pads content lines to the same width", () => {
75+
const banner = formatBanner("pkg", "1.0.0", "2.0.0");
76+
const lines = banner.split("\n");
77+
assert.strictEqual(lines[0].length, lines[3].length);
78+
assert.strictEqual(lines[1].length, lines[2].length);
79+
assert.strictEqual(lines[0].length, lines[1].length);
80+
});
81+
});
82+
83+
describe("suppressionReason", () => {
84+
function withEnv(overrides, fn) {
85+
const saved = {};
86+
for (const key of Object.keys(overrides)) {
87+
saved[key] = process.env[key];
88+
if (overrides[key] === undefined) delete process.env[key];
89+
else process.env[key] = overrides[key];
90+
}
91+
try {
92+
return fn();
93+
} finally {
94+
for (const key of Object.keys(saved)) {
95+
if (saved[key] === undefined) delete process.env[key];
96+
else process.env[key] = saved[key];
97+
}
98+
}
99+
}
100+
101+
it("returns 'NO_UPDATE_NOTIFIER' when the env var is '1'", () => {
102+
withEnv({ NO_UPDATE_NOTIFIER: "1", CI: undefined }, () => {
103+
assert.strictEqual(
104+
suppressionReason({ ttyOverride: true }),
105+
"NO_UPDATE_NOTIFIER"
106+
);
107+
});
108+
});
109+
110+
it("returns 'CI' when CI env var is set", () => {
111+
withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: "true" }, () => {
112+
assert.strictEqual(suppressionReason({ ttyOverride: true }), "CI");
113+
});
114+
});
115+
116+
it("returns 'non-tty' when stdout is not a TTY", () => {
117+
withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: undefined }, () => {
118+
assert.strictEqual(suppressionReason({ ttyOverride: false }), "non-tty");
119+
});
120+
});
121+
122+
it("returns null when checks should proceed", () => {
123+
withEnv({ NO_UPDATE_NOTIFIER: undefined, CI: undefined }, () => {
124+
assert.strictEqual(suppressionReason({ ttyOverride: true }), null);
125+
});
126+
});
127+
128+
it("force: true bypasses all suppressions", () => {
129+
withEnv({ NO_UPDATE_NOTIFIER: "1", CI: "true" }, () => {
130+
assert.strictEqual(
131+
suppressionReason({ force: true, ttyOverride: false }),
132+
null
133+
);
134+
});
135+
});
136+
});
137+
138+
describe("checkForUpdate (suppression paths)", () => {
139+
it("returns null immediately when suppressed, without network I/O", async () => {
140+
const saved = process.env.NO_UPDATE_NOTIFIER;
141+
process.env.NO_UPDATE_NOTIFIER = "1";
142+
try {
143+
const result = await checkForUpdate("@alan-jowett/promptkit", "0.6.1");
144+
assert.strictEqual(result, null);
145+
} finally {
146+
if (saved === undefined) delete process.env.NO_UPDATE_NOTIFIER;
147+
else process.env.NO_UPDATE_NOTIFIER = saved;
148+
}
149+
});
150+
});

0 commit comments

Comments
 (0)