UNPKG

@agentled/cli

Version:

CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.

162 lines 6.4 kB
/** * Dependency-free update notifier for the agentled CLI. * * Checks the npm registry for a newer version of the given package and * prints a one-line notice to stderr before the process exits. Design * constraints: * - Zero external dependencies (uses Node 18+ `fetch`). * - Silent on failure — a missing network, registry hiccup, or malformed * cache must never break the user's command. * - Cached: one HTTP round-trip per 24h (cache miss adds ≤1.2s latency), * cache hits are free. * - Opt-out: honors `AGENTLED_NO_UPDATE_CHECK=1`, `NO_UPDATE_NOTIFIER=1`, * and standard CI env vars. Also skips when stderr is not a TTY. */ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; export const DEFAULT_TIMEOUT_MS = 1200; // --------------------------------------------------------------------------- // Cache file I/O // --------------------------------------------------------------------------- export function cachePath(pkgName) { const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); const safe = pkgName.replace(/[\/@]/g, '_'); return path.join(base, 'agentled', `update-check-${safe}.json`); } export function readCache(pkgName) { try { const raw = fs.readFileSync(cachePath(pkgName), 'utf-8'); const parsed = JSON.parse(raw); if (typeof parsed.checked !== 'number' || typeof parsed.latest !== 'string') return null; return { checked: parsed.checked, latest: parsed.latest }; } catch { return null; } } export function writeCache(pkgName, entry) { try { const file = cachePath(pkgName); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(entry)); } catch { // Best-effort; update checks must never block the CLI on disk issues. } } // --------------------------------------------------------------------------- // Disable logic // --------------------------------------------------------------------------- export function isDisabled() { return Boolean(process.env.AGENTLED_NO_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || process.env.CI || process.env.CONTINUOUS_INTEGRATION || !(process.stderr.isTTY ?? false)); } // --------------------------------------------------------------------------- // Semver comparison (X.Y.Z only — pre-release tags are ignored) // --------------------------------------------------------------------------- export function semverCompare(a, b) { const parse = (v) => v.split('-')[0] .split('.') .slice(0, 3) .map((n) => { const parsed = parseInt(n, 10); return Number.isFinite(parsed) ? parsed : 0; }); const pa = parse(a); const pb = parse(b); for (let i = 0; i < 3; i++) { const diff = (pa[i] ?? 0) - (pb[i] ?? 0); if (diff !== 0) return diff; } return 0; } // --------------------------------------------------------------------------- // Registry fetch // --------------------------------------------------------------------------- export async function fetchLatestVersion(pkgName, timeoutMs) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName).replace('%40', '@')}/latest`, { headers: { Accept: 'application/vnd.npm.install-v1+json' }, signal: ctrl.signal, }); if (!res.ok) return null; const data = (await res.json()); return typeof data.version === 'string' ? data.version : null; } catch { return null; } finally { clearTimeout(timer); } } export async function checkForUpdate(pkgName, currentVersion, opts = {}) { if (isDisabled()) return null; const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const fetcher = opts.fetcher ?? fetchLatestVersion; const cached = readCache(pkgName); const now = Date.now(); const hasFreshCache = cached && now - cached.checked < ttlMs; let latest = null; if (hasFreshCache) { latest = cached.latest; } else { latest = await fetcher(pkgName, timeoutMs); if (latest) { writeCache(pkgName, { checked: now, latest }); } else if (cached) { // Fetch failed — fall back to stale cache rather than silently // suppressing a known update. Refresh `checked` so we don't // retry on every invocation in offline / registry-blocked // environments (back off for a full TTL). latest = cached.latest; writeCache(pkgName, { checked: now, latest: cached.latest }); } else { // Fetch failed and no prior cache — record the attempt so the // next invocation backs off for a full TTL instead of paying the // timeout on every run. Store currentVersion so no false notice // is shown (we have no idea what's on npm). writeCache(pkgName, { checked: now, latest: currentVersion }); } } if (latest && semverCompare(latest, currentVersion) > 0) { return latest; } return null; } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- const NO_COLOR = Boolean(process.env.NO_COLOR) || !(process.stderr.isTTY ?? false); const esc = (code) => (NO_COLOR ? '' : `\x1b[${code}m`); const reset = esc('0'); const cyan = esc('36'); const yellow = esc('33'); const gray = esc('90'); export function renderUpdateNotice(pkgName, current, latest) { return [ '', ` ${gray}───${reset} ${yellow}${reset} Update available: ${gray}${current}${reset}${cyan}${latest}${reset}`, ` Run ${cyan}npx ${pkgName}@latest${reset} to update.`, '', ].join('\n'); } export function printUpdateNotice(pkgName, current, latest) { process.stderr.write(renderUpdateNotice(pkgName, current, latest)); } //# sourceMappingURL=update-check.js.map