Files
jackwener__opencli/scripts/fetch-adapters.js
jakevin 3c088da53e refactor: smart sync adapters — hash-based diff instead of full copy (#966)
* refactor: smart sync adapters instead of full copy (#sparse-override)

Replace unconditional full-copy of all adapters to ~/.opencli/clis/ with
hash-based smart sync that only copies files whose content has changed.

Changes:
- fetch-adapters.js: use SHA-256 content hashes to skip unchanged files;
  store per-file hashes in adapter-manifest.json
- discovery.ts: simplify ensureUserAdapters() to only create the directory
  (no longer triggers full copy on first run)
- main.ts: fix fast completion to check manifest file existence instead of
  directory existence (sparse override may have empty user dir)
- cli.ts: add `opencli adapter eject/reset/status` commands for managing
  local adapter overrides
- engine.test.ts: add tests for empty user dir and ensureUserAdapters

* fix: address review blockers — site-level sync + reset --all

1. Fix `adapter reset --all`: change <site> from required to optional
   argument so --all can be used without specifying a site name.

2. Change smart sync from file-level to site-level granularity:
   if any file in a site has changed upstream, overwrite the entire
   site directory. This matches the agreed product semantics — local
   modifications to any file in a site are replaced when upstream
   updates that site.

* fix: delete old site dir before writing updated adapter files

When a site has upstream changes, delete the entire site directory
first, then write the new version. This prevents stale files from
older versions lingering in the user directory.

* fix: reset --all preserves custom sites, only removes official overrides

Blocker 3 fix: reset --all now checks BUILTIN_CLIS to identify official
sites and only deletes those, preserving user-created custom sites.

* refactor: sparse sync deletes local overrides instead of copying new versions

Changed fetch-adapters.js semantics per team agreement:
- When an official site has upstream changes, DELETE the local override
  instead of copying the new version into ~/.opencli/clis/
- Runtime automatically falls back to package baseline
- ~/.opencli/clis/ becomes a true sparse override layer

* fix: reset <site> rejects custom sites, only allows official overrides

Single-site reset now checks BUILTIN_CLIS before deleting, matching
the same protection that reset --all already has.

* fix: reset <site> allows custom sites per product decision

Per @WAWQAQ: explicit single-site reset should work on custom sites too.
Differentiate messaging: official sites say "using official baseline",
custom sites say "removed custom site".

reset --all still only removes official overrides (bulk safety).

* fix: reset --all deletes all local sites including custom per product decision

Per @WAWQAQ: --all should clear the entire local working cache,
including custom sites. Single-site reset already handles both types.
2026-04-11 21:28:40 +08:00

267 lines
8.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Sparse adapter sync: keeps ~/.opencli/clis/ clean by removing stale overrides.
*
* Strategy (hash-based, site-level granularity):
* - When an official site has upstream changes: DELETE the local override
* (do NOT copy new version — runtime falls back to package baseline)
* - When an official site has no changes: leave local override intact
* - User-created custom sites (not in package): always preserved
* - Skips entirely if already synced at the same version
*
* ~/.opencli/clis/ is a sparse override layer, not a full copy.
* Only eject-ed or user-modified sites appear here.
*
* Only runs on global install (npm install -g) or explicit OPENCLI_FETCH=1.
* No network calls — reads hashes from clis/ in the installed package.
*
* This is an ESM script (package.json type: module). No TypeScript, no src/ imports.
*/
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, resolve, dirname } from 'node:path';
import { homedir } from 'node:os';
const OPENCLI_DIR = join(homedir(), '.opencli');
const USER_CLIS_DIR = join(OPENCLI_DIR, 'clis');
const MANIFEST_PATH = join(OPENCLI_DIR, 'adapter-manifest.json');
const PACKAGE_ROOT = resolve(import.meta.dirname, '..');
const BUILTIN_CLIS = join(PACKAGE_ROOT, 'clis');
function log(msg) {
console.log(`[opencli] ${msg}`);
}
function getPackageVersion() {
try {
return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf-8')).version;
} catch {
return 'unknown';
}
}
/**
* Compute SHA-256 hash of file content.
*/
function fileHash(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
/**
* Read existing manifest. Returns { version, files, hashes } or null.
*/
function readManifest() {
try {
return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8'));
} catch {
return null;
}
}
/**
* Collect all relative file paths under a directory.
*/
function walkFiles(dir, prefix = '') {
const results = [];
if (!existsSync(dir)) return results;
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const rel = prefix ? `${prefix}/${entry}` : entry;
if (statSync(full).isDirectory()) {
results.push(...walkFiles(full, rel));
} else {
results.push(rel);
}
}
return results;
}
/**
* Remove empty parent directories up to (but not including) stopAt.
*/
function pruneEmptyDirs(filePath, stopAt) {
let dir = dirname(filePath);
while (dir !== stopAt && dir.startsWith(stopAt)) {
try {
const entries = readdirSync(dir);
if (entries.length > 0) break;
rmSync(dir);
dir = dirname(dir);
} catch {
break;
}
}
}
export function fetchAdapters() {
const currentVersion = getPackageVersion();
const oldManifest = readManifest();
// Skip if already installed at the same version (unless forced via OPENCLI_FETCH=1)
const isForced = process.env.OPENCLI_FETCH === '1';
if (!isForced && currentVersion !== 'unknown' && oldManifest?.version === currentVersion) {
log(`Adapters already up to date (v${currentVersion})`);
return;
}
if (!existsSync(BUILTIN_CLIS)) {
log('Warning: clis/ not found in package — skipping adapter copy');
return;
}
const newOfficialFiles = new Set(walkFiles(BUILTIN_CLIS));
const oldOfficialFiles = new Set(oldManifest?.files ?? []);
const oldHashes = oldManifest?.hashes ?? {};
mkdirSync(USER_CLIS_DIR, { recursive: true });
// 1. Compute new hashes and detect which sites have changes
const newHashes = {};
const siteFiles = new Map(); // site -> [relPath, ...]
for (const relPath of newOfficialFiles) {
const src = join(BUILTIN_CLIS, relPath);
const srcHash = fileHash(src);
newHashes[relPath] = srcHash;
const site = relPath.split('/')[0];
if (!siteFiles.has(site)) siteFiles.set(site, []);
siteFiles.get(site).push(relPath);
}
// Determine which sites have any changed/new/removed files
const changedSites = new Set();
for (const [site, files] of siteFiles) {
for (const relPath of files) {
if (oldHashes[relPath] !== newHashes[relPath]) {
changedSites.add(site);
break;
}
}
}
// Also mark sites that had files removed
for (const relPath of oldOfficialFiles) {
if (!newOfficialFiles.has(relPath)) {
changedSites.add(relPath.split('/')[0]);
}
}
// 2. Sparse cleanup: for changed/removed official sites, delete local overrides.
// Do NOT copy new versions — runtime falls back to package baseline.
// Only eject-ed sites live in ~/.opencli/clis/.
let cleared = 0;
for (const site of changedSites) {
const siteDir = join(USER_CLIS_DIR, site);
if (existsSync(siteDir)) {
rmSync(siteDir, { recursive: true, force: true });
cleared++;
}
}
// 3. Clean up stale .ts adapter files left by older versions (pre-1.7.1)
// Older versions shipped adapters as .ts; current versions use .js only.
let tsCleaned = 0;
for (const relPath of walkFiles(USER_CLIS_DIR)) {
if (relPath.endsWith('.ts') && !relPath.endsWith('.d.ts')) {
const jsCounterpart = relPath.replace(/\.ts$/, '.js');
if (newOfficialFiles.has(jsCounterpart)) {
try {
unlinkSync(join(USER_CLIS_DIR, relPath));
pruneEmptyDirs(join(USER_CLIS_DIR, relPath), USER_CLIS_DIR);
tsCleaned++;
} catch { /* ignore */ }
}
}
}
if (tsCleaned > 0) log(`Cleaned up ${tsCleaned} stale .ts adapter files`);
// 4. Clean up legacy compat shim files from ~/.opencli/
// These were created by an older approach that placed re-export shims directly
// in ~/.opencli/ (e.g., registry.js, errors.js, browser/). The current approach
// uses a node_modules/@jackwener/opencli symlink instead.
const LEGACY_SHIM_FILES = [
'registry.js', 'errors.js', 'utils.js', 'launcher.js', 'logger.js', 'types.js',
];
const LEGACY_SHIM_DIRS = [
'browser', 'download', 'errors', 'launcher', 'logger', 'pipeline', 'registry', 'types', 'utils',
];
let legacyCleaned = 0;
for (const file of LEGACY_SHIM_FILES) {
const p = join(OPENCLI_DIR, file);
try {
const content = readFileSync(p, 'utf-8');
// Only delete if it's a re-export shim, not a user-created file
if (content.includes("export * from 'file://")) {
unlinkSync(p);
legacyCleaned++;
}
} catch { /* doesn't exist */ }
}
for (const dir of LEGACY_SHIM_DIRS) {
const p = join(OPENCLI_DIR, dir);
try {
// Delete individual shim files, then prune empty directory
for (const entry of readdirSync(p)) {
const fp = join(p, entry);
try {
if (!statSync(fp).isFile()) continue;
const content = readFileSync(fp, 'utf-8');
if (content.includes("export * from 'file://")) {
unlinkSync(fp);
legacyCleaned++;
}
} catch { /* skip unreadable entries */ }
}
// Remove directory only if now empty
try {
if (readdirSync(p).length === 0) rmSync(p);
} catch { /* ignore */ }
} catch { /* doesn't exist or not a directory */ }
}
// 5. Clean up stale .plugins.lock.json.tmp-* files
let tmpCleaned = 0;
try {
for (const entry of readdirSync(OPENCLI_DIR)) {
if (entry.startsWith('.plugins.lock.json.tmp-')) {
try {
unlinkSync(join(OPENCLI_DIR, entry));
tmpCleaned++;
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
if (legacyCleaned > 0 || tmpCleaned > 0) {
log(`Cleaned up${legacyCleaned > 0 ? ` ${legacyCleaned} legacy shim files` : ''}${tmpCleaned > 0 ? `${legacyCleaned > 0 ? ',' : ''} ${tmpCleaned} stale tmp files` : ''}`);
}
// 6. Write updated manifest (with per-file hashes for smart sync)
writeFileSync(MANIFEST_PATH, JSON.stringify({
version: currentVersion,
files: [...newOfficialFiles].sort(),
hashes: newHashes,
updatedAt: new Date().toISOString(),
}, null, 2));
log(`Synced adapters: ${cleared} local override(s) cleared` +
(tsCleaned > 0 ? `, ${tsCleaned} stale .ts files removed` : ''));
}
function main() {
// Skip in CI
if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) return;
// Allow opt-out
if (process.env.OPENCLI_SKIP_FETCH === '1') return;
// Only run on global install, explicit trigger, or first-run fallback
const isGlobal = process.env.npm_config_global === 'true';
const isExplicit = process.env.OPENCLI_FETCH === '1';
const isFirstRun = process.env._OPENCLI_FIRST_RUN === '1';
if (!isGlobal && !isExplicit && !isFirstRun) return;
fetchAdapters();
}
main();