Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/adapter-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions src/adapter-override.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([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,
Expand Down Expand Up @@ -96,13 +127,22 @@ 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 });
fs.writeFileSync(overridePath, content);
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);
Expand Down
Loading