396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
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> | 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 ?? [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<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>();
|
|
private readonly legacySelections = new Map<string, readonly string[]>();
|
|
|
|
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) {
|
|
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<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);
|