|
| 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 | +}; |
0 commit comments