skillverse 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +10 -0
- package/README.md +369 -0
- package/client/README.md +73 -0
- package/client/eslint.config.js +23 -0
- package/client/index.html +13 -0
- package/client/package.json +41 -0
- package/client/postcss.config.js +6 -0
- package/client/public/vite.svg +1 -0
- package/client/src/App.css +42 -0
- package/client/src/App.tsx +26 -0
- package/client/src/assets/react.svg +1 -0
- package/client/src/components/AddSkillDialog.tsx +249 -0
- package/client/src/components/Layout.tsx +134 -0
- package/client/src/components/LinkWorkspaceDialog.tsx +196 -0
- package/client/src/components/LoadingSpinner.tsx +57 -0
- package/client/src/components/SkillCard.tsx +269 -0
- package/client/src/components/Toast.tsx +44 -0
- package/client/src/components/Tooltip.tsx +132 -0
- package/client/src/index.css +168 -0
- package/client/src/lib/api.ts +196 -0
- package/client/src/main.tsx +10 -0
- package/client/src/pages/Dashboard.tsx +209 -0
- package/client/src/pages/Marketplace.tsx +282 -0
- package/client/src/pages/Settings.tsx +136 -0
- package/client/src/pages/SkillLibrary.tsx +163 -0
- package/client/src/pages/Workspaces.tsx +662 -0
- package/client/src/stores/appStore.ts +222 -0
- package/client/tailwind.config.js +82 -0
- package/client/tsconfig.app.json +28 -0
- package/client/tsconfig.json +7 -0
- package/client/tsconfig.node.json +26 -0
- package/client/vite.config.ts +26 -0
- package/package.json +34 -0
- package/registry/.env.example +5 -0
- package/registry/Dockerfile +42 -0
- package/registry/docker-compose.yml +33 -0
- package/registry/package.json +37 -0
- package/registry/prisma/schema.prisma +59 -0
- package/registry/src/index.ts +34 -0
- package/registry/src/lib/db.ts +3 -0
- package/registry/src/middleware/errorHandler.ts +35 -0
- package/registry/src/routes/auth.ts +152 -0
- package/registry/src/routes/skills.ts +295 -0
- package/registry/tsconfig.json +23 -0
- package/server/.env.example +11 -0
- package/server/package.json +60 -0
- package/server/prisma/schema.prisma +73 -0
- package/server/public/assets/index-BsYtpZSa.css +1 -0
- package/server/public/assets/index-Dfr_6UV8.js +20 -0
- package/server/public/index.html +14 -0
- package/server/public/vite.svg +1 -0
- package/server/src/bin.ts +428 -0
- package/server/src/config.ts +39 -0
- package/server/src/index.ts +112 -0
- package/server/src/lib/db.ts +14 -0
- package/server/src/middleware/errorHandler.ts +40 -0
- package/server/src/middleware/logger.ts +12 -0
- package/server/src/routes/dashboard.ts +102 -0
- package/server/src/routes/marketplace.ts +273 -0
- package/server/src/routes/skills.ts +294 -0
- package/server/src/routes/workspaces.ts +168 -0
- package/server/src/services/bundleService.ts +123 -0
- package/server/src/services/skillService.ts +722 -0
- package/server/src/services/workspaceService.ts +521 -0
- package/server/src/verify-sync.ts +91 -0
- package/server/tsconfig.json +19 -0
- package/server/tsup.config.ts +18 -0
- package/shared/package.json +21 -0
- package/shared/pnpm-lock.yaml +24 -0
- package/shared/src/index.ts +169 -0
- package/shared/tsconfig.json +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { join, basename } from 'path';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { mkdir, rm, cp, readFile, unlink } from 'fs/promises';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import simpleGit from 'simple-git';
|
|
6
|
+
import AdmZip from 'adm-zip';
|
|
7
|
+
import { prisma } from '../lib/db.js';
|
|
8
|
+
import { AppError } from '../middleware/errorHandler.js';
|
|
9
|
+
import { ErrorCode } from '@skillverse/shared';
|
|
10
|
+
|
|
11
|
+
const SKILLVERSE_HOME = process.env.SKILLVERSE_HOME || join(process.env.HOME || '', '.skillverse');
|
|
12
|
+
const SKILLS_DIR = process.env.SKILLS_DIR || join(SKILLVERSE_HOME, 'skills');
|
|
13
|
+
const TEMP_DIR = process.env.TEMP_DIR || join(SKILLVERSE_HOME, 'temp');
|
|
14
|
+
|
|
15
|
+
// Helper to parse Git URL and subdirectory
|
|
16
|
+
function parseGitUrl(url: string): { repoUrl: string; subdir?: string; skillName: string } {
|
|
17
|
+
// Handle simple URL: https://github.com/owner/repo
|
|
18
|
+
if (!url.includes('/tree/')) {
|
|
19
|
+
const urlParts = url.split('/');
|
|
20
|
+
const repoName = urlParts[urlParts.length - 1].replace('.git', '');
|
|
21
|
+
return { repoUrl: url, skillName: repoName };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Handle URL with tree/subdir: https://github.com/owner/repo/tree/main/path/to/skill
|
|
25
|
+
// Note: This logic is specific to GitHub URLs structure
|
|
26
|
+
const parts = url.split('/tree/');
|
|
27
|
+
const repoUrl = parts[0] + (parts[0].endsWith('.git') ? '' : '.git');
|
|
28
|
+
|
|
29
|
+
// parts[1] is like "main/path/to/skill"
|
|
30
|
+
// We need to support branch names that might contain slashes, but for MVP standard GitHub URLs
|
|
31
|
+
// usually have the branch as the first segment after /tree/
|
|
32
|
+
const pathParts = parts[1].split('/');
|
|
33
|
+
const branch = pathParts[0]; // Assume first part is branch for now
|
|
34
|
+
const subdir = pathParts.slice(1).join('/');
|
|
35
|
+
const skillName = pathParts[pathParts.length - 1];
|
|
36
|
+
|
|
37
|
+
return { repoUrl, subdir, skillName };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class SkillService {
|
|
41
|
+
async getAllSkills() {
|
|
42
|
+
const skills = await prisma.skill.findMany({
|
|
43
|
+
include: {
|
|
44
|
+
linkedWorkspaces: {
|
|
45
|
+
include: {
|
|
46
|
+
workspace: true,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
marketplaceEntry: true,
|
|
50
|
+
},
|
|
51
|
+
orderBy: {
|
|
52
|
+
installDate: 'desc',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return skills;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async getSkillById(id: string) {
|
|
60
|
+
const skill = await prisma.skill.findUnique({
|
|
61
|
+
where: { id },
|
|
62
|
+
include: {
|
|
63
|
+
linkedWorkspaces: {
|
|
64
|
+
include: {
|
|
65
|
+
workspace: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
marketplaceEntry: true,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!skill) {
|
|
73
|
+
throw new AppError(ErrorCode.NOT_FOUND, 'Skill not found', 404);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return skill;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getSkillByName(name: string) {
|
|
80
|
+
const skill = await prisma.skill.findUnique({
|
|
81
|
+
where: { name },
|
|
82
|
+
include: {
|
|
83
|
+
linkedWorkspaces: {
|
|
84
|
+
include: {
|
|
85
|
+
workspace: true,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
marketplaceEntry: true,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!skill) {
|
|
93
|
+
throw new AppError(ErrorCode.NOT_FOUND, `Skill "${name}" not found`, 404);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return skill;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async parseSkillMetadata(skillPath: string): Promise<{ description: string; metadata: any }> {
|
|
100
|
+
try {
|
|
101
|
+
const skillMdPath = join(skillPath, 'SKILL.md');
|
|
102
|
+
|
|
103
|
+
let description = '';
|
|
104
|
+
let metadata: any = {};
|
|
105
|
+
|
|
106
|
+
if (existsSync(skillMdPath)) {
|
|
107
|
+
const fileContent = await readFile(skillMdPath, 'utf-8');
|
|
108
|
+
const parsed = matter(fileContent);
|
|
109
|
+
|
|
110
|
+
// Use description from frontmatter or from first paragraph of body?
|
|
111
|
+
// Prioritize frontmatter
|
|
112
|
+
description = parsed.data.description || '';
|
|
113
|
+
metadata = parsed.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Fallback to package.json or skill.json if no description
|
|
117
|
+
if (!description) {
|
|
118
|
+
const packageJsonPath = join(skillPath, 'package.json');
|
|
119
|
+
const skillJsonPath = join(skillPath, 'skill.json');
|
|
120
|
+
|
|
121
|
+
if (existsSync(skillJsonPath)) {
|
|
122
|
+
const content = await readFile(skillJsonPath, 'utf-8');
|
|
123
|
+
const json = JSON.parse(content);
|
|
124
|
+
description = json.description || '';
|
|
125
|
+
metadata = { ...metadata, ...json };
|
|
126
|
+
} else if (existsSync(packageJsonPath)) {
|
|
127
|
+
const content = await readFile(packageJsonPath, 'utf-8');
|
|
128
|
+
const pkg = JSON.parse(content);
|
|
129
|
+
description = pkg.description || '';
|
|
130
|
+
metadata = { ...metadata, name: pkg.name, version: pkg.version };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { description, metadata };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.warn('Failed to parse skill metadata:', error);
|
|
137
|
+
return { description: '', metadata: {} };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async createSkillFromGit(gitUrl: string, description?: string) {
|
|
142
|
+
let tempPath: string | null = null;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// Parse URL
|
|
146
|
+
const { repoUrl, subdir, skillName } = parseGitUrl(gitUrl);
|
|
147
|
+
|
|
148
|
+
// Check if skill already exists
|
|
149
|
+
const existingSkill = await prisma.skill.findUnique({
|
|
150
|
+
where: { name: skillName },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (existingSkill) {
|
|
154
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill "${skillName}" already exists`, 409);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Prepare directories
|
|
158
|
+
const skillPath = join(SKILLS_DIR, skillName);
|
|
159
|
+
if (existsSync(skillPath)) {
|
|
160
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill directory "${skillName}" already exists`, 409);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let commitHash = '';
|
|
164
|
+
|
|
165
|
+
if (subdir) {
|
|
166
|
+
// Clone to temp directory first for subdirectory extraction
|
|
167
|
+
tempPath = join(TEMP_DIR, `git-clone-${Date.now()}`);
|
|
168
|
+
await mkdir(tempPath, { recursive: true });
|
|
169
|
+
|
|
170
|
+
console.log(`Cloning ${repoUrl} to temp path for extraction...`);
|
|
171
|
+
const git = simpleGit();
|
|
172
|
+
await git.clone(repoUrl, tempPath);
|
|
173
|
+
|
|
174
|
+
// Get commit hash
|
|
175
|
+
try {
|
|
176
|
+
commitHash = await simpleGit(tempPath).revparse(['HEAD']);
|
|
177
|
+
console.log(`Captured commit hash: ${commitHash}`);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.warn('Failed to capture commit hash:', e);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sourcePath = join(tempPath, subdir);
|
|
183
|
+
if (!existsSync(sourcePath)) {
|
|
184
|
+
throw new AppError(
|
|
185
|
+
ErrorCode.GIT_ERROR,
|
|
186
|
+
`Subdirectory "${subdir}" not found in repository`,
|
|
187
|
+
400
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Validate skill structure (must have SKILL.md, skill.json, or package.json)
|
|
192
|
+
const hasSkillMd = existsSync(join(sourcePath, 'SKILL.md'));
|
|
193
|
+
const hasSkillJson = existsSync(join(sourcePath, 'skill.json'));
|
|
194
|
+
const hasPackageJson = existsSync(join(sourcePath, 'package.json'));
|
|
195
|
+
|
|
196
|
+
if (!hasSkillMd && !hasSkillJson && !hasPackageJson) {
|
|
197
|
+
throw new AppError(
|
|
198
|
+
ErrorCode.VALIDATION_ERROR,
|
|
199
|
+
`Invalid skill structure: "${subdir}" does not contain SKILL.md, skill.json, or package.json`,
|
|
200
|
+
400
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Move subdir content to final skill path
|
|
205
|
+
await mkdir(skillPath, { recursive: true });
|
|
206
|
+
await cp(sourcePath, skillPath, { recursive: true });
|
|
207
|
+
|
|
208
|
+
} else {
|
|
209
|
+
// Direct clone to skill path
|
|
210
|
+
if (!existsSync(skillPath)) {
|
|
211
|
+
await mkdir(skillPath, { recursive: true });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const git = simpleGit();
|
|
215
|
+
console.log(`Cloning ${gitUrl} to ${skillPath}...`);
|
|
216
|
+
await git.clone(gitUrl, skillPath);
|
|
217
|
+
|
|
218
|
+
// Get commit hash
|
|
219
|
+
try {
|
|
220
|
+
commitHash = await git.cwd(skillPath).revparse(['HEAD']);
|
|
221
|
+
console.log(`Captured commit hash: ${commitHash}`);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
console.warn('Failed to capture commit hash:', e);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Read metadata
|
|
228
|
+
const parsed = await this.parseSkillMetadata(skillPath);
|
|
229
|
+
|
|
230
|
+
// Save to database
|
|
231
|
+
const skill = await prisma.skill.create({
|
|
232
|
+
data: {
|
|
233
|
+
name: skillName,
|
|
234
|
+
source: 'git',
|
|
235
|
+
sourceUrl: gitUrl,
|
|
236
|
+
repoUrl,
|
|
237
|
+
commitHash,
|
|
238
|
+
description: description || parsed.description || '',
|
|
239
|
+
storagePath: skillPath,
|
|
240
|
+
metadata: JSON.stringify(parsed.metadata),
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return skill;
|
|
245
|
+
} catch (error: any) {
|
|
246
|
+
// Cleanup on error (only if we created files but failed DB)
|
|
247
|
+
// Actually we check DB first so if we fail here it's likely during git/fs ops
|
|
248
|
+
// The implementation already has cleanup for tempPath in finally
|
|
249
|
+
throw error;
|
|
250
|
+
} finally {
|
|
251
|
+
if (tempPath && existsSync(tempPath)) {
|
|
252
|
+
await rm(tempPath, { recursive: true, force: true }).catch(console.error);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async createSkillFromDirectory(path: string, name?: string, description?: string) {
|
|
260
|
+
if (!existsSync(path)) {
|
|
261
|
+
throw new AppError(ErrorCode.FILE_SYSTEM_ERROR, `Source path not found: ${path}`, 400);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const skillName = name || basename(path);
|
|
265
|
+
const skillPath = join(SKILLS_DIR, skillName);
|
|
266
|
+
|
|
267
|
+
// Check if skill already exists
|
|
268
|
+
const existingSkill = await prisma.skill.findUnique({
|
|
269
|
+
where: { name: skillName },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if (existingSkill) {
|
|
273
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill "${skillName}" already exists`, 409);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (existsSync(skillPath)) {
|
|
277
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill directory "${skillName}" already exists`, 409);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
// Create skill directory
|
|
282
|
+
await mkdir(skillPath, { recursive: true });
|
|
283
|
+
|
|
284
|
+
// Copy files
|
|
285
|
+
await cp(path, skillPath, { recursive: true });
|
|
286
|
+
|
|
287
|
+
// Read metadata
|
|
288
|
+
const parsed = await this.parseSkillMetadata(skillPath);
|
|
289
|
+
|
|
290
|
+
const skill = await prisma.skill.create({
|
|
291
|
+
data: {
|
|
292
|
+
name: skillName,
|
|
293
|
+
source: 'local',
|
|
294
|
+
description: description || parsed.description || '',
|
|
295
|
+
storagePath: skillPath,
|
|
296
|
+
metadata: JSON.stringify(parsed.metadata),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return skill;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
// Clean up
|
|
303
|
+
if (existsSync(skillPath)) {
|
|
304
|
+
await rm(skillPath, { recursive: true, force: true }).catch(() => { });
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Helper to handle cases where the archive contains a single top-level directory.
|
|
312
|
+
* Moves contents up one level if that's the case.
|
|
313
|
+
*/
|
|
314
|
+
private async stripTopLevelDirectory(targetPath: string) {
|
|
315
|
+
const { readdir, rename, rmdir, stat } = await import('fs/promises');
|
|
316
|
+
|
|
317
|
+
const items = await readdir(targetPath);
|
|
318
|
+
|
|
319
|
+
// Ignore system files like .DS_Store
|
|
320
|
+
const validItems = items.filter(i => i !== '.DS_Store' && i !== '__MACOSX');
|
|
321
|
+
|
|
322
|
+
if (validItems.length === 1) {
|
|
323
|
+
const topLevelItem = validItems[0];
|
|
324
|
+
const topLevelPath = join(targetPath, topLevelItem);
|
|
325
|
+
const stats = await stat(topLevelPath);
|
|
326
|
+
|
|
327
|
+
if (stats.isDirectory()) {
|
|
328
|
+
console.log(`Striping top-level directory: ${topLevelItem}`);
|
|
329
|
+
|
|
330
|
+
// Move all items from subdirectory to targetPath
|
|
331
|
+
const subItems = await readdir(topLevelPath);
|
|
332
|
+
for (const item of subItems) {
|
|
333
|
+
await rename(join(topLevelPath, item), join(targetPath, item));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Remove the empty subdirectory
|
|
337
|
+
await rmdir(topLevelPath);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async createSkillFromLocal(name: string, zipPath: string, description?: string) {
|
|
343
|
+
const skillPath = join(SKILLS_DIR, name);
|
|
344
|
+
|
|
345
|
+
// Check if skill already exists
|
|
346
|
+
const existingSkill = await prisma.skill.findUnique({
|
|
347
|
+
where: { name },
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (existingSkill) {
|
|
351
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill "${name}" already exists`, 409);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (existsSync(skillPath)) {
|
|
355
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill directory "${name}" already exists`, 409);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Use a temp path for extraction to verify structure before determining final path
|
|
359
|
+
const extractPath = join(TEMP_DIR, `extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
|
360
|
+
await mkdir(extractPath, { recursive: true });
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const zip = new AdmZip(zipPath);
|
|
364
|
+
zip.extractAllTo(extractPath, true);
|
|
365
|
+
|
|
366
|
+
// Fix nested directory structure if present
|
|
367
|
+
await this.stripTopLevelDirectory(extractPath);
|
|
368
|
+
|
|
369
|
+
// Read metadata from the extracted files
|
|
370
|
+
const parsed = await this.parseSkillMetadata(extractPath);
|
|
371
|
+
|
|
372
|
+
// Now move to final destination
|
|
373
|
+
// Check again to be safe against race conditions
|
|
374
|
+
if (existsSync(skillPath)) {
|
|
375
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill directory "${name}" already exists`, 409);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await import('fs/promises').then(fs => fs.rename(extractPath, skillPath));
|
|
379
|
+
|
|
380
|
+
const skill = await prisma.skill.create({
|
|
381
|
+
data: {
|
|
382
|
+
name,
|
|
383
|
+
source: 'local',
|
|
384
|
+
description: description || parsed.description || '',
|
|
385
|
+
storagePath: skillPath,
|
|
386
|
+
metadata: JSON.stringify(parsed.metadata),
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
return skill;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
// Clean up final path if it was created but DB failed
|
|
393
|
+
if (existsSync(skillPath)) {
|
|
394
|
+
await rm(skillPath, { recursive: true, force: true }).catch(() => { });
|
|
395
|
+
}
|
|
396
|
+
// Clean up temp path if it still exists
|
|
397
|
+
if (existsSync(extractPath)) {
|
|
398
|
+
await rm(extractPath, { recursive: true, force: true }).catch(() => { });
|
|
399
|
+
}
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Create a skill from a tar.gz bundle (marketplace install)
|
|
406
|
+
* @param bundlePath - Path to the .tar.gz bundle
|
|
407
|
+
* @param originalName - Original skill name
|
|
408
|
+
* @param description - Optional skill description
|
|
409
|
+
*/
|
|
410
|
+
async createSkillFromBundle(bundlePath: string, originalName: string, description?: string) {
|
|
411
|
+
// Generate a unique name to avoid conflicts
|
|
412
|
+
let name = originalName;
|
|
413
|
+
let counter = 1;
|
|
414
|
+
while (await prisma.skill.findUnique({ where: { name } })) {
|
|
415
|
+
name = `${originalName}-${counter}`;
|
|
416
|
+
counter++;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const skillPath = join(SKILLS_DIR, name);
|
|
420
|
+
|
|
421
|
+
// Use a temp path for extraction
|
|
422
|
+
const extractPath = join(TEMP_DIR, `extract-bundle-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
|
423
|
+
await mkdir(extractPath, { recursive: true });
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
// Extract bundle using tar
|
|
427
|
+
const { createReadStream } = await import('fs');
|
|
428
|
+
const { createGunzip } = await import('zlib');
|
|
429
|
+
const tar = await import('tar');
|
|
430
|
+
const { pipeline } = await import('stream/promises');
|
|
431
|
+
|
|
432
|
+
await pipeline(
|
|
433
|
+
createReadStream(bundlePath),
|
|
434
|
+
createGunzip(),
|
|
435
|
+
tar.extract({ cwd: extractPath })
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Fix nested directory structure if present
|
|
439
|
+
await this.stripTopLevelDirectory(extractPath);
|
|
440
|
+
|
|
441
|
+
// Read metadata from extracted files
|
|
442
|
+
const parsed = await this.parseSkillMetadata(extractPath);
|
|
443
|
+
|
|
444
|
+
// Check again for existence before moving
|
|
445
|
+
if (existsSync(skillPath)) {
|
|
446
|
+
throw new AppError(ErrorCode.ALREADY_EXISTS, `Skill directory "${name}" already exists`, 409);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await import('fs/promises').then(fs => fs.rename(extractPath, skillPath));
|
|
450
|
+
|
|
451
|
+
const skill = await prisma.skill.create({
|
|
452
|
+
data: {
|
|
453
|
+
name,
|
|
454
|
+
source: 'local',
|
|
455
|
+
description: description || parsed.description || '',
|
|
456
|
+
storagePath: skillPath,
|
|
457
|
+
metadata: JSON.stringify(parsed.metadata),
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
console.log(`📦 Installed skill "${name}" from bundle`);
|
|
462
|
+
return skill;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
// Clean up final path if it was created but DB failed
|
|
465
|
+
if (existsSync(skillPath)) {
|
|
466
|
+
await rm(skillPath, { recursive: true, force: true }).catch(() => { });
|
|
467
|
+
}
|
|
468
|
+
// Clean up temp path if it still exists
|
|
469
|
+
if (existsSync(extractPath)) {
|
|
470
|
+
await rm(extractPath, { recursive: true, force: true }).catch(() => { });
|
|
471
|
+
}
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async updateSkill(id: string, data: { name?: string; description?: string; metadata?: any }) {
|
|
477
|
+
const skill = await this.getSkillById(id);
|
|
478
|
+
|
|
479
|
+
const updatedSkill = await prisma.skill.update({
|
|
480
|
+
where: { id },
|
|
481
|
+
data: {
|
|
482
|
+
...(data.name && { name: data.name }),
|
|
483
|
+
...(data.description !== undefined && { description: data.description }),
|
|
484
|
+
...(data.metadata && { metadata: JSON.stringify(data.metadata) }),
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return updatedSkill;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async deleteSkill(id: string, removeFiles: boolean = true) {
|
|
492
|
+
const skill = await this.getSkillById(id);
|
|
493
|
+
|
|
494
|
+
// Remove symlinks from linked workspaces
|
|
495
|
+
if (skill.linkedWorkspaces && skill.linkedWorkspaces.length > 0) {
|
|
496
|
+
for (const link of skill.linkedWorkspaces) {
|
|
497
|
+
try {
|
|
498
|
+
const workspacePath = link.workspace.path;
|
|
499
|
+
const symlinkPath = join(workspacePath, skill.name);
|
|
500
|
+
|
|
501
|
+
if (existsSync(symlinkPath)) {
|
|
502
|
+
// Check if it is indeed a symlink to be safe
|
|
503
|
+
const stats = await import('fs/promises').then(fs => fs.lstat(symlinkPath));
|
|
504
|
+
if (stats.isSymbolicLink()) {
|
|
505
|
+
await unlink(symlinkPath);
|
|
506
|
+
console.log(`Removed symlink for ${skill.name} in workspace ${workspacePath}`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.warn(`Failed to remove symlink for ${skill.name} in workspace ${link.workspace.path}:`, error);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
await prisma.skill.delete({
|
|
516
|
+
where: { id },
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (removeFiles && skill.storagePath) {
|
|
520
|
+
await rm(skill.storagePath, { recursive: true, force: true }).catch(err => {
|
|
521
|
+
console.warn(`Failed to delete skill directory ${skill.storagePath}:`, err);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { success: true, message: 'Skill deleted successfully' };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check for updates
|
|
529
|
+
async checkForUpdate(id: string) {
|
|
530
|
+
const skill = await this.getSkillById(id);
|
|
531
|
+
|
|
532
|
+
if (skill.source !== 'git' || !skill.repoUrl) {
|
|
533
|
+
return {
|
|
534
|
+
hasUpdate: false,
|
|
535
|
+
currentHash: skill.commitHash,
|
|
536
|
+
remoteHash: null,
|
|
537
|
+
message: 'Not a git skill or missing repo URL',
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const tempPath = join(TEMP_DIR, `check-update-${Date.now()}`);
|
|
542
|
+
await mkdir(tempPath, { recursive: true });
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
const git = simpleGit();
|
|
546
|
+
// We only need to check the remote HEAD, no need to full clone
|
|
547
|
+
// But ls-remote might require authentication or be tricky with some hosts
|
|
548
|
+
// Safer to shallow clone to temp
|
|
549
|
+
console.log(`Checking updates for ${skill.name} from ${skill.repoUrl}...`);
|
|
550
|
+
|
|
551
|
+
// Parse original git URL to get branch if possible, or just HEAD
|
|
552
|
+
// For now, assume main/HEAD of the repoUrl
|
|
553
|
+
const remote = await git.listRemote([skill.repoUrl, 'HEAD']);
|
|
554
|
+
if (!remote) {
|
|
555
|
+
throw new Error('Failed to get remote HEAD');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// remote is like "hash\tHEAD"
|
|
559
|
+
const remoteHash = remote.split('\t')[0];
|
|
560
|
+
const hasUpdate = remoteHash !== skill.commitHash;
|
|
561
|
+
|
|
562
|
+
// Update DB with check result
|
|
563
|
+
await prisma.skill.update({
|
|
564
|
+
where: { id },
|
|
565
|
+
data: {
|
|
566
|
+
lastUpdateCheck: new Date(),
|
|
567
|
+
updateAvailable: hasUpdate,
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
hasUpdate,
|
|
573
|
+
currentHash: skill.commitHash,
|
|
574
|
+
remoteHash,
|
|
575
|
+
};
|
|
576
|
+
} catch (error: any) {
|
|
577
|
+
console.error('Update check error:', error);
|
|
578
|
+
throw new AppError(ErrorCode.GIT_ERROR, `Failed to check for updates: ${error.message}`, 500);
|
|
579
|
+
} finally {
|
|
580
|
+
await rm(tempPath, { recursive: true, force: true }).catch(() => { });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async checkUpdates(ids?: string[]) {
|
|
585
|
+
// Check all git skills
|
|
586
|
+
const whereClause: any = {
|
|
587
|
+
source: 'git',
|
|
588
|
+
repoUrl: { not: null },
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
if (ids && ids.length > 0) {
|
|
592
|
+
whereClause.id = { in: ids };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const skills = await prisma.skill.findMany({ where: whereClause });
|
|
596
|
+
const results: Record<string, any> = {};
|
|
597
|
+
|
|
598
|
+
// Limit concurrency to avoid hammering network/git
|
|
599
|
+
// For now simple serial or Promise.all
|
|
600
|
+
console.log(`Checking updates for ${skills.length} skills...`);
|
|
601
|
+
|
|
602
|
+
await Promise.all(skills.map(async (skill: any) => {
|
|
603
|
+
try {
|
|
604
|
+
const result = await this.checkForUpdate(skill.id);
|
|
605
|
+
results[skill.id] = result;
|
|
606
|
+
} catch (e) {
|
|
607
|
+
results[skill.id] = { error: (e as Error).message };
|
|
608
|
+
}
|
|
609
|
+
}));
|
|
610
|
+
|
|
611
|
+
return results;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async refreshMetadata(id: string) {
|
|
615
|
+
const skill = await this.getSkillById(id);
|
|
616
|
+
const parsed = await this.parseSkillMetadata(skill.storagePath);
|
|
617
|
+
|
|
618
|
+
const updated = await prisma.skill.update({
|
|
619
|
+
where: { id },
|
|
620
|
+
data: {
|
|
621
|
+
description: parsed.description,
|
|
622
|
+
metadata: JSON.stringify(parsed.metadata),
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return updated;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async upgradeSkill(id: string) {
|
|
630
|
+
const skill = await this.getSkillById(id);
|
|
631
|
+
|
|
632
|
+
if (skill.source !== 'git' || !skill.sourceUrl) {
|
|
633
|
+
throw new AppError(ErrorCode.VALIDATION_ERROR, 'Cannot upgrade non-git skill', 400);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Reuse createSkillFromGit logic but force update
|
|
637
|
+
// Since createSkillFromGit checks for existing skill, we need to adapt it
|
|
638
|
+
// Or just manually do the steps here:
|
|
639
|
+
// 1. Download new version to temp
|
|
640
|
+
// 2. Validate
|
|
641
|
+
// 3. Replace files
|
|
642
|
+
// 4. Update DB
|
|
643
|
+
|
|
644
|
+
let tempPath: string | null = null;
|
|
645
|
+
let tempSkillPath: string | null = null;
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
const { repoUrl, subdir } = parseGitUrl(skill.sourceUrl);
|
|
649
|
+
|
|
650
|
+
tempPath = join(TEMP_DIR, `upgrade-${Date.now()}`);
|
|
651
|
+
await mkdir(tempPath, { recursive: true });
|
|
652
|
+
|
|
653
|
+
const git = simpleGit();
|
|
654
|
+
console.log(`Cloning ${repoUrl} to temp for upgrade...`);
|
|
655
|
+
await git.clone(repoUrl, tempPath);
|
|
656
|
+
|
|
657
|
+
let commitHash = '';
|
|
658
|
+
try {
|
|
659
|
+
commitHash = await simpleGit(tempPath).revparse(['HEAD']);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
console.warn('Failed to capture commit hash during upgrade:', e);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
let sourcePath = tempPath;
|
|
665
|
+
if (subdir) {
|
|
666
|
+
sourcePath = join(tempPath, subdir);
|
|
667
|
+
if (!existsSync(sourcePath)) {
|
|
668
|
+
throw new AppError(ErrorCode.GIT_ERROR, `Subdirectory "${subdir}" not found`, 400);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Validate structure
|
|
673
|
+
const hasSkillMd = existsSync(join(sourcePath, 'SKILL.md'));
|
|
674
|
+
const hasSkillJson = existsSync(join(sourcePath, 'skill.json'));
|
|
675
|
+
const hasPackageJson = existsSync(join(sourcePath, 'package.json'));
|
|
676
|
+
|
|
677
|
+
if (!hasSkillMd && !hasSkillJson && !hasPackageJson) {
|
|
678
|
+
throw new AppError(ErrorCode.VALIDATION_ERROR, 'Invalid skill structure in new version', 400);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Validated. Now replace files.
|
|
682
|
+
// We should be careful not to delete user configs if any, but for now we do full replace
|
|
683
|
+
// except maybe keeping some local files? For MVP, full replace.
|
|
684
|
+
|
|
685
|
+
// Clear existing directory content
|
|
686
|
+
const files = await import('fs/promises').then(fs => fs.readdir(skill.storagePath));
|
|
687
|
+
for (const file of files) {
|
|
688
|
+
await rm(join(skill.storagePath, file), { recursive: true, force: true });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Copy new files
|
|
692
|
+
await cp(sourcePath, skill.storagePath, { recursive: true });
|
|
693
|
+
|
|
694
|
+
// Parse new metadata after upgrade
|
|
695
|
+
const parsed = await this.parseSkillMetadata(skill.storagePath);
|
|
696
|
+
|
|
697
|
+
// Update DB
|
|
698
|
+
const updatedSkill = await prisma.skill.update({
|
|
699
|
+
where: { id },
|
|
700
|
+
data: {
|
|
701
|
+
commitHash,
|
|
702
|
+
updateAvailable: false,
|
|
703
|
+
lastUpdateCheck: new Date(),
|
|
704
|
+
installDate: new Date(), // Considering upgrade as reinstall? Or user prefer generic updatedAt
|
|
705
|
+
// Keeping installDate as "last installed/upgraded"
|
|
706
|
+
description: parsed.description,
|
|
707
|
+
metadata: JSON.stringify(parsed.metadata),
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
return updatedSkill;
|
|
712
|
+
|
|
713
|
+
} catch (error: any) {
|
|
714
|
+
console.error('Upgrade error:', error);
|
|
715
|
+
throw new AppError(ErrorCode.GIT_ERROR, `Failed to upgrade skill: ${error.message}`, 500);
|
|
716
|
+
} finally {
|
|
717
|
+
if (tempPath) await rm(tempPath, { recursive: true, force: true }).catch(() => { });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export const skillService = new SkillService();
|