import path from 'node:path'; import { atomicWriteJson, readJsonFile, type JsonFileWriter } from '../coding-projects/atomic-json'; import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; import { 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; onAdapterDeactivated?( event: ProjectPluginAdapterDeactivatedEvent, ): Promise | 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 { const source = typeof value === 'function' ? value() : value ?? [DATA_SERVICE_PLUGIN_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; 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>(); private readonly managedInputRevisions = new Map(); private readonly legacySelections = new Map(); 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 { 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) { this.legacySelections.delete(project); 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, }; } let legacyProjectedPluginIds = this.legacySelections.get(project); if (!legacyProjectedPluginIds) { legacyProjectedPluginIds = Object.freeze(await this.legacyProjectedPluginIds(project)); this.legacySelections.set(project, legacyProjectedPluginIds); } 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 { return this.readSelection(projectPath); } readProjectSelection(projectPath: string): Promise { return this.readSelection(projectPath); } async getEnabledPluginIds(projectPath: string): Promise { return (await this.readSelection(projectPath)).enabledPluginIds; } async isEnabled(projectPath: string, pluginId: string): Promise { const id = normalizePluginId(pluginId, 'pluginId'); return (await this.readSelection(projectPath)).enabledPluginIds.includes(id); } enable(projectPath: string, pluginId: string): Promise { return this.setEnabled(projectPath, pluginId, true); } disable(projectPath: string, pluginId: string): Promise { return this.setEnabled(projectPath, pluginId, false); } setEnabled( projectPath: string, pluginId: string, enabled: boolean, ): Promise { 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); const current = await this.readSelection(normalizedProjectPath); const wasEnabled = current.enabledPluginIds.includes(id); if (!knownIds.has(id) && (enabled || !wasEnabled)) { throw new ProjectPluginServiceError( 'CODING_PLUGIN_UNKNOWN', `Unknown coding plugin: ${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 { 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 { 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(projectPath: string, operation: () => Promise): Promise { 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);