feat(coding): add bundled plugin package selection

This commit is contained in:
2026-08-27 15:08:28 +08:00
parent 2ab1c51a24
commit 422150d4fa
14 changed files with 2188 additions and 15 deletions

View File

@@ -0,0 +1,122 @@
// @vitest-environment node
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
BUNDLED_CODING_PLUGIN_ROOTS,
CodingPluginManifestError,
loadBundledCodingPluginDefinitions,
loadCodingPluginDefinition,
parseCodingPluginManifest,
parseAgentPluginsRootManifest,
resolveBundledCodingPluginRootPaths,
} from '../../electron/coding-plugins/manifest';
import {
AGENT_PLUGINS_SCHEMA_URL,
DATA_SERVICE_PLUGIN_DEFINITION,
DATA_SERVICE_TOOL_NAMES,
} from '../../shared/coding-plugins';
const PACKAGE_ROOT = path.resolve('resources/coding-plugins/data-service');
async function packageManifests(): Promise<{ root: Record<string, unknown>; capability: Record<string, unknown> }> {
return {
root: JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'plugin.json'), 'utf8')) as Record<string, unknown>,
capability: JSON.parse(await readFile(path.join(PACKAGE_ROOT, 'com.makelore/capability.json'), 'utf8')) as Record<string, unknown>,
};
}
describe('bundled coding plugin manifests', () => {
it('loads the fixed Data Service package and immutable declarations', async () => {
const definitions = await loadBundledCodingPluginDefinitions(path.resolve('resources/coding-plugins'));
expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual(['data-service']);
expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([PACKAGE_ROOT]);
expect(definitions).toHaveLength(1);
expect(definitions[0]).toMatchObject({
id: 'makelore.data-service',
adapterId: 'data-service',
contractVersion: 1,
skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
});
expect(definitions[0]?.tools.map(({ name }) => name)).toEqual(DATA_SERVICE_TOOL_NAMES);
expect(Object.isFrozen(definitions[0])).toBe(true);
expect(Object.isFrozen(definitions[0]?.tools)).toBe(true);
expect(DATA_SERVICE_PLUGIN_DEFINITION.tools).toHaveLength(10);
});
it('accepts exact root and capability manifests and freezes the projection', async () => {
const { root, capability } = await packageManifests();
expect(parseAgentPluginsRootManifest(root, 'plugin.json').$schema).toBe(AGENT_PLUGINS_SCHEMA_URL);
const parsed = parseCodingPluginManifest(root, capability, {
packageRoot: PACKAGE_ROOT,
capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'),
});
expect(parsed.id).toBe('makelore.data-service');
expect(Object.isFrozen(parsed)).toBe(true);
expect(Object.isFrozen(parsed.tools[0]?.inputSchema)).toBe(true);
});
it.each([
['price field', (root: Record<string, unknown>) => { root.price = 1; }],
['non-empty MCP', (root: Record<string, unknown>) => { root.mcp = [{ name: 'unsupported' }]; }],
['non-empty scripts', (root: Record<string, unknown>) => { root.scripts = ['run.js']; }],
['unknown adapter', (_root: Record<string, unknown>, capability: Record<string, unknown>) => { capability.adapterId = 'other'; }],
['unknown surface', (_root: Record<string, unknown>, capability: Record<string, unknown>) => {
capability.surfaces = { projectSettings: 'other' };
}],
])('rejects %s', async (_label, mutate) => {
const { root, capability } = await packageManifests();
mutate(root, capability);
expect(() => parseCodingPluginManifest(root, capability, {
packageRoot: PACKAGE_ROOT,
capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'),
})).toThrow(CodingPluginManifestError);
});
it('rejects duplicate tools, escaping Skill entries and unconfirmed destructive tools', async () => {
const { root, capability } = await packageManifests();
const duplicate = structuredClone(capability) as Record<string, unknown>;
duplicate.tools = [...(capability.tools as unknown[]), (capability.tools as unknown[])[0]];
expect(() => parseCodingPluginManifest(root, duplicate, {
packageRoot: PACKAGE_ROOT,
capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'),
})).toThrow('duplicate tool identifier');
const escaping = structuredClone(capability) as Record<string, unknown>;
const skill = (escaping.skills as Array<Record<string, unknown>>)[0];
skill.entry = '../../outside/SKILL.md';
expect(() => parseCodingPluginManifest(root, escaping, {
packageRoot: PACKAGE_ROOT,
capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'),
})).toThrow('escapes the package root');
const unconfirmed = structuredClone(capability) as Record<string, unknown>;
const tools = unconfirmed.tools as Array<Record<string, unknown>>;
const destructive = tools.find((tool) => tool.mutation === 'destructive');
expect(destructive).toBeDefined();
const schema = destructive?.inputSchema as Record<string, unknown>;
const properties = schema.properties as Record<string, unknown>;
delete properties.confirmed;
schema.required = (schema.required as string[]).filter((required) => required !== 'confirmed');
expect(() => parseCodingPluginManifest(root, unconfirmed, {
packageRoot: PACKAGE_ROOT,
capabilityManifestPath: path.join(PACKAGE_ROOT, 'com.makelore/capability.json'),
})).toThrow('confirmed: true');
});
it('does not infer package roots from arbitrary directories', async () => {
expect(resolveBundledCodingPluginRootPaths(path.resolve('tmp'))).toEqual([
path.resolve('tmp/data-service'),
]);
});
it('loads a single package directly through its exact capability path', async () => {
await expect(loadCodingPluginDefinition(PACKAGE_ROOT)).resolves.toMatchObject({
id: 'makelore.data-service',
tools: expect.arrayContaining([
expect.objectContaining({ name: 'data_service_configure' }),
]),
});
});
});

View File

@@ -8,7 +8,8 @@ import ts from 'typescript';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { listProductCodingSkills } from '@electron/coding-projects/skill-registry';
const ASSET_ROOT = path.resolve('resources/coding-skills/data-service/assets');
const PLUGIN_SKILL_ROOT = path.resolve('resources/coding-plugins/data-service/skills/data-service');
const ASSET_ROOT = path.join(PLUGIN_SKILL_ROOT, 'assets');
const ASSETS = ['makelore-data.ts', 'makelore-data.js'] as const;
const temporaryRoots: string[] = [];
let moduleCounter = 0;
@@ -236,7 +237,7 @@ describe.each(ASSETS)('generated Data Service SDK (%s)', (assetName) => {
describe('bundled Data Service Skill packaging', () => {
it('ships both canonical assets and an ordered workflow with explicit completion criteria', async () => {
const skill = await readFile(path.resolve('resources/coding-skills/data-service/SKILL.md'), 'utf8');
const skill = await readFile(path.join(PLUGIN_SKILL_ROOT, 'SKILL.md'), 'utf8');
const readme = await readFile(path.resolve('README.md'), 'utf8');
const tsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.ts'), 'utf8');
const jsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.js'), 'utf8');
@@ -263,9 +264,17 @@ describe('bundled Data Service Skill packaging', () => {
expect(readme).toContain('data-service');
expect(readme).toContain('显式请求');
expect(readme).toContain('预览');
const packaged = await listProductCodingSkills(path.resolve('resources/coding-skills'));
const packaged = await listProductCodingSkills(
path.resolve('resources/coding-skills'),
[],
[{ id: 'data-service', directory: PLUGIN_SKILL_ROOT }],
);
const dataService = packaged.find(({ id }) => id === 'data-service');
expect(dataService).toMatchObject({ id: 'data-service', name: 'data-service' });
expect(dataService).toMatchObject({
id: 'data-service',
name: 'data-service',
location: PLUGIN_SKILL_ROOT,
});
expect(dataService?.entries).toEqual(expect.arrayContaining([
{ path: 'SKILL.md', type: 'file' },
{ path: 'assets', type: 'directory' },

View File

@@ -0,0 +1,158 @@
// @vitest-environment node
import { readFile, stat } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
createCodingProjectAgent,
createCodingProjectMetadata,
} from '../../electron/coding-projects/project-config';
import {
PROJECT_PLUGIN_SELECTION_PATH,
ProjectPluginService,
ProjectPluginServiceError,
} from '../../electron/coding-plugins/project-service';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function project(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-selection-'));
roots.push(root);
await createCodingProjectMetadata(root, { now: '2026-08-27T00:00:00.000Z' });
return root;
}
async function legacyProject(): Promise<string> {
const root = await project();
await createCodingProjectAgent(root, {
id: 'builder',
avatarId: 'avatar-01',
roleName: 'Builder',
name: 'Builder',
model: { accountId: 'account', modelId: 'model', thinkingLevel: 'medium' },
modelResolution: 'resolved',
skillIds: ['data-service'],
responsibility: {
mission: 'Build the project',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
});
return root;
}
function selectionPath(root: string): string {
return path.join(root, PROJECT_PLUGIN_SELECTION_PATH);
}
describe('ProjectPluginService', () => {
it('does not create plugins.json during an ordinary missing read', async () => {
const root = await project();
const service = new ProjectPluginService();
await expect(service.readSelection(root)).resolves.toMatchObject({
status: 'missing',
source: 'none',
enabledPluginIds: [],
persisted: false,
});
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
await service.disable(root, 'makelore.data-service');
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('projects the legacy Data Service Skill once without eager persistence', async () => {
const root = await legacyProject();
const service = new ProjectPluginService();
await expect(service.readSelection(root)).resolves.toMatchObject({
status: 'missing',
source: 'legacy',
enabledPluginIds: ['makelore.data-service'],
legacyProjectedPluginIds: ['makelore.data-service'],
persisted: false,
});
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('writes deterministic, atomic selection and preserves unknown IDs', async () => {
const root = await project();
await writeFile(selectionPath(root), JSON.stringify({
schemaVersion: 1,
enabledPluginIds: ['z.future-plugin', 'makelore.data-service', 'z.future-plugin'],
updatedAt: '2026-08-27T01:00:00.000Z',
}), 'utf8');
const service = new ProjectPluginService({ now: () => '2026-08-27T02:00:00.000Z' });
await expect(service.readSelection(root)).resolves.toMatchObject({
source: 'file',
enabledPluginIds: ['makelore.data-service', 'z.future-plugin'],
unknownPluginIds: ['z.future-plugin'],
});
const next = await service.disable(root, 'makelore.data-service');
expect(next.enabledPluginIds).toEqual(['z.future-plugin']);
expect(JSON.parse(await readFile(selectionPath(root), 'utf8'))).toEqual({
schemaVersion: 1,
enabledPluginIds: ['z.future-plugin'],
updatedAt: '2026-08-27T02:00:00.000Z',
});
});
it('enables and disables idempotently while invoking lifecycle callbacks once', async () => {
const root = await project();
const managed: unknown[] = [];
const deactivated: unknown[] = [];
let writes = 0;
const service = new ProjectPluginService({
now: () => '2026-08-27T03:00:00.000Z',
writer: async (filePath, value) => {
writes += 1;
const { atomicWriteJson } = await import('../../electron/coding-projects/atomic-json');
await atomicWriteJson(filePath, value);
},
onManagedInputsChanged: (event) => { managed.push(event); },
onAdapterDeactivated: (event) => { deactivated.push(event); },
});
await service.enable(root, 'makelore.data-service');
await service.enable(root, 'makelore.data-service');
await service.disable(root, 'makelore.data-service');
await service.disable(root, 'makelore.data-service');
expect(writes).toBe(2);
expect(managed).toHaveLength(2);
expect(deactivated).toEqual([{ projectPath: root, pluginId: 'makelore.data-service' }]);
expect(service.getManagedInputRevision(root)).toBe(2);
});
it('keeps selection unchanged when atomic persistence fails', async () => {
const root = await project();
const service = new ProjectPluginService({
writer: async () => { throw new Error('disk full'); },
});
await expect(service.enable(root, 'makelore.data-service')).rejects.toMatchObject({
code: 'CODING_PLUGIN_SELECTION_WRITE_FAILED',
} satisfies Partial<ProjectPluginServiceError>);
await expect(stat(selectionPath(root))).rejects.toMatchObject({ code: 'ENOENT' });
await expect(service.readSelection(root)).resolves.toMatchObject({ enabledPluginIds: [] });
});
it('rejects unknown plugin mutations but retains unknown IDs already in the file', async () => {
const root = await project();
await writeFile(selectionPath(root), JSON.stringify({
schemaVersion: 1,
enabledPluginIds: ['future.plugin'],
updatedAt: '2026-08-27T04:00:00.000Z',
}), 'utf8');
const service = new ProjectPluginService();
await expect(service.enable(root, 'future.plugin')).rejects.toMatchObject({
code: 'CODING_PLUGIN_UNKNOWN',
});
await expect(service.enable(root, 'makelore.data-service')).resolves.toMatchObject({
enabledPluginIds: ['future.plugin', 'makelore.data-service'],
});
});
});