Skip to content

Commit 3c088da

Browse files
authored
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.
1 parent 00e200b commit 3c088da

5 files changed

Lines changed: 214 additions & 66 deletions

File tree

scripts/fetch-adapters.js

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
#!/usr/bin/env node
22

33
/**
4-
* Copy official CLI adapters from the installed package to ~/.opencli/clis/.
4+
* Sparse adapter sync: keeps ~/.opencli/clis/ clean by removing stale overrides.
55
*
6-
* Update strategy (file-level granularity via adapter-manifest.json):
7-
* - Official files (in new manifest) are unconditionally overwritten
8-
* - Removed official files (in old manifest but not new) are cleaned up
9-
* - User-created files (never in any manifest) are preserved
10-
* - Skips if already installed at the same version
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.
1115
*
1216
* Only runs on global install (npm install -g) or explicit OPENCLI_FETCH=1.
13-
* No network calls — copies directly from clis/ in the installed package.
17+
* No network calls — reads hashes from clis/ in the installed package.
1418
*
1519
* This is an ESM script (package.json type: module). No TypeScript, no src/ imports.
1620
*/
1721

18-
import { existsSync, mkdirSync, rmSync, cpSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
22+
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
23+
import { createHash } from 'node:crypto';
1924
import { join, resolve, dirname } from 'node:path';
2025
import { homedir } from 'node:os';
2126

@@ -38,7 +43,14 @@ function getPackageVersion() {
3843
}
3944

4045
/**
41-
* Read existing manifest. Returns { version, files } or null.
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.
4254
*/
4355
function readManifest() {
4456
try {
@@ -101,30 +113,48 @@ export function fetchAdapters() {
101113

102114
const newOfficialFiles = new Set(walkFiles(BUILTIN_CLIS));
103115
const oldOfficialFiles = new Set(oldManifest?.files ?? []);
116+
const oldHashes = oldManifest?.hashes ?? {};
104117
mkdirSync(USER_CLIS_DIR, { recursive: true });
105118

106-
// 1. Copy official files (unconditionally overwrite)
107-
let copied = 0;
119+
// 1. Compute new hashes and detect which sites have changes
120+
const newHashes = {};
121+
const siteFiles = new Map(); // site -> [relPath, ...]
108122
for (const relPath of newOfficialFiles) {
109123
const src = join(BUILTIN_CLIS, relPath);
110-
const dst = join(USER_CLIS_DIR, relPath);
111-
mkdirSync(dirname(dst), { recursive: true });
112-
cpSync(src, dst, { force: true });
113-
copied++;
124+
const srcHash = fileHash(src);
125+
newHashes[relPath] = srcHash;
126+
127+
const site = relPath.split('/')[0];
128+
if (!siteFiles.has(site)) siteFiles.set(site, []);
129+
siteFiles.get(site).push(relPath);
114130
}
115131

116-
// 2. Remove files that were official but are no longer (upstream deleted)
117-
let removed = 0;
132+
// Determine which sites have any changed/new/removed files
133+
const changedSites = new Set();
134+
for (const [site, files] of siteFiles) {
135+
for (const relPath of files) {
136+
if (oldHashes[relPath] !== newHashes[relPath]) {
137+
changedSites.add(site);
138+
break;
139+
}
140+
}
141+
}
142+
// Also mark sites that had files removed
118143
for (const relPath of oldOfficialFiles) {
119144
if (!newOfficialFiles.has(relPath)) {
120-
const dst = join(USER_CLIS_DIR, relPath);
121-
try {
122-
unlinkSync(dst);
123-
pruneEmptyDirs(dst, USER_CLIS_DIR);
124-
removed++;
125-
} catch {
126-
// File may not exist locally
127-
}
145+
changedSites.add(relPath.split('/')[0]);
146+
}
147+
}
148+
149+
// 2. Sparse cleanup: for changed/removed official sites, delete local overrides.
150+
// Do NOT copy new versions — runtime falls back to package baseline.
151+
// Only eject-ed sites live in ~/.opencli/clis/.
152+
let cleared = 0;
153+
for (const site of changedSites) {
154+
const siteDir = join(USER_CLIS_DIR, site);
155+
if (existsSync(siteDir)) {
156+
rmSync(siteDir, { recursive: true, force: true });
157+
cleared++;
128158
}
129159
}
130160

@@ -206,15 +236,16 @@ export function fetchAdapters() {
206236
log(`Cleaned up${legacyCleaned > 0 ? ` ${legacyCleaned} legacy shim files` : ''}${tmpCleaned > 0 ? `${legacyCleaned > 0 ? ',' : ''} ${tmpCleaned} stale tmp files` : ''}`);
207237
}
208238

209-
// 6. Write updated manifest
239+
// 6. Write updated manifest (with per-file hashes for smart sync)
210240
writeFileSync(MANIFEST_PATH, JSON.stringify({
211241
version: currentVersion,
212242
files: [...newOfficialFiles].sort(),
243+
hashes: newHashes,
213244
updatedAt: new Date().toISOString(),
214245
}, null, 2));
215246

216-
log(`Installed ${copied} adapter files to ${USER_CLIS_DIR}` +
217-
(removed > 0 ? `, removed ${removed} deprecated files` : ''));
247+
log(`Synced adapters: ${cleared} local override(s) cleared` +
248+
(tsCleaned > 0 ? `, ${tsCleaned} stale .ts files removed` : ''));
218249
}
219250

220251
function main() {

src/cli.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,121 @@ cli({
935935
}
936936
});
937937

938+
// ── Built-in: adapter management ─────────────────────────────────────────
939+
const adapterCmd = program.command('adapter').description('Manage CLI adapters');
940+
941+
adapterCmd
942+
.command('status')
943+
.description('Show which sites have local overrides vs using official baseline')
944+
.action(async () => {
945+
const os = await import('node:os');
946+
const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
947+
const builtinClisDir = BUILTIN_CLIS;
948+
try {
949+
const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
950+
const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
951+
let builtinSites: string[] = [];
952+
try {
953+
const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true });
954+
builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
955+
} catch { /* no builtin dir */ }
956+
957+
if (userSites.length === 0) {
958+
console.log('No local adapter overrides. All sites use the official baseline.');
959+
return;
960+
}
961+
962+
console.log(`Local overrides in ~/.opencli/clis/ (${userSites.length} sites):\n`);
963+
for (const site of userSites) {
964+
const isOfficial = builtinSites.includes(site);
965+
const label = isOfficial ? 'override' : 'custom';
966+
console.log(` ${site} [${label}]`);
967+
}
968+
console.log(`\nOfficial baseline: ${builtinSites.length} sites in package`);
969+
} catch {
970+
console.log('No local adapter overrides. All sites use the official baseline.');
971+
}
972+
});
973+
974+
adapterCmd
975+
.command('eject')
976+
.description('Copy an official adapter to ~/.opencli/clis/ for local editing')
977+
.argument('<site>', 'Site name (e.g. twitter, bilibili)')
978+
.action(async (site: string) => {
979+
const os = await import('node:os');
980+
const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
981+
const builtinSiteDir = path.join(BUILTIN_CLIS, site);
982+
const userSiteDir = path.join(userClisDir, site);
983+
984+
try {
985+
await fs.promises.access(builtinSiteDir);
986+
} catch {
987+
console.error(styleText('red', `Error: Site "${site}" not found in official adapters.`));
988+
process.exitCode = EXIT_CODES.USAGE_ERROR;
989+
return;
990+
}
991+
992+
try {
993+
await fs.promises.access(userSiteDir);
994+
console.error(styleText('yellow', `Site "${site}" already exists in ~/.opencli/clis/. Use "opencli adapter reset ${site}" first to restore official version.`));
995+
process.exitCode = EXIT_CODES.USAGE_ERROR;
996+
return;
997+
} catch { /* good, doesn't exist yet */ }
998+
999+
fs.cpSync(builtinSiteDir, userSiteDir, { recursive: true });
1000+
console.log(styleText('green', `✅ Ejected "${site}" to ~/.opencli/clis/${site}/`));
1001+
console.log('You can now edit the adapter files. Changes take effect immediately.');
1002+
console.log(styleText('yellow', 'Note: Official updates to this adapter will overwrite your changes.'));
1003+
});
1004+
1005+
adapterCmd
1006+
.command('reset')
1007+
.description('Remove local override and restore official adapter version')
1008+
.argument('[site]', 'Site name (e.g. twitter, bilibili)')
1009+
.option('--all', 'Reset all local overrides')
1010+
.action(async (site: string | undefined, opts: { all?: boolean }) => {
1011+
const os = await import('node:os');
1012+
const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
1013+
1014+
if (opts.all) {
1015+
try {
1016+
const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
1017+
const dirs = userEntries.filter(e => e.isDirectory());
1018+
if (dirs.length === 0) {
1019+
console.log('No local sites to reset.');
1020+
return;
1021+
}
1022+
for (const dir of dirs) {
1023+
fs.rmSync(path.join(userClisDir, dir.name), { recursive: true, force: true });
1024+
}
1025+
console.log(styleText('green', `✅ Reset ${dirs.length} site(s). All adapters now use official baseline.`));
1026+
} catch {
1027+
console.log('No local sites to reset.');
1028+
}
1029+
return;
1030+
}
1031+
1032+
if (!site) {
1033+
console.error(styleText('red', 'Error: Please specify a site name or use --all.'));
1034+
process.exitCode = EXIT_CODES.USAGE_ERROR;
1035+
return;
1036+
}
1037+
1038+
const userSiteDir = path.join(userClisDir, site);
1039+
try {
1040+
await fs.promises.access(userSiteDir);
1041+
} catch {
1042+
console.error(styleText('yellow', `Site "${site}" has no local override.`));
1043+
return;
1044+
}
1045+
1046+
const isOfficial = fs.existsSync(path.join(BUILTIN_CLIS, site));
1047+
fs.rmSync(userSiteDir, { recursive: true, force: true });
1048+
console.log(styleText('green', isOfficial
1049+
? `✅ Reset "${site}". Now using official baseline.`
1050+
: `✅ Removed custom site "${site}".`));
1051+
});
1052+
9381053
// ── Built-in: daemon ──────────────────────────────────────────────────────
9391054
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
9401055
daemonCmd

src/discovery.ts

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { type InternalCliCommand, Strategy, registerCommand } from './registry.j
1616
import { getErrorMessage } from './errors.js';
1717
import { log } from './logger.js';
1818
import type { ManifestEntry } from './build-manifest.js';
19-
import { findPackageRoot, getCliManifestPath, getFetchAdaptersScriptPath } from './package-paths.js';
19+
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
2020

2121
/** User runtime directory: ~/.opencli */
2222
export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
@@ -77,42 +77,15 @@ export async function ensureUserCliCompatShims(baseDir: string = USER_OPENCLI_DI
7777
}
7878
}
7979

80-
const ADAPTER_MANIFEST_PATH = path.join(USER_OPENCLI_DIR, 'adapter-manifest.json');
81-
8280
/**
83-
* First-run fallback: if postinstall was skipped (--ignore-scripts) or failed,
84-
* trigger adapter fetch on first CLI invocation when ~/.opencli/clis/ is empty.
81+
* Ensure the user adapters directory exists.
82+
*
83+
* With smart sync, ~/.opencli/clis/ only holds files that differ from the
84+
* package baseline (upstream-synced cache + autofix output + user overrides).
85+
* Built-in adapters are loaded directly from the installed package.
8586
*/
8687
export async function ensureUserAdapters(): Promise<void> {
87-
// If adapter manifest already exists, adapters were fetched — nothing to do
88-
try {
89-
await fs.promises.access(ADAPTER_MANIFEST_PATH);
90-
return;
91-
} catch {
92-
// No manifest — first run or postinstall was skipped
93-
}
94-
95-
// Check if clis dir has any content (could be manually populated)
96-
try {
97-
const entries = await fs.promises.readdir(USER_CLIS_DIR);
98-
if (entries.length > 0) return;
99-
} catch {
100-
// Dir doesn't exist — needs fetch
101-
}
102-
103-
log.info('First run detected — copying adapters (one-time setup)...');
104-
try {
105-
const { execFileSync } = await import('node:child_process');
106-
const scriptPath = getFetchAdaptersScriptPath(PACKAGE_ROOT);
107-
execFileSync(process.execPath, [scriptPath], {
108-
stdio: 'inherit',
109-
env: { ...process.env, _OPENCLI_FIRST_RUN: '1' },
110-
timeout: 120_000,
111-
});
112-
} catch (err) {
113-
log.warn(`Could not fetch adapters on first run: ${getErrorMessage(err)}`);
114-
log.warn('Built-in adapters from the package will be used.');
115-
}
88+
await fs.promises.mkdir(USER_CLIS_DIR, { recursive: true });
11689
}
11790

11891
/**

src/engine.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import { discoverClis, discoverPlugins, ensureUserCliCompatShims, PLUGINS_DIR } from './discovery.js';
2+
import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js';
33
import { executeCommand } from './execution.js';
44
import { getRegistry, cli, Strategy } from './registry.js';
55
import { clearAllHooks, onAfterExecute } from './hooks.js';
@@ -114,6 +114,34 @@ cli({
114114
});
115115
});
116116

117+
describe('ensureUserAdapters', () => {
118+
it('creates user clis directory without triggering full copy', async () => {
119+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-ensure-'));
120+
const clisDir = path.join(tempDir, 'clis');
121+
try {
122+
// Patch USER_CLIS_DIR is not easy, so we test the function behavior indirectly:
123+
// ensureUserAdapters should not throw and should be very fast (no fetch script)
124+
const start = Date.now();
125+
await ensureUserAdapters();
126+
const elapsed = Date.now() - start;
127+
// Should complete quickly (< 1s) since it only creates a directory
128+
expect(elapsed).toBeLessThan(1000);
129+
} finally {
130+
await fs.promises.rm(tempDir, { recursive: true, force: true });
131+
}
132+
});
133+
134+
it('discoverClis handles empty user directory gracefully', async () => {
135+
const emptyDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-empty-'));
136+
try {
137+
// Should not throw for an empty directory (no adapters to discover)
138+
await expect(discoverClis(emptyDir)).resolves.not.toThrow();
139+
} finally {
140+
await fs.promises.rm(emptyDir, { recursive: true, force: true });
141+
}
142+
});
143+
});
144+
117145
describe('discoverPlugins', () => {
118146
const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
119147
const yamlPath = path.join(testPluginDir, 'greeting.yaml');

src/main.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ if (argv[0] === 'completion' && argv.length >= 2) {
5151
// Fast path: --get-completions — read from manifest, skip discovery
5252
const getCompIdx = process.argv.indexOf('--get-completions');
5353
if (getCompIdx !== -1) {
54-
// Only require manifest for directories that actually exist.
55-
// If user clis dir doesn't exist, there are no user adapters to miss.
54+
// Only include manifests that actually exist on disk.
55+
// With sparse override, the user clis dir may exist but have no manifest.
5656
const manifestPaths = [getCliManifestPath(BUILTIN_CLIS)];
57-
try { fs.accessSync(USER_CLIS); manifestPaths.push(getCliManifestPath(USER_CLIS)); } catch { /* no user dir */ }
57+
const userManifest = getCliManifestPath(USER_CLIS);
58+
try { fs.accessSync(userManifest); manifestPaths.push(userManifest); } catch { /* no user manifest */ }
5859
if (hasAllManifests(manifestPaths)) {
5960
const rest = process.argv.slice(getCompIdx + 1);
6061
let cursor: number | undefined;

0 commit comments

Comments
 (0)