diff --git a/bootstrap.md b/bootstrap.md index cbe3e0a..ae4c93d 100644 --- a/bootstrap.md +++ b/bootstrap.md @@ -28,6 +28,16 @@ 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. 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." 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; + } + }); +});