@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
JavaScript
/**
* 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