Skip to content
Open
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
2 changes: 1 addition & 1 deletion docs/publish-community-plugin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The root `webcmd-plugin.json` and the README community table are generated by re

## Authoring Through Webcmd

To fork an installed command into a private copy you can edit, run `webcmd adapter override <site>/<command>` in either mode. Locally that copies the file into `~/.webcmd/clis` and records provenance, and `webcmd adapter path <site>/<command>` prints the file to edit. In hosted mode it forks the command into a tenant-private package, and `webcmd adapter source get|put <site>/<command>` reads and writes that source. Use `webcmd browser init <site>/<command>` to scaffold, `webcmd browser verify <site>/<command>` to validate, and `webcmd site memory` commands to retain sanitized discovery evidence.
To fork an installed command into a private copy you can edit, run `webcmd adapter override <site>/<command>` in either mode. Locally that copies the command — and any sibling files it imports — into `~/.webcmd/clis` and records provenance, and `webcmd adapter path <site>/<command>` prints the file to edit. In hosted mode it forks the command into a tenant-private package, and `webcmd adapter source get|put <site>/<command>` reads and writes that source. Use `webcmd browser init <site>/<command>` to scaffold, `webcmd browser verify <site>/<command>` to validate, and `webcmd site memory` commands to retain sanitized discovery evidence.

## Validation and Pull Request

Expand Down
109 changes: 109 additions & 0 deletions src/adapter-import-closure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { collectRelativeImportClosure } from './adapter-import-closure.js';

let root: string;

function write(relPath: string, source: string): string {
const filePath = path.join(root, relPath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, source);
return filePath;
}

beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'aic-'));
});

afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});

describe('collectRelativeImportClosure', () => {
it('returns nothing for an adapter with no relative imports', () => {
const entry = write('search.js', "import { cli } from '@agentrhq/webcmd/registry';\n");
expect(collectRelativeImportClosure(entry, root)).toEqual([]);
});

it('collects a sibling import', () => {
const entry = write('search.js', "import { parseLimit } from './shared.js';\n");
write('shared.js', 'export const parseLimit = () => 1;\n');
expect(collectRelativeImportClosure(entry, root)).toEqual(['shared.js']);
});

it('follows the graph transitively', () => {
const entry = write('timeline.js', "import { a } from './posts-core.js';\n");
write('posts-core.js', "import { b } from './shared.js';\n");
write('shared.js', "import { c } from './_utils.js';\n");
write('_utils.js', 'export const c = 1;\n');
expect(collectRelativeImportClosure(entry, root)).toEqual([
'_utils.js', 'posts-core.js', 'shared.js',
]);
});

it('preserves nested directories in the returned paths', () => {
const entry = write('publish.js', "import { p } from './_shared/private-publish.js';\n");
write('_shared/private-publish.js', "import { r } from './runtime-info.js';\n");
write('_shared/runtime-info.js', 'export const r = 1;\n');
expect(collectRelativeImportClosure(entry, root)).toEqual([
'_shared/private-publish.js', '_shared/runtime-info.js',
]);
});

it('terminates on an import cycle', () => {
const entry = write('a.js', "import { b } from './b.js';\n");
write('b.js', "import { a } from './a.js';\nexport const b = 1;\n");
expect(collectRelativeImportClosure(entry, root)).toEqual(['b.js']);
});

it('recognises re-exports, side-effect imports, dynamic imports, require, and double quotes', () => {
const entry = write('mixed.js', [
"export { helper } from './reexport.js';",
"export * from './star.js';",
"import './side-effect.js';",
"const late = await import('./dynamic.js');",
"const legacy = require('./legacy.js');",
'import { quoted } from "./double-quoted.js";',
].join('\n'));
for (const name of ['reexport', 'star', 'side-effect', 'dynamic', 'legacy', 'double-quoted']) {
write(`${name}.js`, 'export const x = 1;\n');
}
expect(collectRelativeImportClosure(entry, root)).toEqual([
'double-quoted.js', 'dynamic.js', 'legacy.js', 'reexport.js', 'side-effect.js', 'star.js',
]);
});

it('ignores bare package specifiers', () => {
const entry = write('search.js', [
"import { cli } from '@agentrhq/webcmd/registry';",
"import * as path from 'node:path';",
"import { local } from './shared.js';",
].join('\n'));
write('shared.js', 'export const local = 1;\n');
expect(collectRelativeImportClosure(entry, root)).toEqual(['shared.js']);
});

it('skips a relative import whose target does not exist', () => {
// The plugin itself is already broken here; the override should fail the
// same way the plugin does rather than refuse to be created.
const entry = write('search.js', "import { gone } from './missing.js';\n");
expect(collectRelativeImportClosure(entry, root)).toEqual([]);
});

it('refuses a specifier that escapes the plugin directory', () => {
const entry = write('plugin/search.js', "import { outside } from '../outside.js';\n");
write('outside.js', 'export const outside = 1;\n');
expect(() => collectRelativeImportClosure(entry, path.join(root, 'plugin')))
.toThrow(/resolves outside the plugin directory/i);
});

it('refuses an escaping specifier reached transitively', () => {
const entry = write('plugin/search.js', "import { s } from './shared.js';\n");
write('plugin/shared.js', "import { outside } from '../../escape.js';\n");
write('escape.js', 'export const outside = 1;\n');
expect(() => collectRelativeImportClosure(entry, path.join(root, 'plugin')))
.toThrow(/shared\.js imports "\.\.\/\.\.\/escape\.js"/);
});
});
101 changes: 101 additions & 0 deletions src/adapter-import-closure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Resolve the relative-import closure of an adapter file.
*
* `webcmd adapter override` forks a single command file out of an installed
* plugin. Most plugin commands are not single files: 510 of the 871 adapter
* files shipped in this repo import a sibling helper (`./shared.js`,
* `./_shared/protocol-capture.js`, ...). Copying the command alone produces
* an override that throws `Cannot find module` on load, so command resolution
* silently falls back to the plugin copy while `adapter status` still reports
* the override as tracked — the user edits a file that never runs.
*
* This module walks the transitive relative-import graph so the fork can copy
* everything the command actually needs.
*/

import * as fs from 'node:fs';
import * as path from 'node:path';

/**
* Relative specifiers in `import`/`export`/`require` position.
*
* Covers every form the adapter corpus and hand-written private adapters use:
* import x from './a.js' export { x } from './a.js'
* import './a.js' export * from './a.js'
* await import('./a.js') require('./a.js')
*
* Comments are deliberately not stripped first. A commented-out import can
* only ever make the closure copy one file too many — harmless, since the
* copy is tracked and removed by `adapter reset`. Comment-stripping, by
* contrast, can drop a *real* import when it trips over a string, which
* silently reintroduces the broken override this module exists to prevent.
*/
const RELATIVE_SPECIFIER = /(?:\bfrom\s*|\bimport\s*|\brequire\s*)\(?\s*(['"])(\.[^'"]*)\1/g;

/** Every relative specifier appearing in `source`, in order of appearance. */
function readRelativeSpecifiers(source: string): string[] {
const specifiers: string[] = [];
for (const match of source.matchAll(RELATIVE_SPECIFIER)) {
const specifier = match[2];
if (specifier) specifiers.push(specifier);
}
return specifiers;
}

/** Path relative to `rootDir`, normalised to forward slashes for storage. */
function toPosixRelative(rootDir: string, target: string): string {
return path.relative(rootDir, target).split(path.sep).join('/');
}

function escapesRoot(rootDir: string, target: string): boolean {
const relative = path.relative(rootDir, target);
return relative.startsWith('..') || path.isAbsolute(relative);
}

/**
* Every file `entryFile` imports transitively through relative specifiers,
* as paths relative to `rootDir` and sorted for a stable provenance record.
* `entryFile` itself is not included.
*
* A specifier that resolves outside `rootDir` throws: the fork cannot be made
* loadable by copying inside the plugin directory, and a half-copied override
* is exactly the silent breakage this closure exists to prevent. A specifier
* pointing at a file that does not exist is skipped — the plugin is already
* broken in that case, and the override should fail the same way the plugin
* does rather than blame the fork.
*/
export function collectRelativeImportClosure(entryFile: string, rootDir: string): string[] {
const resolvedRoot = path.resolve(rootDir);
const resolvedEntry = path.resolve(entryFile);
const closure = new Set<string>();
const visited = new Set<string>([resolvedEntry]);
const queue: string[] = [resolvedEntry];

while (queue.length > 0) {
const current = queue.shift()!;
let source: string;
try {
source = fs.readFileSync(current, 'utf-8');
} catch {
continue;
}

for (const specifier of readRelativeSpecifiers(source)) {
const target = path.resolve(path.dirname(current), specifier);
if (escapesRoot(resolvedRoot, target)) {
throw new Error(
`${toPosixRelative(resolvedRoot, current) || path.basename(current)} imports "${specifier}", ` +
`which resolves outside the plugin directory ${resolvedRoot}. ` +
'An override can only copy files from inside the plugin, so this adapter cannot be forked as-is.',
);
}
if (visited.has(target)) continue;
visited.add(target);
if (!fs.existsSync(target)) continue;
closure.add(toPosixRelative(resolvedRoot, target));
queue.push(target);
}
}

return [...closure].sort();
}
149 changes: 148 additions & 1 deletion src/adapter-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createAdapterOverride } from './adapter-override.js';
import { getBaseCopyPath, readOverrideRecords, fileSha256 } from './override-provenance.js';
import {
getBaseCopyPath,
getBaseDependencyPath,
readOverrideRecords,
fileSha256,
removeOverrideRecords,
} from './override-provenance.js';
import type { LockEntry } from './plugin.js';
import { createProgram } from './cli.js';

Expand Down Expand Up @@ -94,3 +100,144 @@ describe('createAdapterOverride', () => {
}
});
});

describe('createAdapterOverride import closure', () => {
const pluginDir = () => path.join(home, '.webcmd', 'plugins', 'linkedin');
const clisDir = () => path.join(home, '.webcmd', 'clis', 'linkedin');

function writePluginFile(relPath: string, source: string): string {
const filePath = path.join(pluginDir(), relPath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, source);
return filePath;
}

it('copies a sibling import so the override can actually load', () => {
// The bug: only search.js was copied, so the override threw
// "Cannot find module .../clis/linkedin/shared.js" on load and command
// resolution silently fell back to the plugin copy.
fs.writeFileSync(pluginFile, "import { parseLimit } from './shared.js';\n");
writePluginFile('shared.js', 'export const parseLimit = () => 1;\n');

const result = createAdapterOverride('linkedin/search', { homeDir: home });

const copied = path.join(clisDir(), 'shared.js');
expect(fs.existsSync(copied)).toBe(true);
expect(fs.readFileSync(copied, 'utf-8')).toBe('export const parseLimit = () => 1;\n');
expect(result.dependencies).toEqual(['shared.js']);
});

it('copies the transitive closure, preserving nested directories', () => {
fs.writeFileSync(pluginFile, "import { a } from './posts-core.js';\n");
writePluginFile('posts-core.js', "import { b } from './_shared/util.js';\n");
writePluginFile('_shared/util.js', 'export const b = 1;\n');

const result = createAdapterOverride('linkedin/search', { homeDir: home });

expect(result.dependencies).toEqual(['_shared/util.js', 'posts-core.js']);
expect(fs.existsSync(path.join(clisDir(), 'posts-core.js'))).toBe(true);
expect(fs.existsSync(path.join(clisDir(), '_shared', 'util.js'))).toBe(true);
});

it('keeps a fork-time base copy of every copied file', () => {
fs.writeFileSync(pluginFile, "import { s } from './shared.js';\n");
writePluginFile('shared.js', 'export const s = 1;\n');

createAdapterOverride('linkedin/search', { homeDir: home });

const baseCopy = getBaseDependencyPath('linkedin', 'shared.js', home);
expect(fs.readFileSync(baseCopy, 'utf-8')).toBe('export const s = 1;\n');
});

it('records each copied file with the sha256 of the copy the override loads', () => {
fs.writeFileSync(pluginFile, "import { s } from './shared.js';\n");
const sharedPlugin = writePluginFile('shared.js', 'export const s = 1;\n');

createAdapterOverride('linkedin/search', { homeDir: home });

const record = readOverrideRecords(home)['linkedin/search']!;
expect(record.dependencies).toEqual([
{ path: 'shared.js', sha256: fileSha256(sharedPlugin) },
]);
});

it('omits the dependencies key entirely for a single-file adapter', () => {
createAdapterOverride('linkedin/search', { homeDir: home });
expect(readOverrideRecords(home)['linkedin/search']!.dependencies).toBeUndefined();
});

it('removes the base copies of copied files when the override is dropped', () => {
fs.writeFileSync(pluginFile, "import { s } from './shared.js';\n");
writePluginFile('shared.js', 'export const s = 1;\n');
createAdapterOverride('linkedin/search', { homeDir: home });
const baseCopy = getBaseDependencyPath('linkedin', 'shared.js', home);
expect(fs.existsSync(baseCopy)).toBe(true);

removeOverrideRecords('linkedin', home);

expect(fs.existsSync(baseCopy)).toBe(false);
});

it('never overwrites a copied file the user has already edited', () => {
fs.writeFileSync(pluginFile, "import { s } from './shared.js';\n");
writePluginFile('shared.js', 'export const s = 1;\n');
writePluginFile('other.js', "import { s } from './shared.js';\n");

createAdapterOverride('linkedin/search', { homeDir: home });
const copied = path.join(clisDir(), 'shared.js');
fs.writeFileSync(copied, 'export const s = 42; // my fix\n');

createAdapterOverride('linkedin/other', { homeDir: home });

expect(fs.readFileSync(copied, 'utf-8')).toBe('export const s = 42; // my fix\n');
});

it('adopts a file an earlier fork copied instead of refusing as "already exists"', () => {
// linkedin/salesnav-thread imports ./salesnav-inbox.js, which is itself a
// command: forking the first puts the second command's file in clis/, and
// overriding it must not be blocked by webcmd's own copy.
fs.writeFileSync(pluginFile, "import { list } from './inbox.js';\n");
writePluginFile('inbox.js', 'export const list = () => [];\n');
createAdapterOverride('linkedin/search', { homeDir: home });

const result = createAdapterOverride('linkedin/inbox', { homeDir: home });

expect(result.overridePath).toBe(path.join(clisDir(), 'inbox.js'));
const record = readOverrideRecords(home)['linkedin/inbox']!;
expect(record.plugin).toBe('linkedin');
expect(fs.existsSync(getBaseCopyPath('linkedin/inbox', home))).toBe(true);
});

it('adopting an existing copy does not discard edits already made to it', () => {
fs.writeFileSync(pluginFile, "import { list } from './inbox.js';\n");
writePluginFile('inbox.js', 'export const list = () => [];\n');
createAdapterOverride('linkedin/search', { homeDir: home });
const copied = path.join(clisDir(), 'inbox.js');
fs.writeFileSync(copied, 'export const list = () => [1]; // mine\n');

createAdapterOverride('linkedin/inbox', { homeDir: home });

expect(fs.readFileSync(copied, 'utf-8')).toBe('export const list = () => [1]; // mine\n');
});

it('still refuses a second override of the same command', () => {
fs.writeFileSync(pluginFile, "import { s } from './shared.js';\n");
writePluginFile('shared.js', 'export const s = 1;\n');
createAdapterOverride('linkedin/search', { homeDir: home });

expect(() => createAdapterOverride('linkedin/search', { homeDir: home }))
.toThrow(/already/i);
});

it('writes nothing when an import cannot be copied from inside the plugin', () => {
fs.writeFileSync(pluginFile, "import { x } from '../outside.js';\n");
fs.writeFileSync(path.join(home, '.webcmd', 'plugins', 'outside.js'), 'export const x = 1;\n');

expect(() => createAdapterOverride('linkedin/search', { homeDir: home }))
.toThrow(/resolves outside the plugin directory/i);

expect(fs.existsSync(path.join(clisDir(), 'search.js'))).toBe(false);
expect(fs.existsSync(getBaseCopyPath('linkedin/search', home))).toBe(false);
expect(readOverrideRecords(home)['linkedin/search']).toBeUndefined();
});
});
Loading
Loading