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();