Files
jackwener__opencli/scripts/fetch-adapters.js
jakevin 2288cf7149 fix: clean up legacy shim files and stale tmp files on upgrade (#934)
* fix: clean up legacy shim files and stale tmp files on upgrade

Add cleanup steps to fetch-adapters.js that run on every version upgrade:

1. Remove legacy compat shim files from ~/.opencli/ (registry.js,
   errors.js, utils.js, etc.) that were created by an older approach
   using file:// re-exports. Current approach uses node_modules symlink.
   Only deletes files containing "export * from 'file://" to avoid
   removing user-created files.

2. Remove legacy compat shim directories (browser/, download/, errors/,
   etc.) using the same safety check.

3. Clean up stale .plugins.lock.json.tmp-* files left behind by
   crashed processes. These accumulate over time (108 found on one
   machine) and clutter ~/.opencli/.

* fix: check every file in legacy shim directories before deleting

Instead of checking only the first file and deleting the entire
directory, now checks each file individually and only deletes files
matching the shim pattern. Directory is removed only if empty after
individual file cleanup.
2026-04-10 18:58:44 +08:00

219 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Copy official CLI adapters from the installed package to ~/.opencli/clis/.
*
* Update strategy (file-level granularity via adapter-manifest.json):
* - Official files (in new manifest) are unconditionally overwritten
* - Removed official files (in old manifest but not new) are cleaned up
* - User-created files (never in any manifest) are preserved
* - Skips if already installed at the same version
*
* Only runs on global install (npm install -g) or explicit OPENCLI_FETCH=1.
* No network calls — copies directly from clis/ in the installed package.
*
* This is an ESM script (package.json type: module). No TypeScript, no src/ imports.
*/
import { existsSync, mkdirSync, rmSync, cpSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
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';
}
}
/**
* Read existing manifest. Returns { version, files } 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 ?? []);
mkdirSync(USER_CLIS_DIR, { recursive: true });
// 1. Copy official files (unconditionally overwrite)
let copied = 0;
for (const relPath of newOfficialFiles) {
const src = join(BUILTIN_CLIS, relPath);
const dst = join(USER_CLIS_DIR, relPath);
mkdirSync(dirname(dst), { recursive: true });
cpSync(src, dst, { force: true });
copied++;
}
// 2. Remove files that were official but are no longer (upstream deleted)
let removed = 0;
for (const relPath of oldOfficialFiles) {
if (!newOfficialFiles.has(relPath)) {
const dst = join(USER_CLIS_DIR, relPath);
try {
unlinkSync(dst);
pruneEmptyDirs(dst, USER_CLIS_DIR);
removed++;
} catch {
// File may not exist locally
}
}
}
// 3. 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 */ }
}
// 4. 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` : ''}`);
}
// 5. Write updated manifest
writeFileSync(MANIFEST_PATH, JSON.stringify({
version: currentVersion,
files: [...newOfficialFiles].sort(),
updatedAt: new Date().toISOString(),
}, null, 2));
log(`Installed ${copied} adapter files to ${USER_CLIS_DIR}` +
(removed > 0 ? `, removed ${removed} deprecated files` : ''));
}
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();