Skip to content

Commit 5e2e1df

Browse files
ByteYuejackwener
andauthored
fix(plugin): detect symlinked monorepo sub-plugins in discoverPlugins (jackwener#487)
* fix(plugin): detect symlinked monorepo sub-plugins in discoverPlugins discoverPlugins() used entry.isDirectory() to filter plugin directories, but monorepo sub-plugins are installed as symlinks pointing into ~/.opencli/monorepos/. On most Node.js versions, isDirectory() returns false for symlinks, causing monorepo plugin commands to be silently skipped during discovery. Add entry.isSymbolicLink() check so symlinked plugin directories are properly discovered and their commands registered. * fix(plugin): skip broken symlink discovery --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 39eec0d commit 5e2e1df

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

src/discovery.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,9 @@ export async function discoverPlugins(): Promise<void> {
196196
try { await fs.promises.access(PLUGINS_DIR); } catch { return; }
197197
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true });
198198
for (const entry of entries) {
199-
if (!entry.isDirectory()) continue;
200-
await discoverPluginDir(path.join(PLUGINS_DIR, entry.name), entry.name);
199+
const pluginDir = path.join(PLUGINS_DIR, entry.name);
200+
if (!(await isDiscoverablePluginDir(entry, pluginDir))) continue;
201+
await discoverPluginDir(pluginDir, entry.name);
201202
}
202203
}
203204

@@ -246,3 +247,18 @@ async function isCliModule(filePath: string): Promise<boolean> {
246247
return false;
247248
}
248249
}
250+
251+
async function isDiscoverablePluginDir(entry: fs.Dirent, pluginDir: string): Promise<boolean> {
252+
if (entry.isDirectory()) return true;
253+
if (!entry.isSymbolicLink()) return false;
254+
255+
try {
256+
return (await fs.promises.stat(pluginDir)).isDirectory();
257+
} catch (err) {
258+
const code = (err as NodeJS.ErrnoException).code;
259+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
260+
log.warn(`Failed to inspect plugin link ${pluginDir}: ${getErrorMessage(err)}`);
261+
}
262+
return false;
263+
}
264+
}

src/engine.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,15 @@ cli({
8383
describe('discoverPlugins', () => {
8484
const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
8585
const yamlPath = path.join(testPluginDir, 'greeting.yaml');
86+
const symlinkTargetDir = path.join(os.tmpdir(), '__test-plugin-symlink-target__');
87+
const symlinkPluginDir = path.join(PLUGINS_DIR, '__test-plugin-symlink__');
88+
const brokenSymlinkDir = path.join(PLUGINS_DIR, '__test-plugin-broken__');
8689

8790
afterEach(async () => {
8891
try { await fs.promises.rm(testPluginDir, { recursive: true }); } catch {}
92+
try { await fs.promises.rm(symlinkPluginDir, { recursive: true, force: true }); } catch {}
93+
try { await fs.promises.rm(symlinkTargetDir, { recursive: true, force: true }); } catch {}
94+
try { await fs.promises.rm(brokenSymlinkDir, { recursive: true, force: true }); } catch {}
8995
});
9096

9197
it('discovers YAML plugins from ~/.opencli/plugins/', async () => {
@@ -118,6 +124,38 @@ columns: [message]
118124
// discoverPlugins should not throw if ~/.opencli/plugins/ does not exist
119125
await expect(discoverPlugins()).resolves.not.toThrow();
120126
});
127+
128+
it('discovers YAML plugins from symlinked plugin directories', async () => {
129+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
130+
await fs.promises.mkdir(symlinkTargetDir, { recursive: true });
131+
await fs.promises.writeFile(path.join(symlinkTargetDir, 'hello.yaml'), `
132+
site: __test-plugin-symlink__
133+
name: hello
134+
description: Test plugin greeting via symlink
135+
strategy: public
136+
browser: false
137+
138+
pipeline:
139+
- evaluate: "() => [{ message: 'hello from symlink plugin' }]"
140+
141+
columns: [message]
142+
`);
143+
await fs.promises.symlink(symlinkTargetDir, symlinkPluginDir, 'dir');
144+
145+
await discoverPlugins();
146+
147+
const cmd = getRegistry().get('__test-plugin-symlink__/hello');
148+
expect(cmd).toBeDefined();
149+
expect(cmd!.description).toBe('Test plugin greeting via symlink');
150+
});
151+
152+
it('skips broken plugin symlinks without throwing', async () => {
153+
await fs.promises.mkdir(PLUGINS_DIR, { recursive: true });
154+
await fs.promises.symlink(path.join(os.tmpdir(), '__missing-plugin-target__'), brokenSymlinkDir, 'dir');
155+
156+
await expect(discoverPlugins()).resolves.not.toThrow();
157+
expect(getRegistry().get('__test-plugin-broken__/hello')).toBeUndefined();
158+
});
121159
});
122160

123161
describe('executeCommand', () => {

0 commit comments

Comments
 (0)