/ scripts / fetch-adapters.js
fetch-adapters.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Sparse adapter sync: keeps ~/.opencli/clis/ clean by removing stale overrides.
  5   *
  6   * Strategy (hash-based, site-level granularity):
  7   * - When an official site has upstream changes: DELETE the local override
  8   *   (do NOT copy new version — runtime falls back to package baseline)
  9   * - When an official site has no changes: leave local override intact
 10   * - User-created custom sites (not in package): always preserved
 11   * - Skips entirely if already synced at the same version
 12   *
 13   * ~/.opencli/clis/ is a sparse override layer, not a full copy.
 14   * Only eject-ed or user-modified sites appear here.
 15   *
 16   * Only runs on global install (npm install -g) or explicit OPENCLI_FETCH=1.
 17   * No network calls — reads hashes from clis/ in the installed package.
 18   *
 19   * This is an ESM script (package.json type: module). No TypeScript, no src/ imports.
 20   */
 21  
 22  import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
 23  import { createHash } from 'node:crypto';
 24  import { join, resolve, dirname, relative } from 'node:path';
 25  import { homedir } from 'node:os';
 26  
 27  const OPENCLI_DIR = join(homedir(), '.opencli');
 28  const USER_CLIS_DIR = join(OPENCLI_DIR, 'clis');
 29  const MANIFEST_PATH = join(OPENCLI_DIR, 'adapter-manifest.json');
 30  const PACKAGE_ROOT = resolve(import.meta.dirname, '..');
 31  const BUILTIN_CLIS = join(PACKAGE_ROOT, 'clis');
 32  
 33  function log(msg) {
 34    console.log(`[opencli] ${msg}`);
 35  }
 36  
 37  function getPackageVersion() {
 38    try {
 39      return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version;
 40    } catch {
 41      return 'unknown';
 42    }
 43  }
 44  
 45  /**
 46   * Compute SHA-256 hash of file content.
 47   */
 48  function fileHash(filePath) {
 49    return createHash('sha256').update(readFileSync(filePath)).digest('hex');
 50  }
 51  
 52  /**
 53   * Read existing manifest. Returns { version, files, hashes } or null.
 54   */
 55  function readManifest() {
 56    try {
 57      return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
 58    } catch {
 59      return null;
 60    }
 61  }
 62  
 63  /**
 64   * Collect all relative file paths under a directory.
 65   */
 66  function walkFiles(dir, prefix = '') {
 67    const results = [];
 68    if (!existsSync(dir)) return results;
 69    for (const entry of readdirSync(dir)) {
 70      const full = join(dir, entry);
 71      const rel = prefix ? `${prefix}/${entry}` : entry;
 72      if (statSync(full).isDirectory()) {
 73        results.push(...walkFiles(full, rel));
 74      } else {
 75        results.push(rel);
 76      }
 77    }
 78    return results;
 79  }
 80  
 81  /**
 82   * Remove empty parent directories up to (but not including) stopAt.
 83   */
 84  function pruneEmptyDirs(filePath, stopAt) {
 85    const boundary = resolve(stopAt);
 86    let dir = resolve(dirname(filePath));
 87    while (dir !== boundary) {
 88      const rel = relative(boundary, dir);
 89      if (!rel || rel.startsWith('..')) break;
 90      try {
 91        const entries = readdirSync(dir);
 92        if (entries.length > 0) break;
 93        rmSync(dir);
 94        dir = dirname(dir);
 95      } catch {
 96        break;
 97      }
 98    }
 99  }
100  
101  export function fetchAdapters() {
102    const currentVersion = getPackageVersion();
103    const oldManifest = readManifest();
104  
105    // Skip if already installed at the same version (unless forced via OPENCLI_FETCH=1)
106    const isForced = process.env.OPENCLI_FETCH === '1';
107    if (!isForced && currentVersion !== 'unknown' && oldManifest?.version === currentVersion) {
108      log(`Adapters already up to date (v${currentVersion})`);
109      return;
110    }
111  
112    if (!existsSync(BUILTIN_CLIS)) {
113      log('Warning: clis/ not found in package — skipping adapter copy');
114      return;
115    }
116  
117    const newOfficialFiles = new Set(walkFiles(BUILTIN_CLIS));
118    const oldOfficialFiles = new Set(oldManifest?.files ?? []);
119    const rawHashes = oldManifest?.hashes;
120    // Guard against corrupted manifest: if hashes is a non-object type (string, number,
121    // array), skip sync to avoid false-positive "changed" detection that deletes overrides.
122    // null/undefined are treated as empty (old manifests may lack the field).
123    if (rawHashes != null && (typeof rawHashes !== 'object' || Array.isArray(rawHashes))) {
124      log('Warning: adapter-manifest.json has corrupted hashes — skipping sync. Will fix on next run.');
125      return;
126    }
127    const oldHashes = rawHashes ?? {};
128    mkdirSync(USER_CLIS_DIR, { recursive: true });
129  
130    // 1. Compute new hashes and detect which sites have changes
131    const newHashes = {};
132    const siteFiles = new Map(); // site -> [relPath, ...]
133    for (const relPath of newOfficialFiles) {
134      const src = join(BUILTIN_CLIS, relPath);
135      const srcHash = fileHash(src);
136      newHashes[relPath] = srcHash;
137  
138      const site = relPath.split('/')[0];
139      if (!siteFiles.has(site)) siteFiles.set(site, []);
140      siteFiles.get(site).push(relPath);
141    }
142  
143    // Determine which sites have any changed/new/removed files
144    const changedSites = new Set();
145    for (const [site, files] of siteFiles) {
146      for (const relPath of files) {
147        if (oldHashes[relPath] !== newHashes[relPath]) {
148          changedSites.add(site);
149          break;
150        }
151      }
152    }
153    // Also mark sites that had files removed
154    for (const relPath of oldOfficialFiles) {
155      if (!newOfficialFiles.has(relPath)) {
156        changedSites.add(relPath.split('/')[0]);
157      }
158    }
159  
160    // 2. Sparse cleanup: for changed/removed official sites, delete local overrides.
161    //    Do NOT copy new versions — runtime falls back to package baseline.
162    //    Only eject-ed sites live in ~/.opencli/clis/.
163    let cleared = 0;
164    for (const site of changedSites) {
165      const siteDir = join(USER_CLIS_DIR, site);
166      if (existsSync(siteDir)) {
167        rmSync(siteDir, { recursive: true, force: true });
168        cleared++;
169      }
170    }
171  
172    // 3. Clean up stale .ts adapter files left by older versions (pre-1.7.1)
173    // Older versions shipped adapters as .ts; current versions use .js only.
174    let tsCleaned = 0;
175    for (const relPath of walkFiles(USER_CLIS_DIR)) {
176      if (relPath.endsWith('.ts') && !relPath.endsWith('.d.ts')) {
177        const jsCounterpart = relPath.replace(/\.ts$/, '.js');
178        if (newOfficialFiles.has(jsCounterpart)) {
179          try {
180            unlinkSync(join(USER_CLIS_DIR, relPath));
181            pruneEmptyDirs(join(USER_CLIS_DIR, relPath), USER_CLIS_DIR);
182            tsCleaned++;
183          } catch { /* ignore */ }
184        }
185      }
186    }
187    if (tsCleaned > 0) log(`Cleaned up ${tsCleaned} stale .ts adapter files`);
188  
189    // 3b. Clean up stale .yaml/.yml adapter files left by older versions (pre-1.7.0)
190    // Older versions shipped adapters as YAML; current versions use .js only.
191    // These cause "Ignoring YAML adapter" warnings on every run (issue #953).
192    let yamlCleaned = 0;
193    for (const relPath of walkFiles(USER_CLIS_DIR)) {
194      if (relPath.endsWith('.yaml') || relPath.endsWith('.yml')) {
195        const jsCounterpart = relPath.replace(/\.ya?ml$/, '.js');
196        if (newOfficialFiles.has(jsCounterpart)) {
197          try {
198            unlinkSync(join(USER_CLIS_DIR, relPath));
199            pruneEmptyDirs(join(USER_CLIS_DIR, relPath), USER_CLIS_DIR);
200            yamlCleaned++;
201          } catch { /* ignore */ }
202        }
203      }
204    }
205    if (yamlCleaned > 0) log(`Cleaned up ${yamlCleaned} stale .yaml adapter files`);
206  
207    // 4. Clean up legacy compat shim files from ~/.opencli/
208    // These were created by an older approach that placed re-export shims directly
209    // in ~/.opencli/ (e.g., registry.js, errors.js, browser/). The current approach
210    // uses a node_modules/@jackwener/opencli symlink instead.
211    const LEGACY_SHIM_FILES = [
212      'registry.js', 'errors.js', 'utils.js', 'launcher.js', 'logger.js', 'types.js',
213    ];
214    const LEGACY_SHIM_DIRS = [
215      'browser', 'download', 'errors', 'launcher', 'logger', 'pipeline', 'registry', 'types', 'utils',
216    ];
217    let legacyCleaned = 0;
218    for (const file of LEGACY_SHIM_FILES) {
219      const p = join(OPENCLI_DIR, file);
220      try {
221        const content = readFileSync(p, 'utf-8');
222        // Only delete if it's a re-export shim, not a user-created file
223        if (content.includes("export * from 'file://")) {
224          unlinkSync(p);
225          legacyCleaned++;
226        }
227      } catch { /* doesn't exist */ }
228    }
229    for (const dir of LEGACY_SHIM_DIRS) {
230      const p = join(OPENCLI_DIR, dir);
231      try {
232        // Delete individual shim files, then prune empty directory
233        for (const entry of readdirSync(p)) {
234          const fp = join(p, entry);
235          try {
236            if (!statSync(fp).isFile()) continue;
237            const content = readFileSync(fp, 'utf-8');
238            if (content.includes("export * from 'file://")) {
239              unlinkSync(fp);
240              legacyCleaned++;
241            }
242          } catch { /* skip unreadable entries */ }
243        }
244        // Remove directory only if now empty
245        try {
246          if (readdirSync(p).length === 0) rmSync(p);
247        } catch { /* ignore */ }
248      } catch { /* doesn't exist or not a directory */ }
249    }
250  
251    // 5. Clean up stale .plugins.lock.json.tmp-* files
252    let tmpCleaned = 0;
253    try {
254      for (const entry of readdirSync(OPENCLI_DIR)) {
255        if (entry.startsWith('.plugins.lock.json.tmp-')) {
256          try {
257            unlinkSync(join(OPENCLI_DIR, entry));
258            tmpCleaned++;
259          } catch { /* ignore */ }
260        }
261      }
262    } catch { /* ignore */ }
263  
264    if (legacyCleaned > 0 || tmpCleaned > 0) {
265      log(`Cleaned up${legacyCleaned > 0 ? ` ${legacyCleaned} legacy shim files` : ''}${tmpCleaned > 0 ? `${legacyCleaned > 0 ? ',' : ''} ${tmpCleaned} stale tmp files` : ''}`);
266    }
267  
268    // 6. Write updated manifest (with per-file hashes for smart sync)
269    writeFileSync(MANIFEST_PATH, JSON.stringify({
270      version: currentVersion,
271      files: [...newOfficialFiles].sort(),
272      hashes: newHashes,
273      updatedAt: new Date().toISOString(),
274    }, null, 2));
275  
276    log(`Synced adapters: ${cleared} local override(s) cleared` +
277      (tsCleaned > 0 ? `, ${tsCleaned} stale .ts files removed` : '') +
278      (yamlCleaned > 0 ? `, ${yamlCleaned} stale .yaml files removed` : ''));
279  }
280  
281  function main() {
282    // Skip in CI
283    if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) return;
284    // Only run on global install, explicit trigger, or first-run fallback
285    const isGlobal = process.env.npm_config_global === 'true';
286    const isExplicit = process.env.OPENCLI_FETCH === '1';
287    const isFirstRun = process.env._OPENCLI_FIRST_RUN === '1';
288    if (!isGlobal && !isExplicit && !isFirstRun) return;
289  
290    fetchAdapters();
291  }
292  
293  main();