From 046735e6fafb4fdee357699d285c4b59cbe8b0d1 Mon Sep 17 00:00:00 2001 From: chukka-venugopalam Date: Wed, 23 Sep 2026 21:35:12 +0530 Subject: [PATCH 1/2] fix: copy sibling plugin files when creating an adapter override createAdapterOverride() only copied the named command file into ~/.webcmd/clis//, so a plugin command that imports a sibling file in the same plugin directory (shared helpers, types, constants) would have that import resolve to nothing once forked. Copy every other file in the plugin's directory alongside the command file, skipping any that already exist in the override directory so a user's own edits to a sibling aren't clobbered. Fixes #418 --- src/adapter-override.test.ts | 37 ++++++++++++++++++++++++++++++++++++ src/adapter-override.ts | 21 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/adapter-override.test.ts b/src/adapter-override.test.ts index 757b967b9..76db0412b 100644 --- a/src/adapter-override.test.ts +++ b/src/adapter-override.test.ts @@ -49,6 +49,43 @@ describe('createAdapterOverride', () => { expect(record.sourceSha256).toBe(fileSha256(pluginFile)); }); + it('copies sibling files from the plugin directory alongside the command', () => { + const helperFile = path.join(path.dirname(pluginFile), 'helpers.js'); + fs.writeFileSync(helperFile, '// shared helper\nmodule.exports = { parse() {} };\n'); + seedLock({ + linkedin: { + source: { kind: 'local', path: path.resolve(home) }, + commitHash: 'a'.repeat(40), + installedAt: new Date().toISOString(), + }, + }); + + const result = createAdapterOverride('linkedin/search', { homeDir: home }); + + const copiedHelper = path.join(path.dirname(result.overridePath), 'helpers.js'); + expect(fs.existsSync(copiedHelper)).toBe(true); + expect(fs.readFileSync(copiedHelper, 'utf-8')).toBe(fs.readFileSync(helperFile, 'utf-8')); + }); + + it('does not overwrite a sibling file the user already has in their override dir', () => { + const helperFile = path.join(path.dirname(pluginFile), 'helpers.js'); + fs.writeFileSync(helperFile, '// upstream helper\n'); + const overrideDir = path.join(home, '.webcmd', 'clis', 'linkedin'); + fs.mkdirSync(overrideDir, { recursive: true }); + fs.writeFileSync(path.join(overrideDir, 'helpers.js'), '// user-edited helper\n'); + seedLock({ + linkedin: { + source: { kind: 'local', path: path.resolve(home) }, + commitHash: 'a'.repeat(40), + installedAt: new Date().toISOString(), + }, + }); + + createAdapterOverride('linkedin/search', { homeDir: home }); + + expect(fs.readFileSync(path.join(overrideDir, 'helpers.js'), 'utf-8')).toBe('// user-edited helper\n'); + }); + it('refuses a command that comes from no installed plugin', () => { expect(() => createAdapterOverride('nosuch/cmd', { homeDir: home })) .toThrow(/not provided by an installed plugin/i); diff --git a/src/adapter-override.ts b/src/adapter-override.ts index d14990b45..bae687f09 100644 --- a/src/adapter-override.ts +++ b/src/adapter-override.ts @@ -103,6 +103,27 @@ export function createAdapterOverride( fs.mkdirSync(path.dirname(basePath), { recursive: true }); fs.writeFileSync(basePath, content); + // The plugin command file may import sibling files from the same plugin + // directory (shared helpers, types, constants). Only the named command + // file was copied above, so a forked override whose command file imports + // a sibling would resolve those imports to nothing once it's relocated + // into clis/. Copy every other file in the plugin's directory alongside + // it — mirroring the plugin's own directory layout under clis// — + // so relative imports the command file makes keep resolving after the + // fork. The command file itself is excluded here since it's already + // been written above (and clis/ layout intentionally flattens site/ + // rather than nesting per-command, so a second write would collide). + const pluginDir = path.dirname(pluginFile); + const commandFileName = path.basename(pluginFile); + const siblingEntries = fs.readdirSync(pluginDir, { withFileTypes: true }); + for (const entry of siblingEntries) { + if (!entry.isFile() || entry.name === commandFileName) continue; + const siblingSrc = path.join(pluginDir, entry.name); + const siblingDest = path.join(path.dirname(overridePath), entry.name); + if (fs.existsSync(siblingDest)) continue; // don't clobber an existing override file + fs.copyFileSync(siblingSrc, siblingDest); + } + const commitHash = readCommitHashFor(homeDir, site); const records = readOverrideRecords(options.homeDir); From 87e0eb4f84c8b2550830bbe6433c29a2dbbeffb6 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 25 Sep 2026 16:18:24 +0530 Subject: [PATCH 2/2] fix(adapter): copy only imported plugin helpers --- src/adapter-override.test.ts | 33 ++++++++++++++++++++++ src/adapter-override.ts | 53 ++++++++++++++++++++++++------------ 2 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/adapter-override.test.ts b/src/adapter-override.test.ts index 76db0412b..0d5dfd3c7 100644 --- a/src/adapter-override.test.ts +++ b/src/adapter-override.test.ts @@ -51,7 +51,9 @@ describe('createAdapterOverride', () => { it('copies sibling files from the plugin directory alongside the command', () => { const helperFile = path.join(path.dirname(pluginFile), 'helpers.js'); + fs.writeFileSync(pluginFile, "import { parse } from './helpers.js';\n"); fs.writeFileSync(helperFile, '// shared helper\nmodule.exports = { parse() {} };\n'); + fs.writeFileSync(path.join(path.dirname(pluginFile), 'inbox.js'), "import { cli } from '@agentrhq/webcmd/registry';\n"); seedLock({ linkedin: { source: { kind: 'local', path: path.resolve(home) }, @@ -65,10 +67,12 @@ describe('createAdapterOverride', () => { const copiedHelper = path.join(path.dirname(result.overridePath), 'helpers.js'); expect(fs.existsSync(copiedHelper)).toBe(true); expect(fs.readFileSync(copiedHelper, 'utf-8')).toBe(fs.readFileSync(helperFile, 'utf-8')); + expect(fs.existsSync(path.join(path.dirname(result.overridePath), 'inbox.js'))).toBe(false); }); it('does not overwrite a sibling file the user already has in their override dir', () => { const helperFile = path.join(path.dirname(pluginFile), 'helpers.js'); + fs.writeFileSync(pluginFile, "import { parse } from './helpers.js';\n"); fs.writeFileSync(helperFile, '// upstream helper\n'); const overrideDir = path.join(home, '.webcmd', 'clis', 'linkedin'); fs.mkdirSync(overrideDir, { recursive: true }); @@ -86,6 +90,35 @@ describe('createAdapterOverride', () => { expect(fs.readFileSync(path.join(overrideDir, 'helpers.js'), 'utf-8')).toBe('// user-edited helper\n'); }); + it('copies nested and transitive imports without copying unrelated commands', () => { + const pluginDir = path.dirname(pluginFile); + fs.writeFileSync(pluginFile, "import { parse } from './helpers.js';\n"); + fs.writeFileSync(path.join(pluginDir, 'helpers.js'), "export { parse } from './shared/parse.js';\n"); + fs.mkdirSync(path.join(pluginDir, 'shared')); + fs.writeFileSync(path.join(pluginDir, 'shared', 'parse.js'), 'export const parse = () => 1;\n'); + fs.writeFileSync(path.join(pluginDir, 'inbox.js'), 'export const unrelated = true;\n'); + + const result = createAdapterOverride('linkedin/search', { homeDir: home }); + const overrideDir = path.dirname(result.overridePath); + expect(fs.readFileSync(path.join(overrideDir, 'shared', 'parse.js'), 'utf-8')).toContain('parse'); + expect(fs.existsSync(path.join(overrideDir, 'inbox.js'))).toBe(false); + }); + + it('ignores commented imports and refuses imports outside the plugin', () => { + const pluginDir = path.dirname(pluginFile); + fs.writeFileSync(pluginFile, "// import './inbox.js'\nimport './helpers.js';\n"); + fs.writeFileSync(path.join(pluginDir, 'helpers.js'), 'export const ok = true;\n'); + fs.writeFileSync(path.join(pluginDir, 'inbox.js'), 'export const unrelated = true;\n'); + const result = createAdapterOverride('linkedin/search', { homeDir: home }); + expect(fs.existsSync(path.join(path.dirname(result.overridePath), 'inbox.js'))).toBe(false); + + fs.rmSync(result.overridePath); + fs.writeFileSync(pluginFile, "import '../outside.js';\n"); + fs.writeFileSync(path.join(path.dirname(pluginDir), 'outside.js'), 'export {};\n'); + expect(() => createAdapterOverride('linkedin/search', { homeDir: home })).toThrow(/escapes the plugin directory/); + expect(fs.existsSync(result.overridePath)).toBe(false); + }); + it('refuses a command that comes from no installed plugin', () => { expect(() => createAdapterOverride('nosuch/cmd', { homeDir: home })) .toThrow(/not provided by an installed plugin/i); diff --git a/src/adapter-override.ts b/src/adapter-override.ts index bae687f09..c0e79b36a 100644 --- a/src/adapter-override.ts +++ b/src/adapter-override.ts @@ -50,6 +50,37 @@ function readCommitHashFor(homeDir: string, plugin: string): string | null { } } +function importedPluginFiles(entry: string): string[] { + const root = path.dirname(entry); + const seen = new Set([entry]); + const pending = [entry]; + const files: string[] = []; + while (pending.length) { + const current = pending.pop()!; + // ponytail: this handles literal imports; use a parser if adapters start building specifiers dynamically. + const source = fs.readFileSync(current, 'utf-8').replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, ''); + for (const match of source.matchAll(/(?:\bfrom\s*|\bimport\s*|\brequire\s*)\(?\s*(['"])(\.[^'"]*)\1/g)) { + const specifier = match[2]!; + const candidate = path.resolve(path.dirname(current), specifier); + const target = fs.existsSync(candidate) ? candidate : `${candidate}.js`; + const relative = path.relative(root, target); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Import ${specifier} in ${current} escapes the plugin directory`); + } + if (!fs.existsSync(target)) throw new Error(`Imported file ${specifier} in ${current} does not exist`); + const realRelative = path.relative(fs.realpathSync(root), fs.realpathSync(target)); + if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) { + throw new Error(`Import ${specifier} in ${current} escapes the plugin directory`); + } + if (seen.has(target)) continue; + seen.add(target); + files.push(relative); + if (path.extname(target) === '.js') pending.push(target); + } + } + return files; +} + /** Fork an installed plugin's command file into ~/.webcmd/clis and record provenance. */ export function createAdapterOverride( commandKey: string, @@ -96,6 +127,7 @@ export function createAdapterOverride( } const basePath = getBaseCopyPath(commandKey, options.homeDir); + const dependencies = importedPluginFiles(pluginFile); const content = fs.readFileSync(pluginFile); fs.mkdirSync(path.dirname(overridePath), { recursive: true }); @@ -103,24 +135,11 @@ export function createAdapterOverride( fs.mkdirSync(path.dirname(basePath), { recursive: true }); fs.writeFileSync(basePath, content); - // The plugin command file may import sibling files from the same plugin - // directory (shared helpers, types, constants). Only the named command - // file was copied above, so a forked override whose command file imports - // a sibling would resolve those imports to nothing once it's relocated - // into clis/. Copy every other file in the plugin's directory alongside - // it — mirroring the plugin's own directory layout under clis// — - // so relative imports the command file makes keep resolving after the - // fork. The command file itself is excluded here since it's already - // been written above (and clis/ layout intentionally flattens site/ - // rather than nesting per-command, so a second write would collide). - const pluginDir = path.dirname(pluginFile); - const commandFileName = path.basename(pluginFile); - const siblingEntries = fs.readdirSync(pluginDir, { withFileTypes: true }); - for (const entry of siblingEntries) { - if (!entry.isFile() || entry.name === commandFileName) continue; - const siblingSrc = path.join(pluginDir, entry.name); - const siblingDest = path.join(path.dirname(overridePath), entry.name); + for (const relative of dependencies) { + const siblingSrc = path.join(path.dirname(pluginFile), relative); + const siblingDest = path.join(path.dirname(overridePath), relative); if (fs.existsSync(siblingDest)) continue; // don't clobber an existing override file + fs.mkdirSync(path.dirname(siblingDest), { recursive: true }); fs.copyFileSync(siblingSrc, siblingDest); }