feat(coding): add bundled plugin package selection
This commit is contained in:
664
electron/coding-plugins/manifest.ts
Normal file
664
electron/coding-plugins/manifest.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
AGENT_PLUGINS_SCHEMA_URL,
|
||||
BUNDLED_CODING_PLUGIN_ADAPTER_IDS,
|
||||
BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES,
|
||||
BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES,
|
||||
CODE_OWNED_PLUGIN_PERMISSION_IDS,
|
||||
DATA_SERVICE_CAPABILITY_IDS,
|
||||
DATA_SERVICE_PLUGIN_ID,
|
||||
DATA_SERVICE_TOOL_NAMES,
|
||||
type AgentPluginsRootManifest,
|
||||
type CodingPluginDefinition,
|
||||
type CodingPluginSkillDefinition,
|
||||
type CodingPluginToolDefinition,
|
||||
type PluginToolMutation,
|
||||
} from '../../shared/coding-plugins';
|
||||
|
||||
/**
|
||||
* P0 deliberately has one statically enumerated package root. Keeping this
|
||||
* list relative makes it impossible for an environment variable or a project
|
||||
* file to add a package to the trusted catalog.
|
||||
*/
|
||||
export const BUNDLED_CODING_PLUGIN_ROOTS = Object.freeze([
|
||||
'data-service',
|
||||
] as const);
|
||||
|
||||
const CAPABILITY_MANIFEST_RELATIVE_PATH = 'com.makelore/capability.json';
|
||||
const PACKAGE_MANIFEST_FILE = 'plugin.json';
|
||||
const SKILL_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
||||
const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u;
|
||||
const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
||||
const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u;
|
||||
const SEMVER_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
||||
const MAX_DISPLAY_TEXT = 256;
|
||||
|
||||
const UNSUPPORTED_COMPONENT_KEYS = new Set([
|
||||
'commands',
|
||||
'executables',
|
||||
'hooks',
|
||||
'mcp',
|
||||
'mcpServers',
|
||||
'scripts',
|
||||
]);
|
||||
|
||||
const FORBIDDEN_FIELD_PATTERN = /^(?:price|prices|points|balance|owner|owners|token|tokens|url|urls|entrypoint|entryPoint|react)$/iu;
|
||||
|
||||
const DATA_SERVICE_OPERATION_CAPABILITIES: Readonly<Record<string, string>> = Object.freeze({
|
||||
configure: 'data-service.control',
|
||||
inspect: 'data-service.control',
|
||||
list_projects: 'data-service.control',
|
||||
remove_collection: 'data-service.control',
|
||||
reset: 'data-service.control',
|
||||
remove_project: 'data-service.control',
|
||||
get_document: 'data-service.documents',
|
||||
list_documents: 'data-service.documents',
|
||||
put_document: 'data-service.documents',
|
||||
delete_document: 'data-service.documents',
|
||||
});
|
||||
|
||||
const ROOT_KEYS = new Set([
|
||||
'$schema',
|
||||
'name',
|
||||
'version',
|
||||
'description',
|
||||
'author',
|
||||
'extensions',
|
||||
...UNSUPPORTED_COMPONENT_KEYS,
|
||||
]);
|
||||
|
||||
const CAPABILITY_KEYS = new Set([
|
||||
'schemaVersion',
|
||||
'pluginId',
|
||||
'contractVersion',
|
||||
'scope',
|
||||
'adapterId',
|
||||
'requiresBackend',
|
||||
'display',
|
||||
'skills',
|
||||
'tools',
|
||||
'surfaces',
|
||||
...UNSUPPORTED_COMPONENT_KEYS,
|
||||
]);
|
||||
|
||||
const AUTHOR_KEYS = new Set(['name']);
|
||||
const EXTENSIONS_KEYS = new Set(['com.makelore']);
|
||||
const MAKELore_EXTENSION_KEYS = new Set(['capabilityManifest']);
|
||||
const DISPLAY_KEYS = new Set(['name', 'description']);
|
||||
const SKILL_KEYS = new Set(['id', 'entry', 'grants']);
|
||||
const TOOL_KEYS = new Set([
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
'capabilityId',
|
||||
'operation',
|
||||
'roles',
|
||||
'mutation',
|
||||
'projectWriteLease',
|
||||
'permissions',
|
||||
'inputSchema',
|
||||
]);
|
||||
const SURFACE_KEYS = new Set(['projectSettings', 'previewRuntime']);
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
export class CodingPluginManifestError extends Error {
|
||||
readonly code = 'plugin_manifest_invalid' as const;
|
||||
|
||||
constructor(
|
||||
readonly filePath: string,
|
||||
readonly field: string,
|
||||
message: string,
|
||||
) {
|
||||
super(`Invalid plugin manifest (${filePath}, ${field}): ${message}`);
|
||||
this.name = 'CodingPluginManifestError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function freezeDeep<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
for (const child of Object.values(value as UnknownRecord)) freezeDeep(child);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fail(filePath: string, field: string, message: string): never {
|
||||
throw new CodingPluginManifestError(filePath, field, message);
|
||||
}
|
||||
|
||||
function assertRecord(
|
||||
value: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
): UnknownRecord {
|
||||
if (!isRecord(value)) fail(filePath, field, 'expected an object');
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: UnknownRecord,
|
||||
allowed: ReadonlySet<string>,
|
||||
filePath: string,
|
||||
field: string,
|
||||
): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (FORBIDDEN_FIELD_PATTERN.test(key)) {
|
||||
fail(filePath, `${field}.${key}`, 'field is not allowed in a plugin manifest');
|
||||
}
|
||||
if (!allowed.has(key)) {
|
||||
fail(filePath, `${field}.${key}`, 'field is not part of the exact P0 schema');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedComponentIsEmpty(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return true;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (isRecord(value)) return Object.keys(value).length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
function rejectUnsupportedComponents(value: UnknownRecord, filePath: string, field: string): void {
|
||||
for (const key of UNSUPPORTED_COMPONENT_KEYS) {
|
||||
if (key in value && !unsupportedComponentIsEmpty(value[key])) {
|
||||
fail(filePath, `${field}.${key}`, 'non-empty component is unsupported in P0');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, filePath: string, field: string, maximum = MAX_DISPLAY_TEXT): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0 || value.length > maximum) {
|
||||
fail(filePath, field, `expected non-empty text of at most ${maximum} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableId(
|
||||
value: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
pattern: RegExp,
|
||||
): string {
|
||||
const result = text(value, filePath, field, 64);
|
||||
if (!pattern.test(result)) fail(filePath, field, 'identifier has an invalid shape');
|
||||
return result;
|
||||
}
|
||||
|
||||
function bool(value: unknown, filePath: string, field: string): boolean {
|
||||
if (typeof value !== 'boolean') fail(filePath, field, 'expected a boolean');
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, filePath: string, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
fail(filePath, field, 'expected a positive safe integer');
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function uniqueStrings(
|
||||
value: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
pattern: RegExp,
|
||||
allowEmpty = false,
|
||||
): string[] {
|
||||
if (!Array.isArray(value)) fail(filePath, field, 'expected an array');
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const [index, candidate] of value.entries()) {
|
||||
if (typeof candidate !== 'string' || (!allowEmpty && candidate.trim().length === 0)) {
|
||||
fail(filePath, `${field}[${index}]`, 'expected a non-empty string');
|
||||
}
|
||||
if (!pattern.test(candidate)) fail(filePath, `${field}[${index}]`, 'identifier has an invalid shape');
|
||||
if (seen.has(candidate)) fail(filePath, `${field}[${index}]`, 'duplicate identifier');
|
||||
seen.add(candidate);
|
||||
result.push(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeCandidatePath(
|
||||
packageRoot: string,
|
||||
baseDirectory: string,
|
||||
candidate: unknown,
|
||||
filePath: string,
|
||||
field: string,
|
||||
): string {
|
||||
const value = text(candidate, filePath, field, 512);
|
||||
const portable = value.replaceAll('\\', '/');
|
||||
if (portable.startsWith('/') || /^[A-Za-z]:\//u.test(portable)
|
||||
|| /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(portable)) {
|
||||
fail(filePath, field, 'path must be relative to the package root');
|
||||
}
|
||||
const root = path.resolve(packageRoot);
|
||||
const resolved = path.resolve(baseDirectory, value);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
fail(filePath, field, 'path escapes the package root');
|
||||
}
|
||||
return relative.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function validateSchemaNode(value: unknown, filePath: string, field: string, root = false): void {
|
||||
const schema = assertRecord(value, filePath, field);
|
||||
const allowed = root
|
||||
? new Set(['type', 'additionalProperties', 'required', 'properties'])
|
||||
: new Set([
|
||||
'type',
|
||||
'additionalProperties',
|
||||
'required',
|
||||
'properties',
|
||||
'items',
|
||||
'pattern',
|
||||
'minLength',
|
||||
'maxLength',
|
||||
'minimum',
|
||||
'maximum',
|
||||
'maxItems',
|
||||
'const',
|
||||
]);
|
||||
assertExactKeys(schema, allowed, filePath, field);
|
||||
if (typeof schema.type !== 'string' || !['array', 'boolean', 'integer', 'object', 'string'].includes(schema.type)) {
|
||||
fail(filePath, `${field}.type`, 'unsupported input schema type');
|
||||
}
|
||||
if ('additionalProperties' in schema && typeof schema.additionalProperties !== 'boolean') {
|
||||
fail(filePath, `${field}.additionalProperties`, 'must be a boolean');
|
||||
}
|
||||
if ('required' in schema) {
|
||||
uniqueStrings(schema.required, filePath, `${field}.required`, /^[A-Za-z_][A-Za-z0-9_]*$/u);
|
||||
}
|
||||
if ('properties' in schema) {
|
||||
const properties = assertRecord(schema.properties, filePath, `${field}.properties`);
|
||||
for (const [name, propertySchema] of Object.entries(properties)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
|
||||
fail(filePath, `${field}.properties.${name}`, 'input property name is invalid');
|
||||
}
|
||||
validateSchemaNode(propertySchema, filePath, `${field}.properties.${name}`);
|
||||
}
|
||||
if ('required' in schema) {
|
||||
for (const required of schema.required as string[]) {
|
||||
if (!(required in properties)) {
|
||||
fail(filePath, `${field}.required`, `required property is not declared: ${required}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ('required' in schema && (schema.required as unknown[]).length > 0) {
|
||||
fail(filePath, `${field}.required`, 'required properties need a properties object');
|
||||
}
|
||||
if ('items' in schema) validateSchemaNode(schema.items, filePath, `${field}.items`);
|
||||
for (const numericKey of ['minLength', 'maxLength', 'minimum', 'maximum', 'maxItems']) {
|
||||
if (numericKey in schema && (!Number.isSafeInteger(schema[numericKey]) || (schema[numericKey] as number) < 0)) {
|
||||
fail(filePath, `${field}.${numericKey}`, 'must be a non-negative safe integer');
|
||||
}
|
||||
}
|
||||
if ('const' in schema && schema.const !== true && schema.const !== false) {
|
||||
fail(filePath, `${field}.const`, 'only boolean literal constants are supported');
|
||||
}
|
||||
}
|
||||
|
||||
function validateDestructiveConfirmation(
|
||||
schema: UnknownRecord,
|
||||
filePath: string,
|
||||
field: string,
|
||||
): void {
|
||||
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||
const properties = isRecord(schema.properties) ? schema.properties : {};
|
||||
const confirmation = properties.confirmed;
|
||||
if (!required.includes('confirmed') || !isRecord(confirmation)
|
||||
|| confirmation.type !== 'boolean' || confirmation.const !== true) {
|
||||
fail(filePath, field, 'destructive tools must require literal confirmed: true');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAgentPluginsRootManifest(
|
||||
value: unknown,
|
||||
filePath = PACKAGE_MANIFEST_FILE,
|
||||
): AgentPluginsRootManifest {
|
||||
const root = assertRecord(value, filePath, 'root');
|
||||
assertExactKeys(root, ROOT_KEYS, filePath, 'root');
|
||||
rejectUnsupportedComponents(root, filePath, 'root');
|
||||
if (root.$schema !== AGENT_PLUGINS_SCHEMA_URL) {
|
||||
fail(filePath, '$schema', `must equal ${AGENT_PLUGINS_SCHEMA_URL}`);
|
||||
}
|
||||
const name = stableId(root.name, filePath, 'name', /^[a-z][a-z0-9.-]{0,127}$/u);
|
||||
const version = text(root.version, filePath, 'version', 128);
|
||||
if (!SEMVER_PATTERN.test(version)) fail(filePath, 'version', 'must be a valid package semver');
|
||||
const description = text(root.description, filePath, 'description');
|
||||
const author = assertRecord(root.author, filePath, 'author');
|
||||
assertExactKeys(author, AUTHOR_KEYS, filePath, 'author');
|
||||
const authorName = text(author.name, filePath, 'author.name', 128);
|
||||
const extensions = assertRecord(root.extensions, filePath, 'extensions');
|
||||
assertExactKeys(extensions, EXTENSIONS_KEYS, filePath, 'extensions');
|
||||
const makeLore = assertRecord(extensions['com.makelore'], filePath, 'extensions.com.makelore');
|
||||
assertExactKeys(makeLore, MAKELore_EXTENSION_KEYS, filePath, 'extensions.com.makelore');
|
||||
const capabilityManifest = text(
|
||||
makeLore.capabilityManifest,
|
||||
filePath,
|
||||
'extensions.com.makelore.capabilityManifest',
|
||||
512,
|
||||
);
|
||||
return freezeDeep({
|
||||
$schema: AGENT_PLUGINS_SCHEMA_URL,
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
author: { name: authorName },
|
||||
extensions: { 'com.makelore': { capabilityManifest } },
|
||||
});
|
||||
}
|
||||
|
||||
function parseSkill(
|
||||
value: unknown,
|
||||
index: number,
|
||||
packageRoot: string,
|
||||
capabilityDirectory: string,
|
||||
filePath: string,
|
||||
): CodingPluginSkillDefinition {
|
||||
const skill = assertRecord(value, filePath, `skills[${index}]`);
|
||||
assertExactKeys(skill, SKILL_KEYS, filePath, `skills[${index}]`);
|
||||
const id = stableId(skill.id, filePath, `skills[${index}].id`, SKILL_ID_PATTERN);
|
||||
const entryPath = normalizeCandidatePath(
|
||||
packageRoot,
|
||||
capabilityDirectory,
|
||||
skill.entry,
|
||||
filePath,
|
||||
`skills[${index}].entry`,
|
||||
);
|
||||
const grants = uniqueStrings(
|
||||
skill.grants,
|
||||
filePath,
|
||||
`skills[${index}].grants`,
|
||||
CAPABILITY_ID_PATTERN,
|
||||
);
|
||||
for (const grant of grants) {
|
||||
if (!DATA_SERVICE_CAPABILITY_IDS.includes(grant as (typeof DATA_SERVICE_CAPABILITY_IDS)[number])) {
|
||||
fail(filePath, `skills[${index}].grants`, `unknown capability grant: ${grant}`);
|
||||
}
|
||||
}
|
||||
return { id, entryPath, grants };
|
||||
}
|
||||
|
||||
function parseTool(
|
||||
value: unknown,
|
||||
index: number,
|
||||
filePath: string,
|
||||
): CodingPluginToolDefinition {
|
||||
const tool = assertRecord(value, filePath, `tools[${index}]`);
|
||||
assertExactKeys(tool, TOOL_KEYS, filePath, `tools[${index}]`);
|
||||
const name = stableId(tool.name, filePath, `tools[${index}].name`, TOOL_NAME_PATTERN);
|
||||
const label = text(tool.label, filePath, `tools[${index}].label`);
|
||||
const description = text(tool.description, filePath, `tools[${index}].description`);
|
||||
const capabilityId = stableId(tool.capabilityId, filePath, `tools[${index}].capabilityId`, CAPABILITY_ID_PATTERN);
|
||||
if (!DATA_SERVICE_CAPABILITY_IDS.includes(capabilityId as (typeof DATA_SERVICE_CAPABILITY_IDS)[number])) {
|
||||
fail(filePath, `tools[${index}].capabilityId`, `unknown capability: ${capabilityId}`);
|
||||
}
|
||||
const operation = stableId(tool.operation, filePath, `tools[${index}].operation`, OPERATION_ID_PATTERN);
|
||||
const roles = uniqueStrings(tool.roles, filePath, `tools[${index}].roles`, /^[a-z]+$/u);
|
||||
if (roles.length !== 1 || roles[0] !== 'parent') {
|
||||
fail(filePath, `tools[${index}].roles`, 'P0 tools must be exactly parent-only');
|
||||
}
|
||||
const mutation = tool.mutation;
|
||||
if (mutation !== 'read' && mutation !== 'write' && mutation !== 'destructive') {
|
||||
fail(filePath, `tools[${index}].mutation`, 'unknown tool mutation');
|
||||
}
|
||||
const projectWriteLease = bool(tool.projectWriteLease, filePath, `tools[${index}].projectWriteLease`);
|
||||
const permissions = uniqueStrings(
|
||||
tool.permissions,
|
||||
filePath,
|
||||
`tools[${index}].permissions`,
|
||||
/^[a-z][a-z0-9._-]{0,63}$/u,
|
||||
);
|
||||
for (const permission of permissions) {
|
||||
if (!CODE_OWNED_PLUGIN_PERMISSION_IDS.includes(permission as (typeof CODE_OWNED_PLUGIN_PERMISSION_IDS)[number])) {
|
||||
fail(filePath, `tools[${index}].permissions`, `unknown code-owned permission: ${permission}`);
|
||||
}
|
||||
}
|
||||
validateSchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`, true);
|
||||
const schema = tool.inputSchema as UnknownRecord;
|
||||
if (schema.type !== 'object' || schema.additionalProperties !== false) {
|
||||
fail(filePath, `tools[${index}].inputSchema`, 'tool input schema must be an exact object');
|
||||
}
|
||||
if (mutation === 'destructive') {
|
||||
validateDestructiveConfirmation(schema, filePath, `tools[${index}].inputSchema`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
capabilityId,
|
||||
operation,
|
||||
roles: ['parent'],
|
||||
mutation: mutation as PluginToolMutation,
|
||||
projectWriteLease,
|
||||
permissions,
|
||||
inputSchema: freezeDeep(structuredClone(schema)),
|
||||
};
|
||||
}
|
||||
|
||||
function validateDataServiceDefinition(
|
||||
definition: CodingPluginDefinition,
|
||||
filePath: string,
|
||||
): void {
|
||||
const expectedNames = new Set<string>(DATA_SERVICE_TOOL_NAMES);
|
||||
if (definition.id !== DATA_SERVICE_PLUGIN_ID) return;
|
||||
if (definition.tools.length !== DATA_SERVICE_TOOL_NAMES.length) {
|
||||
fail(filePath, 'tools', `Data Service must declare exactly ${DATA_SERVICE_TOOL_NAMES.length} tools`);
|
||||
}
|
||||
for (const tool of definition.tools) {
|
||||
if (!expectedNames.has(tool.name)) fail(filePath, `tools.${tool.name}`, 'unknown Data Service tool');
|
||||
const expectedCapability = DATA_SERVICE_OPERATION_CAPABILITIES[tool.operation];
|
||||
if (!expectedCapability || expectedCapability !== tool.capabilityId) {
|
||||
fail(filePath, `tools.${tool.name}.operation`, 'operation does not reference its capability');
|
||||
}
|
||||
}
|
||||
const operationNames = definition.tools.map((tool) => tool.operation);
|
||||
if (new Set(operationNames).size !== operationNames.length) {
|
||||
fail(filePath, 'tools.operation', 'duplicate operation identifier');
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCodingPluginManifest(
|
||||
rootValue: unknown,
|
||||
capabilityValue: unknown,
|
||||
options: {
|
||||
packageRoot?: string;
|
||||
rootManifestPath?: string;
|
||||
capabilityManifestPath?: string;
|
||||
} = {},
|
||||
): CodingPluginDefinition {
|
||||
const packageRoot = path.resolve(options.packageRoot ?? path.dirname(options.capabilityManifestPath ?? '.'));
|
||||
const rootManifestPath = path.resolve(
|
||||
options.rootManifestPath ?? path.join(packageRoot, PACKAGE_MANIFEST_FILE),
|
||||
);
|
||||
const root = parseAgentPluginsRootManifest(rootValue, rootManifestPath);
|
||||
const capabilityManifestPath = path.resolve(
|
||||
options.capabilityManifestPath ?? path.join(packageRoot, CAPABILITY_MANIFEST_RELATIVE_PATH),
|
||||
);
|
||||
const capabilityRelativePath = path.relative(packageRoot, capabilityManifestPath);
|
||||
if (!capabilityRelativePath || capabilityRelativePath === '..'
|
||||
|| capabilityRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(capabilityRelativePath)) {
|
||||
fail(capabilityManifestPath, 'root', 'capability manifest must be inside the package root');
|
||||
}
|
||||
const capability = assertRecord(capabilityValue, capabilityManifestPath, 'root');
|
||||
assertExactKeys(capability, CAPABILITY_KEYS, capabilityManifestPath, 'root');
|
||||
rejectUnsupportedComponents(capability, capabilityManifestPath, 'root');
|
||||
if (capability.schemaVersion !== 1) fail(capabilityManifestPath, 'schemaVersion', 'must equal 1');
|
||||
const pluginId = stableId(capability.pluginId, capabilityManifestPath, 'pluginId', /^[a-z][a-z0-9.-]{0,127}$/u);
|
||||
if (pluginId !== root.name) fail(capabilityManifestPath, 'pluginId', 'must match plugin.json name');
|
||||
const contractVersion = positiveInteger(capability.contractVersion, capabilityManifestPath, 'contractVersion');
|
||||
if (capability.scope !== 'project') fail(capabilityManifestPath, 'scope', 'P0 plugins must be project-scoped');
|
||||
const adapterId = text(capability.adapterId, capabilityManifestPath, 'adapterId', 64);
|
||||
if (!BUNDLED_CODING_PLUGIN_ADAPTER_IDS.includes(adapterId as (typeof BUNDLED_CODING_PLUGIN_ADAPTER_IDS)[number])) {
|
||||
fail(capabilityManifestPath, 'adapterId', `adapter is not code-owned: ${adapterId}`);
|
||||
}
|
||||
const requiresBackend = bool(capability.requiresBackend, capabilityManifestPath, 'requiresBackend');
|
||||
const display = assertRecord(capability.display, capabilityManifestPath, 'display');
|
||||
assertExactKeys(display, DISPLAY_KEYS, capabilityManifestPath, 'display');
|
||||
const displayName = text(display.name, capabilityManifestPath, 'display.name');
|
||||
const description = text(display.description, capabilityManifestPath, 'display.description');
|
||||
const capabilityDirectory = path.dirname(capabilityManifestPath);
|
||||
const skillsValue = capability.skills;
|
||||
if (!Array.isArray(skillsValue) || skillsValue.length === 0) {
|
||||
fail(capabilityManifestPath, 'skills', 'must contain at least one Skill');
|
||||
}
|
||||
const skills = skillsValue.map((value, index) => parseSkill(
|
||||
value,
|
||||
index,
|
||||
packageRoot,
|
||||
capabilityDirectory,
|
||||
capabilityManifestPath,
|
||||
));
|
||||
if (new Set(skills.map((skill) => skill.id)).size !== skills.length) {
|
||||
fail(capabilityManifestPath, 'skills', 'duplicate Skill identifier');
|
||||
}
|
||||
if (!Array.isArray(capability.tools) || capability.tools.length === 0) {
|
||||
fail(capabilityManifestPath, 'tools', 'must contain at least one tool');
|
||||
}
|
||||
const tools = capability.tools.map((value, index) => parseTool(value, index, capabilityManifestPath));
|
||||
if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
|
||||
fail(capabilityManifestPath, 'tools.name', 'duplicate tool identifier');
|
||||
}
|
||||
if (new Set(tools.map((tool) => tool.operation)).size !== tools.length) {
|
||||
fail(capabilityManifestPath, 'tools.operation', 'duplicate operation identifier');
|
||||
}
|
||||
const grants = new Set(skills.flatMap((skill) => skill.grants));
|
||||
for (const tool of tools) {
|
||||
if (!grants.has(tool.capabilityId)) {
|
||||
fail(capabilityManifestPath, `tools.${tool.name}.capabilityId`, 'tool capability has no Skill grant');
|
||||
}
|
||||
}
|
||||
const surfacesValue = capability.surfaces;
|
||||
const surfaces = assertRecord(surfacesValue, capabilityManifestPath, 'surfaces');
|
||||
assertExactKeys(surfaces, SURFACE_KEYS, capabilityManifestPath, 'surfaces');
|
||||
const normalizedSurfaces: { projectSettings?: string; previewRuntime?: string } = {};
|
||||
if (surfaces.projectSettings !== undefined) {
|
||||
const projectSettings = text(surfaces.projectSettings, capabilityManifestPath, 'surfaces.projectSettings', 64);
|
||||
if (!BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES.includes(projectSettings as (typeof BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES)[number])) {
|
||||
fail(capabilityManifestPath, 'surfaces.projectSettings', `surface is not code-owned: ${projectSettings}`);
|
||||
}
|
||||
normalizedSurfaces.projectSettings = projectSettings;
|
||||
}
|
||||
if (surfaces.previewRuntime !== undefined) {
|
||||
const previewRuntime = text(surfaces.previewRuntime, capabilityManifestPath, 'surfaces.previewRuntime', 64);
|
||||
if (!BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES.includes(previewRuntime as (typeof BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES)[number])) {
|
||||
fail(capabilityManifestPath, 'surfaces.previewRuntime', `surface is not code-owned: ${previewRuntime}`);
|
||||
}
|
||||
normalizedSurfaces.previewRuntime = previewRuntime;
|
||||
}
|
||||
const definition = freezeDeep({
|
||||
id: pluginId,
|
||||
version: root.version,
|
||||
contractVersion,
|
||||
displayName,
|
||||
description,
|
||||
scope: 'project' as const,
|
||||
adapterId,
|
||||
requiresBackend,
|
||||
skills,
|
||||
tools,
|
||||
surfaces: normalizedSurfaces,
|
||||
});
|
||||
validateDataServiceDefinition(definition, capabilityManifestPath);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function readJson(source: string, filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(source) as unknown;
|
||||
} catch (error) {
|
||||
fail(filePath, 'root', `invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCodingPluginDefinition(packageRoot: string): Promise<CodingPluginDefinition> {
|
||||
const root = path.resolve(packageRoot);
|
||||
const pluginManifestPath = path.join(root, PACKAGE_MANIFEST_FILE);
|
||||
const rootValue = readJson(await readFile(pluginManifestPath, 'utf8'), pluginManifestPath);
|
||||
const rootManifest = parseAgentPluginsRootManifest(rootValue, pluginManifestPath);
|
||||
const capabilityManifest = normalizeCandidatePath(
|
||||
root,
|
||||
root,
|
||||
rootManifest.extensions['com.makelore'].capabilityManifest,
|
||||
pluginManifestPath,
|
||||
'extensions.com.makelore.capabilityManifest',
|
||||
);
|
||||
if (capabilityManifest !== CAPABILITY_MANIFEST_RELATIVE_PATH) {
|
||||
fail(pluginManifestPath, 'extensions.com.makelore.capabilityManifest', `must point to ${CAPABILITY_MANIFEST_RELATIVE_PATH}`);
|
||||
}
|
||||
const capabilityManifestPath = path.join(root, capabilityManifest);
|
||||
const capabilityValue = readJson(await readFile(capabilityManifestPath, 'utf8'), capabilityManifestPath);
|
||||
const definition = parseCodingPluginManifest(rootValue, capabilityValue, {
|
||||
packageRoot: root,
|
||||
rootManifestPath: pluginManifestPath,
|
||||
capabilityManifestPath,
|
||||
});
|
||||
for (const skill of definition.skills) {
|
||||
try {
|
||||
await readFile(path.join(root, skill.entryPath), 'utf8');
|
||||
} catch (error) {
|
||||
fail(
|
||||
capabilityManifestPath,
|
||||
`skills.${skill.id}.entry`,
|
||||
`Skill entry could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function resolveBundledCodingPluginRootPaths(resourcesRoot: string): string[] {
|
||||
const root = path.resolve(resourcesRoot);
|
||||
return BUNDLED_CODING_PLUGIN_ROOTS.map((relativeRoot) => path.join(root, relativeRoot));
|
||||
}
|
||||
|
||||
export const resolveBundledPluginRoots = resolveBundledCodingPluginRootPaths;
|
||||
|
||||
export async function loadBundledCodingPluginDefinitions(
|
||||
resourcesRoot: string,
|
||||
): Promise<CodingPluginDefinition[]> {
|
||||
const roots = resolveBundledCodingPluginRootPaths(resourcesRoot);
|
||||
const definitions = await Promise.all(roots.map(async (root, index) => {
|
||||
const definition = await loadCodingPluginDefinition(root);
|
||||
const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index];
|
||||
if (definition.id !== `makelore.${expectedRoot}`) {
|
||||
throw new CodingPluginManifestError(
|
||||
path.join(root, PACKAGE_MANIFEST_FILE),
|
||||
'name',
|
||||
`fixed bundle root ${expectedRoot} contains ${definition.id}`,
|
||||
);
|
||||
}
|
||||
return definition;
|
||||
}));
|
||||
const pluginIds = new Set<string>();
|
||||
const capabilityIds = new Set<string>();
|
||||
const skillIds = new Set<string>();
|
||||
const operationIds = new Set<string>();
|
||||
const toolIds = new Set<string>();
|
||||
for (const definition of definitions) {
|
||||
if (pluginIds.has(definition.id)) throw new Error(`Duplicate bundled plugin identifier: ${definition.id}`);
|
||||
pluginIds.add(definition.id);
|
||||
for (const skill of definition.skills) {
|
||||
if (skillIds.has(skill.id)) throw new Error(`Duplicate bundled Skill identifier: ${skill.id}`);
|
||||
skillIds.add(skill.id);
|
||||
for (const grant of skill.grants) capabilityIds.add(grant);
|
||||
}
|
||||
for (const tool of definition.tools) {
|
||||
if (toolIds.has(tool.name)) throw new Error(`Duplicate bundled tool identifier: ${tool.name}`);
|
||||
if (operationIds.has(tool.operation)) throw new Error(`Duplicate bundled operation identifier: ${tool.operation}`);
|
||||
toolIds.add(tool.name);
|
||||
operationIds.add(tool.operation);
|
||||
capabilityIds.add(tool.capabilityId);
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}
|
||||
|
||||
export const parseBundledCodingPlugins = loadBundledCodingPluginDefinitions;
|
||||
export const loadBundledPluginDefinitions = loadBundledCodingPluginDefinitions;
|
||||
390
electron/coding-plugins/project-service.ts
Normal file
390
electron/coding-plugins/project-service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import path from 'node:path';
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from '../coding-projects/atomic-json';
|
||||
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
|
||||
import {
|
||||
BUNDLED_CODING_PLUGIN_DEFINITIONS,
|
||||
DATA_SERVICE_PLUGIN_ID,
|
||||
} from '../../shared/coding-plugins';
|
||||
|
||||
export const PROJECT_PLUGIN_SELECTION_PATH = '.niancode/plugins.json';
|
||||
export const PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION = 1 as const;
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
|
||||
const ISO_UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
|
||||
|
||||
export interface ProjectPluginSelectionFile {
|
||||
schemaVersion: typeof PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION;
|
||||
enabledPluginIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ProjectPluginSelectionSource = 'none' | 'file' | 'legacy';
|
||||
|
||||
/**
|
||||
* Effective project selection. A missing file is deliberately represented as
|
||||
* `source: 'none'`; the legacy projection is read-only until a user mutation
|
||||
* persists the new file.
|
||||
*/
|
||||
export interface ProjectPluginSelection {
|
||||
projectPath: string;
|
||||
status: 'missing' | 'present';
|
||||
source: ProjectPluginSelectionSource;
|
||||
schemaVersion: typeof PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION | null;
|
||||
enabledPluginIds: readonly string[];
|
||||
unknownPluginIds: readonly string[];
|
||||
updatedAt: string | null;
|
||||
legacyProjectedPluginIds: readonly string[];
|
||||
persisted: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectPluginManagedInputsChangedEvent {
|
||||
projectPath: string;
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
export interface ProjectPluginAdapterDeactivatedEvent {
|
||||
projectPath: string;
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
export interface ProjectPluginServiceOptions {
|
||||
/** Defaults to the code-owned bundled catalog. */
|
||||
knownPluginIds?: readonly string[] | (() => readonly string[]);
|
||||
now?: () => string;
|
||||
/** Test seam; production writes use the existing atomic JSON writer. */
|
||||
writer?: JsonFileWriter;
|
||||
writeJson?: JsonFileWriter;
|
||||
onManagedInputsChanged?(
|
||||
event: ProjectPluginManagedInputsChangedEvent,
|
||||
): Promise<void> | void;
|
||||
onAdapterDeactivated?(
|
||||
event: ProjectPluginAdapterDeactivatedEvent,
|
||||
): Promise<void> | void;
|
||||
}
|
||||
|
||||
export class ProjectPluginServiceError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'CODING_PLUGIN_PROJECT_PATH_INVALID'
|
||||
| 'CODING_PLUGIN_SELECTION_INVALID'
|
||||
| 'CODING_PLUGIN_UNKNOWN'
|
||||
| 'CODING_PLUGIN_SELECTION_WRITE_FAILED',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ProjectPluginServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
function projectSelectionPath(projectPath: string): string {
|
||||
const candidate = projectPath.trim();
|
||||
if (!candidate || !path.isAbsolute(candidate)) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_PROJECT_PATH_INVALID',
|
||||
'Project path must be an absolute path',
|
||||
);
|
||||
}
|
||||
return path.join(path.resolve(candidate), PROJECT_PLUGIN_SELECTION_PATH);
|
||||
}
|
||||
|
||||
function projectPathFromSelectionPath(selectionPath: string): string {
|
||||
return path.dirname(path.dirname(path.resolve(selectionPath)));
|
||||
}
|
||||
|
||||
function knownPluginIdSet(
|
||||
value: readonly string[] | (() => readonly string[]) | undefined,
|
||||
): Set<string> {
|
||||
const source = typeof value === 'function'
|
||||
? value()
|
||||
: value ?? BUNDLED_CODING_PLUGIN_DEFINITIONS.map(({ id }) => id);
|
||||
return new Set(source.map((id) => id.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function normalizePluginId(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
`${field} must contain strings`,
|
||||
);
|
||||
}
|
||||
const id = value.trim();
|
||||
if (!PLUGIN_ID_PATTERN.test(id)) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
`${field} contains an invalid plugin id`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function normalizePluginIds(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
`${field} must be an array`,
|
||||
);
|
||||
}
|
||||
const ids = value.map((candidate, index) => normalizePluginId(candidate, `${field}[${index}]`));
|
||||
return [...new Set(ids)].sort();
|
||||
}
|
||||
|
||||
function normalizeUpdatedAt(value: unknown): string {
|
||||
const timestamp = typeof value === 'string' ? value.trim() : '';
|
||||
if (!ISO_UTC_TIMESTAMP_PATTERN.test(timestamp) || Number.isNaN(Date.parse(timestamp))) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
'updatedAt must be an ISO UTC timestamp',
|
||||
);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function normalizeSelectionFile(value: unknown): ProjectPluginSelectionFile {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
'Plugin selection must be an object',
|
||||
);
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
if (keys.length !== 3 || keys.some((key, index) => key !== ['enabledPluginIds', 'schemaVersion', 'updatedAt'][index])) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
'Plugin selection has unexpected fields',
|
||||
);
|
||||
}
|
||||
if (record.schemaVersion !== PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
'Unsupported plugin selection schema',
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION,
|
||||
enabledPluginIds: normalizePluginIds(record.enabledPluginIds, 'enabledPluginIds'),
|
||||
updatedAt: normalizeUpdatedAt(record.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function currentTime(now: (() => string) | undefined): string {
|
||||
const value = now?.() ?? new Date().toISOString();
|
||||
if (!ISO_UTC_TIMESTAMP_PATTERN.test(typeof value === 'string' ? value.trim() : '')
|
||||
|| Number.isNaN(Date.parse(value))) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_WRITE_FAILED',
|
||||
'Plugin selection timestamp must be an ISO UTC timestamp',
|
||||
);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
interface ReadSelectionResult {
|
||||
file: ProjectPluginSelectionFile | null;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export class ProjectPluginService {
|
||||
private readonly mutationTails = new Map<string, Promise<unknown>>();
|
||||
private readonly managedInputRevisions = new Map<string, number>();
|
||||
|
||||
constructor(private readonly options: ProjectPluginServiceOptions = {}) {}
|
||||
|
||||
selectionPath(projectPath: string): string {
|
||||
return projectSelectionPath(projectPath);
|
||||
}
|
||||
|
||||
getManagedInputRevision(projectPath: string): number {
|
||||
const normalizedProjectPath = path.resolve(projectPath);
|
||||
return this.managedInputRevisions.get(normalizedProjectPath) ?? 0;
|
||||
}
|
||||
|
||||
async readSelection(projectPath: string): Promise<ProjectPluginSelection> {
|
||||
const filePath = projectSelectionPath(projectPath);
|
||||
const project = path.dirname(path.dirname(filePath));
|
||||
const result = await this.readFile(filePath);
|
||||
const knownIds = knownPluginIdSet(this.options.knownPluginIds);
|
||||
if (result.file) {
|
||||
const enabledPluginIds = result.file.enabledPluginIds;
|
||||
return {
|
||||
projectPath: project,
|
||||
status: 'present',
|
||||
source: 'file',
|
||||
schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION,
|
||||
enabledPluginIds,
|
||||
unknownPluginIds: enabledPluginIds.filter((id) => !knownIds.has(id)),
|
||||
updatedAt: result.file.updatedAt,
|
||||
legacyProjectedPluginIds: [],
|
||||
persisted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyProjectedPluginIds = await this.legacyProjectedPluginIds(project);
|
||||
const enabledPluginIds = [...legacyProjectedPluginIds];
|
||||
return {
|
||||
projectPath: project,
|
||||
status: 'missing',
|
||||
source: enabledPluginIds.length > 0 ? 'legacy' : 'none',
|
||||
schemaVersion: null,
|
||||
enabledPluginIds,
|
||||
unknownPluginIds: enabledPluginIds.filter((id) => !knownIds.has(id)),
|
||||
updatedAt: null,
|
||||
legacyProjectedPluginIds,
|
||||
persisted: false,
|
||||
};
|
||||
}
|
||||
|
||||
getSelection(projectPath: string): Promise<ProjectPluginSelection> {
|
||||
return this.readSelection(projectPath);
|
||||
}
|
||||
|
||||
readProjectSelection(projectPath: string): Promise<ProjectPluginSelection> {
|
||||
return this.readSelection(projectPath);
|
||||
}
|
||||
|
||||
async getEnabledPluginIds(projectPath: string): Promise<readonly string[]> {
|
||||
return (await this.readSelection(projectPath)).enabledPluginIds;
|
||||
}
|
||||
|
||||
async isEnabled(projectPath: string, pluginId: string): Promise<boolean> {
|
||||
const id = normalizePluginId(pluginId, 'pluginId');
|
||||
return (await this.readSelection(projectPath)).enabledPluginIds.includes(id);
|
||||
}
|
||||
|
||||
enable(projectPath: string, pluginId: string): Promise<ProjectPluginSelection> {
|
||||
return this.setEnabled(projectPath, pluginId, true);
|
||||
}
|
||||
|
||||
disable(projectPath: string, pluginId: string): Promise<ProjectPluginSelection> {
|
||||
return this.setEnabled(projectPath, pluginId, false);
|
||||
}
|
||||
|
||||
setEnabled(
|
||||
projectPath: string,
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<ProjectPluginSelection> {
|
||||
const selectionPath = projectSelectionPath(projectPath);
|
||||
const normalizedProjectPath = projectPathFromSelectionPath(selectionPath);
|
||||
return this.enqueue(normalizedProjectPath, async () => {
|
||||
const id = normalizePluginId(pluginId, 'pluginId');
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
'enabled must be a boolean',
|
||||
);
|
||||
}
|
||||
const knownIds = knownPluginIdSet(this.options.knownPluginIds);
|
||||
if (!knownIds.has(id)) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_UNKNOWN',
|
||||
`Unknown coding plugin: ${id}`,
|
||||
);
|
||||
}
|
||||
const current = await this.readSelection(normalizedProjectPath);
|
||||
const wasEnabled = current.enabledPluginIds.includes(id);
|
||||
// A missing read is intentionally side-effect free. A real state
|
||||
// change (or the legacy projection) is the user mutation that creates
|
||||
// the selection file.
|
||||
const needsPersist = current.source === 'legacy' || wasEnabled !== enabled;
|
||||
if (!needsPersist) return current;
|
||||
|
||||
const ids = new Set(current.enabledPluginIds);
|
||||
if (enabled) ids.add(id);
|
||||
else ids.delete(id);
|
||||
const nextFile: ProjectPluginSelectionFile = {
|
||||
schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION,
|
||||
enabledPluginIds: [...ids].sort(),
|
||||
updatedAt: currentTime(this.options.now),
|
||||
};
|
||||
try {
|
||||
await (this.options.writer ?? this.options.writeJson ?? atomicWriteJson)(selectionPath, nextFile);
|
||||
} catch (error) {
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_WRITE_FAILED',
|
||||
`Plugin selection could not be persisted: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const revision = this.nextRevision(normalizedProjectPath);
|
||||
const event: ProjectPluginManagedInputsChangedEvent = {
|
||||
projectPath: normalizedProjectPath,
|
||||
pluginId: id,
|
||||
enabled,
|
||||
revision,
|
||||
};
|
||||
await this.options.onManagedInputsChanged?.(event);
|
||||
if (!enabled && wasEnabled) {
|
||||
await this.options.onAdapterDeactivated?.({
|
||||
projectPath: normalizedProjectPath,
|
||||
pluginId: id,
|
||||
});
|
||||
}
|
||||
return {
|
||||
projectPath: normalizedProjectPath,
|
||||
status: 'present',
|
||||
source: 'file',
|
||||
schemaVersion: PROJECT_PLUGIN_SELECTION_SCHEMA_VERSION,
|
||||
enabledPluginIds: nextFile.enabledPluginIds,
|
||||
unknownPluginIds: nextFile.enabledPluginIds.filter((candidate) => !knownIds.has(candidate)),
|
||||
updatedAt: nextFile.updatedAt,
|
||||
legacyProjectedPluginIds: [],
|
||||
persisted: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async readFile(filePath: string): Promise<ReadSelectionResult> {
|
||||
try {
|
||||
return {
|
||||
file: normalizeSelectionFile(await readJsonFile(filePath)),
|
||||
filePath,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return { file: null, filePath };
|
||||
if (error instanceof ProjectPluginServiceError) throw error;
|
||||
throw new ProjectPluginServiceError(
|
||||
'CODING_PLUGIN_SELECTION_INVALID',
|
||||
`Plugin selection could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async legacyProjectedPluginIds(projectPath: string): Promise<string[]> {
|
||||
const result = await readCodingProjectConfigV2(projectPath);
|
||||
if (result.status !== 'valid') return [];
|
||||
const hasDataServiceSkill = result.config.agents.some((agent) => agent.skillIds.includes('data-service'));
|
||||
return hasDataServiceSkill ? [DATA_SERVICE_PLUGIN_ID] : [];
|
||||
}
|
||||
|
||||
private nextRevision(projectPath: string): number {
|
||||
const revision = (this.managedInputRevisions.get(projectPath) ?? 0) + 1;
|
||||
this.managedInputRevisions.set(projectPath, revision);
|
||||
return revision;
|
||||
}
|
||||
|
||||
private enqueue<T>(projectPath: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.mutationTails.get(projectPath) ?? Promise.resolve();
|
||||
const result = previous.then(operation, operation);
|
||||
this.mutationTails.set(projectPath, result);
|
||||
void result.then(
|
||||
() => {
|
||||
if (this.mutationTails.get(projectPath) === result) this.mutationTails.delete(projectPath);
|
||||
},
|
||||
() => {
|
||||
if (this.mutationTails.get(projectPath) === result) this.mutationTails.delete(projectPath);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const createProjectPluginService = (
|
||||
options: ProjectPluginServiceOptions = {},
|
||||
): ProjectPluginService => new ProjectPluginService(options);
|
||||
Reference in New Issue
Block a user