mirror of
https://github.com/davila7/claude-code-templates.git
synced 2026-09-19 01:30:23 +08:00
a21dfe1ef0
- New cli-tool/src/plugin-installer.js: installs bundles from the
aitmpl.com registry (CCT_PLUGIN_REGISTRY_URL override for testing).
Downloads file-by-file from files.json with sha256 verification,
3-retry exponential backoff, and a zip-slip path guard; unpacks the
pure spec layout into .agent-plugins/{name}/ and activates into the
familiar destinations: skills -> .claude/skills/, mcp.json merged into
.mcp.json, agents/commands/loops flat into .claude/ (same layout the
individual installers produce). Hooks/settings in a bundle are unpacked
but deliberately not auto-activated (their interactive multi-location
merge stays with --hook/--setting). Spec failure model: invalid
plugin.json rejects the plugin; an invalid skill is skipped with a
warning. Tracked as type 'plugin' with batchId/durationMs.
- bin/create-claude-config.js: new --plugin <names> (comma-separated) and
--plugin-raw (unpack-only) options; help text disambiguates from the
--plugins dashboard.
- src/index.js: --plugin wired into routing and installMultipleComponents;
installIndividualMCP's merge body extracted into the shared
mergeMcpServers() helper (same semantics: description stripped, existing
servers preserved, 2-space output) so bundle activation and --mcp share
one code path. merged_with_existing telemetry now reports the real value
instead of always-true.
- cli-tool/tests/plugin/installer.test.js: 11 tests over a stubbed fetch
serving a packer-built bundle — activation placement, existing-server
preservation, --plugin-raw, spec rejection, path traversal, sha256
mismatch cleanup, dry run, and mergeMcpServers regression.
Verified end-to-end: real CLI install of both pilot bundles from a local
HTTP registry produced the expected .agent-plugins/ + .claude/ + .mcp.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPhPzxiW2q2wgQQLS8Bn9w
217 lines
9.1 KiB
JavaScript
217 lines
9.1 KiB
JavaScript
/**
|
|
* Tests for the Agent Plugins bundle installer (src/plugin-installer.js).
|
|
*
|
|
* A real bundle is built with the packer into a temp dir; global.fetch is
|
|
* stubbed to serve the registry, manifests, and files from that dir — no
|
|
* network, no dependency on the live registry.
|
|
*/
|
|
|
|
process.env.CCT_NO_TRACKING = 'true'; // keep tests network-silent
|
|
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs-extra');
|
|
|
|
const { buildPlugin } = require('../../src/plugin');
|
|
const { installIndividualPlugin, mergeMcpServers, isSafeRelativePath } = require('../../src/plugin-installer');
|
|
|
|
const REGISTRY = 'https://registry.test/agent-plugins';
|
|
|
|
function sha256(buffer) {
|
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
}
|
|
|
|
async function walkFiles(dir, base = dir) {
|
|
const out = [];
|
|
for (const entry of (await fs.readdir(dir)).sort()) {
|
|
const p = path.join(dir, entry);
|
|
if ((await fs.stat(p)).isDirectory()) out.push(...(await walkFiles(p, base)));
|
|
else out.push(path.relative(base, p).split(path.sep).join('/'));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe('Agent Plugins installer', () => {
|
|
let tmpDir;
|
|
let bundleDir;
|
|
let targetDir;
|
|
let registryIndex;
|
|
let filesManifest;
|
|
const originalFetch = global.fetch;
|
|
|
|
beforeEach(async () => {
|
|
process.env.CCT_PLUGIN_REGISTRY_URL = REGISTRY;
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ap-installer-test-'));
|
|
targetDir = path.join(tmpDir, 'project');
|
|
await fs.ensureDir(targetDir);
|
|
|
|
// Build a real bundle with the packer
|
|
const componentsDir = path.join(tmpDir, 'components');
|
|
await fs.outputFile(
|
|
path.join(componentsDir, 'skills', 'testing', 'pdf-tools', 'SKILL.md'),
|
|
'---\nname: pdf-tools\ndescription: Extract PDFs.\n---\n\n# PDF Tools\n'
|
|
);
|
|
await fs.outputJson(path.join(componentsDir, 'mcps', 'testing', 'fake-db.json'), {
|
|
mcpServers: {
|
|
'fake-db': { description: 'Fake DB', command: 'npx', args: ['-y', 'fake-db'] },
|
|
},
|
|
});
|
|
await fs.outputFile(
|
|
path.join(componentsDir, 'agents', 'testing', 'db-expert.md'),
|
|
'---\nname: db-expert\ndescription: DB expert\n---\n\nExpert.\n'
|
|
);
|
|
const built = await buildPlugin(
|
|
{ name: 'test-pack', version: '1.0.0', description: 'Test pack',
|
|
components: ['skill:testing/pdf-tools', 'mcp:testing/fake-db', 'agent:testing/db-expert'] },
|
|
{ componentsDir, outDir: path.join(tmpDir, 'out') }
|
|
);
|
|
bundleDir = built.dir;
|
|
|
|
// files.json + registry index, same shape the build script emits
|
|
const files = [];
|
|
for (const rel of await walkFiles(bundleDir)) {
|
|
const buffer = await fs.readFile(path.join(bundleDir, rel));
|
|
files.push({ path: rel, sha256: sha256(buffer), size: buffer.length });
|
|
}
|
|
filesManifest = { registryVersion: 1, files };
|
|
await fs.writeJson(path.join(bundleDir, 'files.json'), filesManifest);
|
|
registryIndex = {
|
|
registryVersion: 1,
|
|
specVersion: '1.0.0',
|
|
bundles: [{ name: 'test-pack', version: '1.0.0', path: 'test-pack/' }],
|
|
};
|
|
|
|
global.fetch = jest.fn(async (url) => {
|
|
const notFound = { ok: false, status: 404, statusText: 'Not Found' };
|
|
if (!String(url).startsWith(REGISTRY)) return notFound;
|
|
const rel = String(url).slice(REGISTRY.length + 1);
|
|
if (rel === 'index.json') {
|
|
return { ok: true, status: 200, json: async () => registryIndex };
|
|
}
|
|
if (!rel.startsWith('test-pack/')) return notFound;
|
|
const filePath = path.join(bundleDir, rel.slice('test-pack/'.length));
|
|
if (!(await fs.pathExists(filePath))) return notFound;
|
|
const buffer = await fs.readFile(filePath);
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => JSON.parse(buffer.toString('utf8')),
|
|
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
|
};
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
global.fetch = originalFetch;
|
|
delete process.env.CCT_PLUGIN_REGISTRY_URL;
|
|
await fs.remove(tmpDir);
|
|
});
|
|
|
|
test('installs a bundle: unpack, verify, and activate into .claude/', async () => {
|
|
const ok = await installIndividualPlugin('test-pack', targetDir, {});
|
|
expect(ok).toBe(true);
|
|
|
|
// Pure standard layout unpacked
|
|
expect(await fs.pathExists(path.join(targetDir, '.agent-plugins', 'test-pack', 'plugin.json'))).toBe(true);
|
|
// Skill activated
|
|
expect(await fs.pathExists(path.join(targetDir, '.claude', 'skills', 'pdf-tools', 'SKILL.md'))).toBe(true);
|
|
// Agent activated flat (category stripped)
|
|
expect(await fs.pathExists(path.join(targetDir, '.claude', 'agents', 'db-expert.md'))).toBe(true);
|
|
// MCP merged
|
|
const mcp = await fs.readJson(path.join(targetDir, '.mcp.json'));
|
|
expect(mcp.mcpServers['fake-db'].command).toBe('npx');
|
|
expect(mcp.mcpServers['fake-db'].description).toBeUndefined();
|
|
expect(mcp.$schema).toBeUndefined();
|
|
});
|
|
|
|
test('preserves existing .mcp.json servers on activation', async () => {
|
|
await fs.writeJson(path.join(targetDir, '.mcp.json'), {
|
|
mcpServers: { 'my-server': { command: 'node', args: ['server.js'] } },
|
|
});
|
|
await installIndividualPlugin('test-pack', targetDir, {});
|
|
const mcp = await fs.readJson(path.join(targetDir, '.mcp.json'));
|
|
expect(mcp.mcpServers['my-server'].command).toBe('node');
|
|
expect(mcp.mcpServers['fake-db'].command).toBe('npx');
|
|
});
|
|
|
|
test('--plugin-raw unpacks without activating', async () => {
|
|
const ok = await installIndividualPlugin('test-pack', targetDir, { pluginRaw: true });
|
|
expect(ok).toBe(true);
|
|
expect(await fs.pathExists(path.join(targetDir, '.agent-plugins', 'test-pack', 'plugin.json'))).toBe(true);
|
|
expect(await fs.pathExists(path.join(targetDir, '.claude'))).toBe(false);
|
|
expect(await fs.pathExists(path.join(targetDir, '.mcp.json'))).toBe(false);
|
|
});
|
|
|
|
test('rejects unknown plugin names', async () => {
|
|
expect(await installIndividualPlugin('nope', targetDir, {})).toBe(false);
|
|
expect(await installIndividualPlugin('Bad Name!', targetDir, {})).toBe(false);
|
|
});
|
|
|
|
test('rejects the whole plugin when plugin.json fails spec validation', async () => {
|
|
await fs.writeJson(path.join(bundleDir, 'plugin.json'), { name: 'test-pack' }); // no $schema
|
|
expect(await installIndividualPlugin('test-pack', targetDir, {})).toBe(false);
|
|
expect(await fs.pathExists(path.join(targetDir, '.agent-plugins'))).toBe(false);
|
|
});
|
|
|
|
test('rejects path traversal in the files manifest', async () => {
|
|
filesManifest.files.push({ path: '../../evil.sh', sha256: 'x', size: 1 });
|
|
await fs.writeJson(path.join(bundleDir, 'files.json'), filesManifest);
|
|
expect(await installIndividualPlugin('test-pack', targetDir, {})).toBe(false);
|
|
expect(await fs.pathExists(path.join(targetDir, '..', 'evil.sh'))).toBe(false);
|
|
});
|
|
|
|
test('rejects on sha256 mismatch and leaves no partial bundle', async () => {
|
|
filesManifest.files.find((f) => f.path === 'README.md').sha256 = '0'.repeat(64);
|
|
await fs.writeJson(path.join(bundleDir, 'files.json'), filesManifest);
|
|
expect(await installIndividualPlugin('test-pack', targetDir, {})).toBe(false);
|
|
expect(await fs.pathExists(path.join(targetDir, '.agent-plugins', 'test-pack'))).toBe(false);
|
|
});
|
|
|
|
test('dry run lists files without writing anything', async () => {
|
|
const ok = await installIndividualPlugin('test-pack', targetDir, { dryRun: true });
|
|
expect(ok).toBe(true);
|
|
expect(await fs.pathExists(path.join(targetDir, '.agent-plugins'))).toBe(false);
|
|
});
|
|
|
|
test('isSafeRelativePath blocks escape vectors', () => {
|
|
expect(isSafeRelativePath('skills/x/SKILL.md')).toBe(true);
|
|
expect(isSafeRelativePath('../x')).toBe(false);
|
|
expect(isSafeRelativePath('/etc/passwd')).toBe(false);
|
|
expect(isSafeRelativePath('a\\b')).toBe(false);
|
|
expect(isSafeRelativePath('C:/windows')).toBe(false);
|
|
expect(isSafeRelativePath('a//b')).toBe(false);
|
|
});
|
|
|
|
describe('mergeMcpServers (regression for the extracted helper)', () => {
|
|
test('strips description, preserves existing, overwrites same-name', async () => {
|
|
await fs.writeJson(path.join(targetDir, '.mcp.json'), {
|
|
mcpServers: {
|
|
keep: { command: 'a' },
|
|
replace: { command: 'old' },
|
|
},
|
|
});
|
|
const merged = await mergeMcpServers(targetDir, {
|
|
$schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json',
|
|
mcpServers: {
|
|
replace: { description: 'gone', command: 'new' },
|
|
added: { command: 'b' },
|
|
},
|
|
});
|
|
expect(merged.mcpServers.keep.command).toBe('a');
|
|
expect(merged.mcpServers.replace.command).toBe('new');
|
|
expect(merged.mcpServers.replace.description).toBeUndefined();
|
|
expect(merged.mcpServers.added.command).toBe('b');
|
|
expect(merged.$schema).toBeUndefined();
|
|
const onDisk = await fs.readFile(path.join(targetDir, '.mcp.json'), 'utf8');
|
|
expect(onDisk).toContain(' "mcpServers"'); // 2-space indent preserved
|
|
});
|
|
|
|
test('does not mutate the caller-provided config object', async () => {
|
|
const incoming = { mcpServers: { s: { description: 'd', command: 'x' } } };
|
|
await mergeMcpServers(targetDir, incoming);
|
|
expect(incoming.mcpServers.s.description).toBe('d');
|
|
});
|
|
});
|
|
});
|