update-check.ts
1 /** 2 * Non-blocking update checker. 3 * 4 * Pattern: register exit-hook + kick-off-background-fetch 5 * - On startup: kick off background fetch (non-blocking) 6 * - On process exit: read cache, print notice if newer version exists 7 * - Check interval: 24 hours 8 * - Notice appears AFTER command output, not before (same as npm/gh/yarn) 9 * - Never delays or blocks the CLI command 10 */ 11 12 import * as fs from 'node:fs'; 13 import * as path from 'node:path'; 14 import * as os from 'node:os'; 15 import { styleText } from 'node:util'; 16 import { PKG_VERSION } from './version.js'; 17 18 const CACHE_DIR = path.join(os.homedir(), '.opencli'); 19 const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json'); 20 const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h 21 const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@jackwener/opencli/latest'; 22 const GITHUB_RELEASES_URL = 'https://api.github.com/repos/jackwener/OpenCLI/releases?per_page=20'; 23 24 interface UpdateCache { 25 lastCheck: number; 26 latestVersion: string; 27 latestExtensionVersion?: string; 28 } 29 30 interface GitHubReleaseAsset { 31 name: string; 32 } 33 34 interface GitHubRelease { 35 tag_name: string; 36 assets?: GitHubReleaseAsset[]; 37 } 38 39 // Read cache once at module load — shared by both exported functions 40 const _cache: UpdateCache | null = (() => { 41 try { 42 return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')) as UpdateCache; 43 } catch { 44 return null; 45 } 46 })(); 47 48 function writeCache(latestVersion: string, latestExtensionVersion?: string): void { 49 try { 50 fs.mkdirSync(CACHE_DIR, { recursive: true }); 51 const data: UpdateCache = { lastCheck: Date.now(), latestVersion }; 52 if (latestExtensionVersion) data.latestExtensionVersion = latestExtensionVersion; 53 fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf-8'); 54 } catch { 55 // Best-effort; never fail 56 } 57 } 58 59 /** Compare semver strings. Returns true if `a` is strictly newer than `b`. */ 60 function isNewer(a: string, b: string): boolean { 61 const parse = (v: string) => v.replace(/^v/, '').split('-')[0].split('.').map(Number); 62 const pa = parse(a); 63 const pb = parse(b); 64 if (pa.some(isNaN) || pb.some(isNaN)) return false; 65 const [aMaj, aMin, aPat] = pa; 66 const [bMaj, bMin, bPat] = pb; 67 if (aMaj !== bMaj) return aMaj > bMaj; 68 if (aMin !== bMin) return aMin > bMin; 69 return aPat > bPat; 70 } 71 72 function isCI(): boolean { 73 return !!(process.env.CI || process.env.CONTINUOUS_INTEGRATION); 74 } 75 76 /** 77 * Register a process exit hook that prints an update notice if a newer 78 * version was found on the last background check. 79 * Notice appears after command output — same pattern as npm/gh/yarn. 80 * Skipped during --get-completions to avoid polluting shell completion output. 81 */ 82 export function registerUpdateNoticeOnExit(): void { 83 if (isCI()) return; 84 if (process.argv.includes('--get-completions')) return; 85 86 process.on('exit', (code) => { 87 if (code !== 0) return; // Don't show update notice on error exit 88 if (!_cache) return; 89 if (!isNewer(_cache.latestVersion, PKG_VERSION)) return; 90 try { 91 process.stderr.write( 92 styleText('yellow', `\n Update available: v${PKG_VERSION} → v${_cache.latestVersion}\n`) + 93 styleText('dim', ` Run: npm install -g @jackwener/opencli\n\n`), 94 ); 95 } catch { 96 // Ignore broken pipe (stderr closed before process exits) 97 } 98 }); 99 } 100 101 function extractLatestExtensionVersionFromReleases(releases: GitHubRelease[]): string | undefined { 102 for (const release of releases) { 103 for (const asset of release.assets ?? []) { 104 const assetMatch = asset.name.match(/^opencli-extension-v(.+)\.zip$/); 105 if (assetMatch) return assetMatch[1]; 106 } 107 108 const tagMatch = release.tag_name.match(/^ext-v(.+)$/); 109 if (tagMatch) return tagMatch[1]; 110 } 111 return undefined; 112 } 113 114 /** Fetch the latest extension version from GitHub Releases. */ 115 async function fetchLatestExtensionVersion(): Promise<string | undefined> { 116 try { 117 const controller = new AbortController(); 118 const timer = setTimeout(() => controller.abort(), 3000); 119 const res = await fetch(GITHUB_RELEASES_URL, { 120 signal: controller.signal, 121 headers: { 'User-Agent': `opencli/${PKG_VERSION}`, Accept: 'application/vnd.github+json' }, 122 }); 123 clearTimeout(timer); 124 if (!res.ok) return undefined; 125 const releases = await res.json() as GitHubRelease[]; 126 return extractLatestExtensionVersionFromReleases(releases); 127 } catch { 128 return undefined; 129 } 130 } 131 132 /** 133 * Kick off a background fetch to npm registry. Writes to cache for next run. 134 * Fully non-blocking — never awaited. 135 */ 136 export function checkForUpdateBackground(): void { 137 if (isCI()) return; 138 if (_cache && Date.now() - _cache.lastCheck < CHECK_INTERVAL_MS) return; 139 140 void (async () => { 141 try { 142 const controller = new AbortController(); 143 const timer = setTimeout(() => controller.abort(), 3000); 144 const res = await fetch(NPM_REGISTRY_URL, { 145 signal: controller.signal, 146 headers: { 'User-Agent': `opencli/${PKG_VERSION}` }, 147 }); 148 clearTimeout(timer); 149 if (!res.ok) return; 150 const data = await res.json() as { version?: string }; 151 if (typeof data.version === 'string') { 152 const extVersion = await fetchLatestExtensionVersion(); 153 writeCache(data.version, extVersion); 154 } 155 } catch { 156 // Network error: silently skip, try again next run 157 } 158 })(); 159 } 160 161 /** 162 * Get the cached latest extension version (if available). 163 * Used by `opencli doctor` to report extension updates. 164 */ 165 export function getCachedLatestExtensionVersion(): string | undefined { 166 return _cache?.latestExtensionVersion; 167 } 168 169 export { 170 extractLatestExtensionVersionFromReleases as _extractLatestExtensionVersionFromReleases, 171 };