Skip to content

Commit 562c9ec

Browse files
abeltranoanbeltraCopilotCopilot
authored
Make PromptKit version visible: session banner + CLI update check (#249)
* Announce PromptKit version when bootstrap loads Add an instruction to bootstrap.md step 1 so the composition engine emits a one-line 'PromptKit v<version> 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> * 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> * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * 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> --------- Co-authored-by: anbeltra <anbeltra@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 24b8964 commit 562c9ec

6 files changed

Lines changed: 560 additions & 2 deletions

File tree

bootstrap.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ You are the **composition engine** for PromptKit. Your job is to:
2828
## How to Begin
2929

3030
1. **Read the manifest** at `manifest.yaml` to discover all available components.
31+
Immediately after reading it, **announce the PromptKit version** to the
32+
user by reading the top-level `version:` field from `manifest.yaml` and
33+
emitting a one-line banner such as `PromptKit v<version> loaded.` before
34+
any other output. Use the **parsed YAML scalar value** for `version`, trim
35+
surrounding whitespace, and do **not** preserve any YAML quoting characters
36+
from the source text. If the parsed `version:` value is missing, unreadable,
37+
empty, or whitespace-only, say `PromptKit (version unknown) loaded.`
38+
instead — do not fabricate a version number or emit `PromptKit v loaded.`
39+
Do the same (re-announce the current version) any time you re-read
40+
`bootstrap.md` or `manifest.yaml` later in the session.
3141
2. **Ask the user** what they want to accomplish. Examples:
3242
- "I need to write a requirements doc for a new authentication system."
3343
- "I need to investigate a memory leak in our C codebase."

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: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
let hardTimer = null;
78+
const done = (value) => {
79+
if (settled) return;
80+
settled = true;
81+
if (hardTimer) clearTimeout(hardTimer);
82+
resolve(value);
83+
};
84+
try {
85+
const req = https.get(
86+
url,
87+
{ timeout: FETCH_TIMEOUT_MS, headers: { Accept: "application/json" } },
88+
(res) => {
89+
if (res.statusCode !== 200) {
90+
res.resume();
91+
return done(null);
92+
}
93+
let body = "";
94+
res.setEncoding("utf8");
95+
res.on("data", (chunk) => {
96+
body += chunk;
97+
// Hard cap to avoid unbounded memory on a misbehaving registry.
98+
if (body.length > 64 * 1024) {
99+
req.destroy();
100+
done(null);
101+
}
102+
});
103+
res.on("end", () => {
104+
try {
105+
const json = JSON.parse(body);
106+
const version =
107+
typeof json.version === "string" ? json.version : null;
108+
// Only return parseable semver so we never cache or surface
109+
// a malformed value (e.g., missing patch, garbage string).
110+
done(version && parseVersion(version) ? version : null);
111+
} catch {
112+
done(null);
113+
}
114+
});
115+
res.on("error", () => done(null));
116+
}
117+
);
118+
// The { timeout } option above is only a socket-inactivity timeout —
119+
// a server that trickles bytes can keep the request alive well past
120+
// FETCH_TIMEOUT_MS. Add an overall hard deadline so interactive
121+
// startup is never delayed longer than intended.
122+
hardTimer = setTimeout(() => {
123+
req.destroy();
124+
done(null);
125+
}, FETCH_TIMEOUT_MS);
126+
if (typeof hardTimer.unref === "function") hardTimer.unref();
127+
req.on("timeout", () => {
128+
req.destroy();
129+
done(null);
130+
});
131+
req.on("error", () => done(null));
132+
} catch {
133+
done(null);
134+
}
135+
});
136+
}
137+
138+
// Decide whether update checking should be performed in this invocation.
139+
// Returns a short string describing the suppression reason, or null if the
140+
// check should proceed.
141+
function suppressionReason({ force = false, ttyOverride } = {}) {
142+
if (force) return null;
143+
if (process.env.NO_UPDATE_NOTIFIER === "1") return "NO_UPDATE_NOTIFIER";
144+
if (process.env.CI) return "CI";
145+
const isTty = ttyOverride !== undefined ? ttyOverride : !!process.stdout.isTTY;
146+
if (!isTty) return "non-tty";
147+
return null;
148+
}
149+
150+
async function checkForUpdate(
151+
pkgName,
152+
currentVersion,
153+
{ force = false, now = Date.now() } = {}
154+
) {
155+
if (suppressionReason({ force })) return null;
156+
157+
// Dispatch through module.exports._internals so tests can stub these
158+
// without depending on the real filesystem or network.
159+
const { readCache, writeCache, fetchLatest } = module.exports._internals;
160+
161+
const cache = readCache();
162+
let latest = null;
163+
164+
if (
165+
!force &&
166+
cache &&
167+
cache.pkg === pkgName &&
168+
typeof cache.latest === "string" &&
169+
parseVersion(cache.latest) &&
170+
typeof cache.checkedAt === "number" &&
171+
now - cache.checkedAt < CACHE_TTL_MS
172+
) {
173+
latest = cache.latest;
174+
} else {
175+
latest = await fetchLatest(pkgName);
176+
// fetchLatest already filters to parseable semver, but guard again so
177+
// a future change to that contract can't poison the cache.
178+
if (latest && parseVersion(latest)) {
179+
writeCache({ pkg: pkgName, latest, checkedAt: now });
180+
} else {
181+
latest = null;
182+
}
183+
}
184+
185+
if (!latest) return null;
186+
return { latest, isUpdate: isNewer(latest, currentVersion) };
187+
}
188+
189+
module.exports = {
190+
checkForUpdate,
191+
formatBanner,
192+
isNewer,
193+
parseVersion,
194+
suppressionReason,
195+
// Exported for tests that need to bypass the real paths.
196+
_internals: { cachePath, readCache, writeCache, fetchLatest },
197+
};

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");

0 commit comments

Comments
 (0)