import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { isPathWithinRoot, validateFileName } from '@main/utils/pathValidation'; import { shell } from 'electron'; import { SkillImportService } from './SkillImportService'; import { SkillPlanService } from './SkillPlanService'; import { SkillRootsResolver } from './SkillRootsResolver'; import { SkillScaffoldService } from './SkillScaffoldService'; import { SkillsCatalogService } from './SkillsCatalogService'; import type { SkillDeleteRequest, SkillDetail, SkillImportRequest, SkillReviewPreview, SkillUpsertRequest, } from '@shared/types/extensions'; export class SkillsMutationService { constructor( private readonly rootsResolver = new SkillRootsResolver(), private readonly catalogService = new SkillsCatalogService(), private readonly scaffoldService = new SkillScaffoldService(rootsResolver), private readonly importService = new SkillImportService(), private readonly planService = new SkillPlanService() ) {} async previewUpsert(request: SkillUpsertRequest): Promise { const targetSkillDir = await this.scaffoldService.resolveUpsertTarget( request.scope, request.rootKind, request.projectPath, request.folderName, request.existingSkillId ); const files = this.scaffoldService.normalizeDraftFiles(request.files); const plan = await this.planService.buildUpsertPlan(targetSkillDir, files); return plan.preview; } async applyUpsert(request: SkillUpsertRequest): Promise { if (!request.reviewPlanId) { throw new Error('Review the skill changes before saving.'); } const targetSkillDir = await this.scaffoldService.resolveUpsertTarget( request.scope, request.rootKind, request.projectPath, request.folderName, request.existingSkillId ); const files = this.scaffoldService.normalizeDraftFiles(request.files); const plan = await this.planService.buildUpsertPlan(targetSkillDir, files); this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId); await this.planService.applyPlan(plan); return this.catalogService.getDetail(targetSkillDir, request.projectPath); } async previewImport(request: SkillImportRequest): Promise { const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request); const inspection = await this.importService.inspectSourceDir(sourceDir); const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files); return { ...plan.preview, warnings: [...new Set([...inspection.warnings, ...plan.preview.warnings])], }; } async applyImport(request: SkillImportRequest): Promise { if (!request.reviewPlanId) { throw new Error('Review the import changes before saving.'); } const { sourceDir, targetSkillDir } = await this.resolveImportTarget(request); const inspection = await this.importService.inspectSourceDir(sourceDir); const plan = await this.planService.buildImportPlan(targetSkillDir, inspection.files); this.assertReviewedPlanMatches(request.reviewPlanId, plan.preview.planId); await this.planService.applyPlan(plan); return this.catalogService.getDetail(targetSkillDir, request.projectPath); } async deleteSkill(request: SkillDeleteRequest): Promise { const skillDir = this.resolveExistingSkill(request.skillId, request.projectPath); await shell.trashItem(skillDir); } private async resolveImportTarget( request: SkillImportRequest ): Promise<{ sourceDir: string; targetSkillDir: string }> { const sourceDir = await this.importService.validateSourceDir(request.sourceDir); const root = this.resolveWritableRoot(request.scope, request.rootKind, request.projectPath); await fs.mkdir(root.rootPath, { recursive: true }); const folderName = request.folderName?.trim() || path.basename(sourceDir); const folderValidation = validateFileName(folderName); if (!folderValidation.valid) { throw new Error(folderValidation.error ?? 'Invalid folder name'); } const targetSkillDir = path.join(root.rootPath, folderName); if (!isPathWithinRoot(targetSkillDir, root.rootPath)) { throw new Error('Import destination is outside the allowed root'); } return { sourceDir, targetSkillDir }; } private resolveWritableRoot( scope: SkillUpsertRequest['scope'], rootKind: SkillUpsertRequest['rootKind'], projectPath?: string ) { const roots = this.rootsResolver.resolve(projectPath); const match = roots.find((root) => root.scope === scope && root.rootKind === rootKind); if (!match) { throw new Error('Requested skill root is unavailable'); } if (scope === 'project' && !projectPath) { throw new Error('projectPath is required for project-scoped skills'); } return match; } private resolveExistingSkill(skillId: string, projectPath?: string): string { const normalizedSkillDir = path.resolve(skillId); const roots = this.rootsResolver.resolve(projectPath); const owningRoot = roots.find((root) => isPathWithinRoot(normalizedSkillDir, root.rootPath)); if (!owningRoot) { throw new Error('Skill is outside the allowed roots'); } return normalizedSkillDir; } private assertReviewedPlanMatches(reviewPlanId: string, currentPlanId: string): void { if (reviewPlanId !== currentPlanId) { throw new Error( 'The skill files changed after review. Review the latest changes and try again.' ); } } }