Ran pnpm lint:fix to resolve 220 auto-fixable lint issues. All changes are import/export reordering — no logic changes.
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
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<SkillReviewPreview> {
|
|
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<SkillDetail | null> {
|
|
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<SkillReviewPreview> {
|
|
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<SkillDetail | null> {
|
|
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<void> {
|
|
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.'
|
|
);
|
|
}
|
|
}
|
|
}
|