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.
Files changed (72) hide show
  1. package/.prettierrc +10 -0
  2. package/README.md +369 -0
  3. package/client/README.md +73 -0
  4. package/client/eslint.config.js +23 -0
  5. package/client/index.html +13 -0
  6. package/client/package.json +41 -0
  7. package/client/postcss.config.js +6 -0
  8. package/client/public/vite.svg +1 -0
  9. package/client/src/App.css +42 -0
  10. package/client/src/App.tsx +26 -0
  11. package/client/src/assets/react.svg +1 -0
  12. package/client/src/components/AddSkillDialog.tsx +249 -0
  13. package/client/src/components/Layout.tsx +134 -0
  14. package/client/src/components/LinkWorkspaceDialog.tsx +196 -0
  15. package/client/src/components/LoadingSpinner.tsx +57 -0
  16. package/client/src/components/SkillCard.tsx +269 -0
  17. package/client/src/components/Toast.tsx +44 -0
  18. package/client/src/components/Tooltip.tsx +132 -0
  19. package/client/src/index.css +168 -0
  20. package/client/src/lib/api.ts +196 -0
  21. package/client/src/main.tsx +10 -0
  22. package/client/src/pages/Dashboard.tsx +209 -0
  23. package/client/src/pages/Marketplace.tsx +282 -0
  24. package/client/src/pages/Settings.tsx +136 -0
  25. package/client/src/pages/SkillLibrary.tsx +163 -0
  26. package/client/src/pages/Workspaces.tsx +662 -0
  27. package/client/src/stores/appStore.ts +222 -0
  28. package/client/tailwind.config.js +82 -0
  29. package/client/tsconfig.app.json +28 -0
  30. package/client/tsconfig.json +7 -0
  31. package/client/tsconfig.node.json +26 -0
  32. package/client/vite.config.ts +26 -0
  33. package/package.json +34 -0
  34. package/registry/.env.example +5 -0
  35. package/registry/Dockerfile +42 -0
  36. package/registry/docker-compose.yml +33 -0
  37. package/registry/package.json +37 -0
  38. package/registry/prisma/schema.prisma +59 -0
  39. package/registry/src/index.ts +34 -0
  40. package/registry/src/lib/db.ts +3 -0
  41. package/registry/src/middleware/errorHandler.ts +35 -0
  42. package/registry/src/routes/auth.ts +152 -0
  43. package/registry/src/routes/skills.ts +295 -0
  44. package/registry/tsconfig.json +23 -0
  45. package/server/.env.example +11 -0
  46. package/server/package.json +60 -0
  47. package/server/prisma/schema.prisma +73 -0
  48. package/server/public/assets/index-BsYtpZSa.css +1 -0
  49. package/server/public/assets/index-Dfr_6UV8.js +20 -0
  50. package/server/public/index.html +14 -0
  51. package/server/public/vite.svg +1 -0
  52. package/server/src/bin.ts +428 -0
  53. package/server/src/config.ts +39 -0
  54. package/server/src/index.ts +112 -0
  55. package/server/src/lib/db.ts +14 -0
  56. package/server/src/middleware/errorHandler.ts +40 -0
  57. package/server/src/middleware/logger.ts +12 -0
  58. package/server/src/routes/dashboard.ts +102 -0
  59. package/server/src/routes/marketplace.ts +273 -0
  60. package/server/src/routes/skills.ts +294 -0
  61. package/server/src/routes/workspaces.ts +168 -0
  62. package/server/src/services/bundleService.ts +123 -0
  63. package/server/src/services/skillService.ts +722 -0
  64. package/server/src/services/workspaceService.ts +521 -0
  65. package/server/src/verify-sync.ts +91 -0
  66. package/server/tsconfig.json +19 -0
  67. package/server/tsup.config.ts +18 -0
  68. package/shared/package.json +21 -0
  69. package/shared/pnpm-lock.yaml +24 -0
  70. package/shared/src/index.ts +169 -0
  71. package/shared/tsconfig.json +10 -0
  72. 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();