Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> 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."
Expand Down
18 changes: 17 additions & 1 deletion cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
});

Expand Down
173 changes: 173 additions & 0 deletions cli/lib/update-check.js
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) => {
Comment on lines +83 to +86
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FETCH_TIMEOUT_MS is described as a hard ~1500ms cap, but https.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., setTimeout that destroys the request) so interactive startup can’t be delayed longer than intended.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkForUpdate caches any truthy latest string, even if it’s not parseable by parseVersion. That can lock users into a bad cached value for 24h (and can also lead to odd banner output if it’s unexpectedly formatted). Consider validating latest with parseVersion (or isNewer inputs) before writing it to cache / returning it.

Copilot uses AI. Check for mistakes.
}
Comment on lines +142 to +159
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache/TLL branch in checkForUpdate (reading ~/.promptkit/update-check.json, TTL gating, and the “cached newer version still shows banner” behavior) isn’t covered by unit tests yet. Since _internals are already exported, consider adding tests that stub cache read/write + now to exercise: fresh cache hit, expired cache triggering fetch, and banner decision based on cached latest.

Copilot uses AI. Check for mistakes.

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 },
};
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions cli/tests/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
150 changes: 150 additions & 0 deletions cli/tests/update-check.test.js
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;
}
});
});
Loading