/ src / update-check.ts
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  };