From 20ab031c737e588a2e2a0615c179e65bd697960d Mon Sep 17 00:00:00 2001 From: _silhouette Date: Tue, 14 Apr 2026 23:05:34 +0800 Subject: [PATCH 1/3] fix: discover symlinked adapter directories in ~/.opencli/clis/ `readdir({ withFileTypes: true })` returns `Dirent` objects where `isDirectory()` returns false for symlinks, even when the target is a directory. This causes user adapters installed as symlinks (e.g. `~/.opencli/clis/mysite -> /path/to/project/adapters/mysite`) to be silently skipped during filesystem discovery. Fix: also check `isSymbolicLink()` and verify the target is a directory via `fs.promises.stat()` before scanning. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/discovery.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/discovery.ts b/src/discovery.ts index e86c4be0f..540c84f43 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -155,10 +155,17 @@ async function discoverClisFromFs(dir: string): Promise { const entries = await fs.promises.readdir(dir, { withFileTypes: true }); const sitePromises = entries - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() || entry.isSymbolicLink()) .map(async (entry) => { const site = entry.name; const siteDir = path.join(dir, site); + // For symlinks, verify the target is a directory + if (entry.isSymbolicLink()) { + try { + const stat = await fs.promises.stat(siteDir); + if (!stat.isDirectory()) return; + } catch { return; } + } const files = await fs.promises.readdir(siteDir); await Promise.all(files.map(async (file) => { const filePath = path.join(siteDir, file); From 89bccf28142d6f322ec9145be7f63a7757c2f685 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 15 Apr 2026 13:20:07 +0800 Subject: [PATCH 2/3] fix: handle symlinks in adapter list/reset and improve error logging - adapter status/reset --all now discover symlinked site directories (same isDirectory()||isSymbolicLink()+stat pattern as discovery) - discovery.ts symlink error handling aligned with isDiscoverablePluginDir: log warning for unexpected errors (not ENOENT/ENOTDIR) --- src/cli.ts | 21 ++++++++++++++++++--- src/discovery.ts | 8 +++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index d9502a7f1..7f3a9ccf4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -938,6 +938,21 @@ cli({ } }); + // Filter Dirent entries to directories, including symlinks that point to directories + async function filterDirentDirs(entries: fs.Dirent[], parentDir: string): Promise { + const results: fs.Dirent[] = []; + for (const e of entries) { + if (e.isDirectory()) { results.push(e); continue; } + if (e.isSymbolicLink()) { + try { + const stat = await fs.promises.stat(path.join(parentDir, e.name)); + if (stat.isDirectory()) results.push(e); + } catch { /* broken or non-dir symlink */ } + } + } + return results; + } + // ── Built-in: adapter management ───────────────────────────────────────── const adapterCmd = program.command('adapter').description('Manage CLI adapters'); @@ -950,11 +965,11 @@ cli({ const builtinClisDir = BUILTIN_CLIS; try { const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true }); - const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort(); + const userSites = (await filterDirentDirs(userEntries, userClisDir)).map(e => e.name).sort(); let builtinSites: string[] = []; try { const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true }); - builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort(); + builtinSites = (await filterDirentDirs(builtinEntries, builtinClisDir)).map(e => e.name).sort(); } catch { /* no builtin dir */ } if (userSites.length === 0) { @@ -1017,7 +1032,7 @@ cli({ if (opts.all) { try { const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true }); - const dirs = userEntries.filter(e => e.isDirectory()); + const dirs = await filterDirentDirs(userEntries, userClisDir); if (dirs.length === 0) { console.log('No local sites to reset.'); return; diff --git a/src/discovery.ts b/src/discovery.ts index 540c84f43..1edfac932 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -164,7 +164,13 @@ async function discoverClisFromFs(dir: string): Promise { try { const stat = await fs.promises.stat(siteDir); if (!stat.isDirectory()) return; - } catch { return; } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + log.warn(`Failed to inspect symlink ${siteDir}: ${getErrorMessage(err)}`); + } + return; + } } const files = await fs.promises.readdir(siteDir); await Promise.all(files.map(async (file) => { From 0408bba2429e19767d90673934b8255139df3afa Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 15 Apr 2026 13:22:43 +0800 Subject: [PATCH 3/3] test: cover symlinked adapter site discovery --- src/engine.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/engine.test.ts b/src/engine.test.ts index a8310bd86..41e6320c2 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -112,6 +112,36 @@ cli({ await fs.promises.rm(tempOpencliRoot, { recursive: true, force: true }); } }); + + it('discovers CLI modules from symlinked site directories', async () => { + const tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-symlinked-site-')); + const userClisDir = path.join(tempRoot, 'clis'); + const targetSiteDir = path.join(tempRoot, 'external-site'); + const linkSiteDir = path.join(userClisDir, 'symlink-site'); + + try { + await fs.promises.mkdir(targetSiteDir, { recursive: true }); + await fs.promises.mkdir(userClisDir, { recursive: true }); + await fs.promises.writeFile(path.join(targetSiteDir, 'hello.js'), ` +import { cli, Strategy } from '${pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href}'; +cli({ + site: 'symlink-site', + name: 'hello', + description: 'hello command', + strategy: Strategy.PUBLIC, + browser: false, + func: async () => [{ ok: true }], +}); +`); + await fs.promises.symlink(targetSiteDir, linkSiteDir, 'dir'); + + await discoverClis(userClisDir); + + expect(getRegistry().get('symlink-site/hello')).toBeDefined(); + } finally { + await fs.promises.rm(tempRoot, { recursive: true, force: true }); + } + }); }); describe('ensureUserAdapters', () => {