diff --git a/src/adapter-override.test.ts b/src/adapter-override.test.ts index 757b967b9..0d5dfd3c7 100644 --- a/src/adapter-override.test.ts +++ b/src/adapter-override.test.ts @@ -49,6 +49,76 @@ 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(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) }, + 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')); + 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 }); + 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('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 d14990b45..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,6 +135,14 @@ export function createAdapterOverride( fs.mkdirSync(path.dirname(basePath), { recursive: true }); fs.writeFileSync(basePath, content); + 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); + } + const commitHash = readCommitHashFor(homeDir, site); const records = readOverrideRecords(options.homeDir);