@prompd/cli 0.5.0-beta.1 → 0.5.0-beta.2

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 (52) hide show
  1. package/dist/commands/compile.d.ts.map +1 -1
  2. package/dist/commands/compile.js +8 -1
  3. package/dist/commands/compile.js.map +1 -1
  4. package/dist/commands/package.d.ts.map +1 -1
  5. package/dist/commands/package.js +177 -4
  6. package/dist/commands/package.js.map +1 -1
  7. package/dist/commands/registry.d.ts.map +1 -1
  8. package/dist/commands/registry.js +58 -4
  9. package/dist/commands/registry.js.map +1 -1
  10. package/dist/commands/uninstall.d.ts.map +1 -1
  11. package/dist/commands/uninstall.js +52 -18
  12. package/dist/commands/uninstall.js.map +1 -1
  13. package/dist/lib/compiler/file-system.d.ts.map +1 -1
  14. package/dist/lib/compiler/file-system.js +10 -0
  15. package/dist/lib/compiler/file-system.js.map +1 -1
  16. package/dist/lib/compiler/index.d.ts +1 -0
  17. package/dist/lib/compiler/index.d.ts.map +1 -1
  18. package/dist/lib/compiler/index.js +9 -1
  19. package/dist/lib/compiler/index.js.map +1 -1
  20. package/dist/lib/compiler/package-resolver.d.ts +24 -3
  21. package/dist/lib/compiler/package-resolver.d.ts.map +1 -1
  22. package/dist/lib/compiler/package-resolver.js +112 -27
  23. package/dist/lib/compiler/package-resolver.js.map +1 -1
  24. package/dist/lib/compiler/stages/semantic.d.ts +7 -0
  25. package/dist/lib/compiler/stages/semantic.d.ts.map +1 -1
  26. package/dist/lib/compiler/stages/semantic.js +78 -2
  27. package/dist/lib/compiler/stages/semantic.js.map +1 -1
  28. package/dist/lib/compiler/stages/template.d.ts.map +1 -1
  29. package/dist/lib/compiler/stages/template.js +24 -12
  30. package/dist/lib/compiler/stages/template.js.map +1 -1
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/config.js +14 -2
  33. package/dist/lib/config.js.map +1 -1
  34. package/dist/lib/providers/types.d.ts +4 -0
  35. package/dist/lib/providers/types.d.ts.map +1 -1
  36. package/dist/lib/providers/types.js.map +1 -1
  37. package/dist/lib/registry.d.ts +42 -3
  38. package/dist/lib/registry.d.ts.map +1 -1
  39. package/dist/lib/registry.js +448 -84
  40. package/dist/lib/registry.js.map +1 -1
  41. package/dist/lib/validation.d.ts.map +1 -1
  42. package/dist/lib/validation.js +10 -2
  43. package/dist/lib/validation.js.map +1 -1
  44. package/dist/lib/workflowExecutor.d.ts +2 -0
  45. package/dist/lib/workflowExecutor.d.ts.map +1 -1
  46. package/dist/lib/workflowExecutor.js +11 -3
  47. package/dist/lib/workflowExecutor.js.map +1 -1
  48. package/dist/types/index.d.ts +48 -1
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/dist/types/index.js +54 -1
  51. package/dist/types/index.js.map +1 -1
  52. package/package.json +4 -1
@@ -45,7 +45,10 @@ const tar = __importStar(require("tar"));
45
45
  const events_1 = require("events");
46
46
  const security_1 = require("./security");
47
47
  const config_1 = require("./config");
48
+ const types_1 = require("../types");
48
49
  const file_system_1 = require("./compiler/file-system");
50
+ const package_resolver_1 = require("./compiler/package-resolver");
51
+ const validation_1 = require("./validation");
49
52
  /**
50
53
  * Package Registry Client
51
54
  */
@@ -75,10 +78,25 @@ class RegistryClient extends events_1.EventEmitter {
75
78
  this.ensureCacheDir();
76
79
  }
77
80
  get registryUrl() {
78
- return this.registryConfig.url;
81
+ return this.registryConfig.url.replace(/\/+$/, '');
82
+ }
83
+ /**
84
+ * Encode a package name for safe URL usage.
85
+ * Preserves the @ prefix and / separator in scoped packages (e.g. @scope/name),
86
+ * but encodes all other special characters in each segment.
87
+ */
88
+ encodePackageName(packageName) {
89
+ // Handle scoped packages: @scope/name
90
+ if (packageName.startsWith('@') && packageName.includes('/')) {
91
+ const slashIndex = packageName.indexOf('/');
92
+ const scope = packageName.substring(1, slashIndex);
93
+ const name = packageName.substring(slashIndex + 1);
94
+ return `@${encodeURIComponent(scope)}/${encodeURIComponent(name)}`;
95
+ }
96
+ return encodeURIComponent(packageName);
79
97
  }
80
98
  get authToken() {
81
- return this.registryConfig.token;
99
+ return this.registryConfig.api_key || this.registryConfig.token;
82
100
  }
83
101
  get cacheDir() {
84
102
  return path.join(require('os').homedir(), '.prompd', 'cache');
@@ -101,7 +119,17 @@ class RegistryClient extends events_1.EventEmitter {
101
119
  const path = require('path');
102
120
  const configPath = path.join(os.homedir(), '.prompd', 'config.yaml');
103
121
  // Use env var for registry URL if set (useful for local development)
104
- const registryUrl = process.env.PROMPD_REGISTRY_URL || 'https://registry.prompdhub.ai';
122
+ const defaultUrl = 'https://registry.prompdhub.ai';
123
+ let registryUrl = defaultUrl;
124
+ const envRegistryUrl = process.env.PROMPD_REGISTRY_URL;
125
+ if (envRegistryUrl) {
126
+ if ((0, validation_1.validateRegistryUrl)(envRegistryUrl)) {
127
+ registryUrl = envRegistryUrl;
128
+ }
129
+ else {
130
+ console.warn(`Warning: PROMPD_REGISTRY_URL value is invalid, falling back to default: ${defaultUrl}`);
131
+ }
132
+ }
105
133
  const defaultConfig = {
106
134
  apiKeys: {},
107
135
  customProviders: {},
@@ -121,7 +149,11 @@ class RegistryClient extends events_1.EventEmitter {
121
149
  try {
122
150
  if (fs.existsSync(configPath)) {
123
151
  const configContent = fs.readFileSync(configPath, 'utf-8');
124
- const fileConfig = yaml.parse(configContent);
152
+ const fileConfig = yaml.parse(configContent, { strict: true, maxAliasCount: 64 });
153
+ // Validate parsed config is a plain object
154
+ if (!fileConfig || typeof fileConfig !== 'object' || Array.isArray(fileConfig)) {
155
+ return defaultConfig;
156
+ }
125
157
  // Merge with default config
126
158
  const mergedConfig = { ...defaultConfig, ...fileConfig };
127
159
  // Ensure registry structure exists
@@ -234,8 +266,6 @@ class RegistryClient extends events_1.EventEmitter {
234
266
  */
235
267
  async install(packageName, options = {}) {
236
268
  this.emit('installStart', { packageName, options });
237
- console.log('[RegistryClient.install] Starting install:', packageName);
238
- console.log('[RegistryClient.install] Options:', JSON.stringify(options));
239
269
  try {
240
270
  // Parse package reference if it includes @version
241
271
  // Format: @namespace/package@version
@@ -248,16 +278,18 @@ class RegistryClient extends events_1.EventEmitter {
248
278
  name = packageName.substring(0, lastAtIndex);
249
279
  versionSpec = packageName.substring(lastAtIndex + 1);
250
280
  }
251
- console.log('[RegistryClient.install] Parsed name:', name, 'version:', versionSpec);
252
281
  // Resolve version
253
282
  const resolvedVersion = await this.resolveVersion(name, versionSpec);
254
- console.log('[RegistryClient.install] Resolved version:', resolvedVersion);
255
283
  // Check cache first
256
284
  const cacheKey = `${name}@${resolvedVersion}`;
257
285
  if (!options.skipCache && !options.force) {
258
286
  const cachedPath = await this.getCachedPackage(cacheKey);
259
287
  if (cachedPath) {
260
288
  await this.installFromCache(cachedPath, name, resolvedVersion, options);
289
+ if (!options.global) {
290
+ await this.addWorkspaceDependency(name, resolvedVersion, options.workspaceRoot);
291
+ }
292
+ this.emit('installComplete', { name, version: resolvedVersion });
261
293
  return;
262
294
  }
263
295
  }
@@ -280,16 +312,17 @@ class RegistryClient extends events_1.EventEmitter {
280
312
  }
281
313
  }
282
314
  // Extract and install package
283
- console.log('[RegistryClient.install] Extracting package to workspace...');
284
315
  await this.extractAndInstallPackage(packageData, name, resolvedVersion, options);
285
- console.log('[RegistryClient.install] Extraction complete');
286
316
  // Cache package
287
317
  await this.cachePackage(cacheKey, packageData);
318
+ // Update workspace prompd.json dependencies (skip for global installs)
319
+ if (!options.global) {
320
+ await this.addWorkspaceDependency(name, resolvedVersion, options.workspaceRoot);
321
+ }
288
322
  this.emit('installComplete', {
289
323
  name: name,
290
324
  version: resolvedVersion
291
325
  });
292
- console.log('[RegistryClient.install] Install complete for', name, '@', resolvedVersion);
293
326
  }
294
327
  catch (error) {
295
328
  this.emit('installError', { packageName, error });
@@ -303,11 +336,13 @@ class RegistryClient extends events_1.EventEmitter {
303
336
  try {
304
337
  const searchParams = new URLSearchParams();
305
338
  if (query.query)
306
- searchParams.set('q', query.query);
339
+ searchParams.set('search', query.query);
307
340
  if (query.category)
308
341
  searchParams.set('category', query.category);
309
- if (query.type)
310
- searchParams.set('type', query.type);
342
+ if (query.type) {
343
+ const typeValue = Array.isArray(query.type) ? query.type.join(',') : query.type;
344
+ searchParams.set('type', typeValue);
345
+ }
311
346
  if (query.tags)
312
347
  searchParams.set('tags', query.tags.join(','));
313
348
  if (query.author)
@@ -318,8 +353,7 @@ class RegistryClient extends events_1.EventEmitter {
318
353
  searchParams.set('offset', query.offset.toString());
319
354
  if (query.sort)
320
355
  searchParams.set('sort', query.sort);
321
- // Registry uses /packages?search= endpoint, not /search?q=
322
- const response = await fetch(`${this.registryUrl}/packages?search=${encodeURIComponent(query.query || '')}`, {
356
+ const response = await fetch(`${this.registryUrl}/packages?${searchParams.toString()}`, {
323
357
  headers: this.getAuthHeaders()
324
358
  });
325
359
  if (!response.ok) {
@@ -339,9 +373,10 @@ class RegistryClient extends events_1.EventEmitter {
339
373
  */
340
374
  async getPackageInfo(packageName, version) {
341
375
  try {
376
+ const encodedName = this.encodePackageName(packageName);
342
377
  const url = version
343
- ? `${this.registryUrl}/packages/${packageName}/${version}`
344
- : `${this.registryUrl}/packages/${packageName}`;
378
+ ? `${this.registryUrl}/packages/${encodedName}/${encodeURIComponent(version)}`
379
+ : `${this.registryUrl}/packages/${encodedName}`;
345
380
  const response = await fetch(url, {
346
381
  headers: this.getAuthHeaders()
347
382
  });
@@ -363,7 +398,7 @@ class RegistryClient extends events_1.EventEmitter {
363
398
  */
364
399
  async getPackageVersions(packageName) {
365
400
  try {
366
- const response = await fetch(`${this.registryUrl}/packages/${packageName}/versions`, {
401
+ const response = await fetch(`${this.registryUrl}/packages/${this.encodePackageName(packageName)}/versions`, {
367
402
  headers: this.getAuthHeaders()
368
403
  });
369
404
  if (!response.ok) {
@@ -423,7 +458,7 @@ class RegistryClient extends events_1.EventEmitter {
423
458
  metadata.keywords = metadata.keywords || [];
424
459
  metadata.dependencies = metadata.dependencies || {};
425
460
  metadata.files = metadata.files || ['**/*'];
426
- metadata.type = metadata.type || 'collection';
461
+ metadata.type = metadata.type || 'package';
427
462
  metadata.category = metadata.category || 'general';
428
463
  metadata.tags = metadata.tags || [];
429
464
  metadata.prmdVersion = '0.2.3';
@@ -444,7 +479,7 @@ class RegistryClient extends events_1.EventEmitter {
444
479
  }
445
480
  // Check for required files
446
481
  const requiredFiles = [];
447
- if (metadata.type === 'prompt' && metadata.main) {
482
+ if (metadata.type === 'package' && metadata.main) {
448
483
  requiredFiles.push(metadata.main);
449
484
  }
450
485
  for (const file of requiredFiles) {
@@ -484,8 +519,8 @@ class RegistryClient extends events_1.EventEmitter {
484
519
  formData.append('metadata', JSON.stringify(metadata));
485
520
  formData.append('access', options.access);
486
521
  formData.append('tag', options.tag);
487
- const response = await fetch(`${this.registryUrl}/publish`, {
488
- method: 'POST',
522
+ const response = await fetch(`${this.registryUrl}/packages/${this.encodePackageName(metadata.name)}`, {
523
+ method: 'PUT',
489
524
  headers: this.getAuthHeaders(),
490
525
  body: formData
491
526
  });
@@ -523,7 +558,7 @@ class RegistryClient extends events_1.EventEmitter {
523
558
  }
524
559
  async downloadPackage(packageName, version) {
525
560
  // Registry endpoint format: /packages/@scope/name/download/version
526
- const response = await fetch(`${this.registryUrl}/packages/${packageName}/download/${version}`, {
561
+ const response = await fetch(`${this.registryUrl}/packages/${this.encodePackageName(packageName)}/download/${encodeURIComponent(version)}`, {
527
562
  headers: this.getAuthHeaders()
528
563
  });
529
564
  if (!response.ok) {
@@ -533,7 +568,13 @@ class RegistryClient extends events_1.EventEmitter {
533
568
  const tarballBuffer = Buffer.from(arrayBuffer);
534
569
  // Extract metadata from the .pdpkg (ZIP) file instead of calling getPackageInfo
535
570
  // Try prompd.json first, fall back to manifest.json for older packages
536
- const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
571
+ let AdmZip;
572
+ try {
573
+ AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
574
+ }
575
+ catch {
576
+ throw new Error('adm-zip package is required for package installation. Run: npm install adm-zip');
577
+ }
537
578
  const zip = new AdmZip(tarballBuffer);
538
579
  // Check for prompd.json first (newer format), then manifest.json (legacy)
539
580
  let manifestEntry = zip.getEntry('prompd.json');
@@ -545,6 +586,11 @@ class RegistryClient extends events_1.EventEmitter {
545
586
  }
546
587
  const manifestContent = manifestEntry.getData().toString('utf8');
547
588
  const metadata = JSON.parse(manifestContent);
589
+ // Prevent prototype pollution from untrusted package manifests
590
+ const metadataObj = metadata;
591
+ delete metadataObj['__proto__'];
592
+ delete metadataObj['constructor'];
593
+ delete metadataObj['prototype'];
548
594
  return {
549
595
  tarball: tarballBuffer,
550
596
  metadata
@@ -557,36 +603,154 @@ class RegistryClient extends events_1.EventEmitter {
557
603
  }
558
604
  }
559
605
  async extractAndInstallPackage(packageData, packageName, version, options) {
560
- // Use workspace root from options if provided, otherwise use cwd
561
- const workspaceRoot = options.workspaceRoot || process.cwd();
562
- console.log('[RegistryClient.extractAndInstallPackage] packageName:', packageName);
563
- console.log('[RegistryClient.extractAndInstallPackage] version:', version);
564
- console.log('[RegistryClient.extractAndInstallPackage] workspaceRoot:', workspaceRoot);
565
- console.log('[RegistryClient.extractAndInstallPackage] global:', options.global);
566
- const installDir = options.global
567
- ? path.join(this.cacheDir, 'global', 'packages', packageName, version)
568
- : path.join(workspaceRoot, '.prompd', 'cache', packageName, version);
569
- console.log('[RegistryClient.extractAndInstallPackage] installDir:', installDir);
570
- await fs.ensureDir(installDir);
571
- console.log('[RegistryClient.extractAndInstallPackage] Directory ensured');
572
- // Extract .pdpkg (ZIP archive) using adm-zip
573
- const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
606
+ const workspaceRoot = options.workspaceRoot || (0, package_resolver_1.findProjectRoot)();
607
+ // Determine and validate package type: manifest > options hint > default 'package'
608
+ const rawType = packageData.metadata?.type || options.type || 'package';
609
+ if (!(0, types_1.isValidPackageType)(rawType)) {
610
+ throw new Error(`Invalid package type '${rawType}' in ${packageName}@${version}. Valid types: package, workflow, skill, node-template`);
611
+ }
612
+ const packageType = rawType;
613
+ const typeDir = (0, types_1.getInstallDirForType)(packageType);
614
+ // Load adm-zip for archive operations
615
+ let AdmZip;
616
+ try {
617
+ AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
618
+ }
619
+ catch {
620
+ throw new Error('adm-zip package is required for package installation. Run: npm install adm-zip');
621
+ }
574
622
  const zip = new AdmZip(packageData.tarball);
575
- // Log zip contents
623
+ // Validate ZIP entries: file sizes, decompression bomb, path traversal, null bytes, symlinks
576
624
  const zipEntries = zip.getEntries();
577
- console.log('[RegistryClient.extractAndInstallPackage] ZIP contains', zipEntries.length, 'entries:');
578
- zipEntries.forEach(entry => {
579
- console.log(' -', entry.entryName);
580
- });
625
+ let cumulativeDecompressedSize = 0;
626
+ for (const entry of zipEntries) {
627
+ // Individual file size limit
628
+ if (entry.header.size > RegistryClient.MAX_FILE_SIZE_IN_ZIP) {
629
+ throw new Error(`File too large in package: ${entry.entryName} (${entry.header.size} bytes, max: ${RegistryClient.MAX_FILE_SIZE_IN_ZIP})`);
630
+ }
631
+ // Cumulative decompressed size limit (decompression bomb protection)
632
+ cumulativeDecompressedSize += entry.header.size;
633
+ if (cumulativeDecompressedSize > RegistryClient.MAX_TOTAL_EXTRACTED_SIZE) {
634
+ throw new Error(`Package total decompressed size exceeds limit (${RegistryClient.MAX_TOTAL_EXTRACTED_SIZE} bytes). Possible decompression bomb.`);
635
+ }
636
+ // Compression ratio check per file (decompression bomb detection)
637
+ const compressedSize = entry.header.compressedSize || 1;
638
+ if (compressedSize > 0 && entry.header.size / compressedSize > RegistryClient.MAX_COMPRESSION_RATIO) {
639
+ throw new Error(`Suspicious compression ratio for ${entry.entryName}: ${Math.round(entry.header.size / compressedSize)}:1 (max: ${RegistryClient.MAX_COMPRESSION_RATIO}:1)`);
640
+ }
641
+ // Null byte check in entry names
642
+ if (entry.entryName.includes('\0')) {
643
+ throw new Error(`Security violation: null byte in entry name: ${entry.entryName}`);
644
+ }
645
+ // Symlink check - reject symlink entries
646
+ // In ZIP, external attributes can indicate symlinks (Unix mode with S_IFLNK = 0xA000)
647
+ const externalAttrs = entry.header.attr;
648
+ if (externalAttrs) {
649
+ const unixMode = (externalAttrs >>> 16) & 0xFFFF;
650
+ if ((unixMode & 0xF000) === 0xA000) {
651
+ throw new Error(`Security violation: symlink detected in archive: ${entry.entryName}`);
652
+ }
653
+ }
654
+ // ZIP slip protection: reject path traversal
655
+ const normalized = path.normalize(entry.entryName);
656
+ if (normalized.includes('..') || path.isAbsolute(entry.entryName)) {
657
+ throw new Error(`Security violation: path traversal detected in ${entry.entryName}`);
658
+ }
659
+ }
660
+ const os = require('os');
661
+ // Node-templates: install as .pdpkg archive (not extracted) so template
662
+ // handlers can scan uniformly for .pdpkg files in the templates directory.
663
+ if (packageType === 'node-template') {
664
+ const templatesDir = options.global
665
+ ? path.join(os.homedir(), '.prompd', typeDir)
666
+ : path.join(workspaceRoot, '.prompd', typeDir);
667
+ await fs.ensureDir(templatesDir);
668
+ // Slugify package name for filename: @scope/name -> scope-name
669
+ const slugName = packageName
670
+ .toLowerCase()
671
+ .replace(/[@/]+/g, '-')
672
+ .replace(/[^a-z0-9-]+/g, '-')
673
+ .replace(/^-+|-+$/g, '');
674
+ const pdpkgFileName = `${slugName}-${version}.pdpkg`;
675
+ await fs.writeFile(path.join(templatesDir, pdpkgFileName), packageData.tarball);
676
+ return;
677
+ }
678
+ const installDir = options.global
679
+ ? path.join(os.homedir(), '.prompd', typeDir, packageName, version)
680
+ : path.join(workspaceRoot, '.prompd', typeDir, packageName, version);
681
+ await fs.ensureDir(installDir);
581
682
  zip.extractAllTo(installDir, true);
582
- console.log('[RegistryClient.extractAndInstallPackage] ZIP extracted to:', installDir);
583
- // Verify extraction
584
- const extractedFiles = await fs.readdir(installDir);
585
- console.log('[RegistryClient.extractAndInstallPackage] Extracted files:', extractedFiles);
586
683
  // Write package metadata for cache tracking
587
684
  const metadataPath = path.join(installDir, '.prmdmeta');
588
685
  await fs.writeJson(metadataPath, packageData.metadata, { spaces: 2 });
589
- console.log('[RegistryClient.extractAndInstallPackage] Wrote .prmdmeta');
686
+ // Deploy to tool-native directories if --tools was specified
687
+ if (options.tools && options.tools.length > 0) {
688
+ if (packageType !== 'skill') {
689
+ throw new Error(`--tools flag is only valid for skills, but package type is '${packageType}'`);
690
+ }
691
+ // Track successful deployments for rollback on failure
692
+ const deployedDirs = [];
693
+ try {
694
+ for (const toolName of options.tools) {
695
+ const deployedDir = await this.deploySkillToTool(installDir, packageName, toolName);
696
+ deployedDirs.push(deployedDir);
697
+ }
698
+ }
699
+ catch (deployError) {
700
+ // Rollback successful deployments
701
+ for (const dir of deployedDirs) {
702
+ await fs.remove(dir).catch(() => { });
703
+ }
704
+ throw deployError;
705
+ }
706
+ }
707
+ }
708
+ /**
709
+ * Deploy skill files to a tool-native directory (e.g., ~/.claude/skills/).
710
+ * Copies skill files and writes a reference marker back to the prompd source location.
711
+ * Returns the deployed directory path for rollback support.
712
+ */
713
+ async deploySkillToTool(skillDir, packageName, toolName) {
714
+ const deployDir = (0, types_1.resolveToolDeployDir)(toolName);
715
+ if (!deployDir) {
716
+ throw new Error(`Unknown tool '${toolName}'. Supported tools: ${Object.keys(types_1.TOOL_DEPLOY_DIRS).join(', ')}`);
717
+ }
718
+ // Verify skill source directory exists before copying
719
+ if (!await fs.pathExists(skillDir)) {
720
+ throw new Error(`Skill source directory not found: ${skillDir}`);
721
+ }
722
+ // Create a subdirectory for this skill within the tool's skills directory
723
+ const skillDeployDir = path.join(deployDir, packageName);
724
+ await fs.ensureDir(skillDeployDir);
725
+ // Pre-copy check: reject if source tree contains symlinks (prevent symlink-based attacks)
726
+ await this.rejectSymlinks(skillDir);
727
+ // Copy all files from the install dir to the tool deploy dir (dereference: false to not follow symlinks)
728
+ await fs.copy(skillDir, skillDeployDir, { overwrite: true, dereference: false });
729
+ // Write a reference marker so we know this was deployed by prompd
730
+ const markerPath = path.join(skillDeployDir, '.prompd-source');
731
+ await fs.writeJson(markerPath, {
732
+ source: skillDir,
733
+ deployedAt: new Date().toISOString(),
734
+ tool: toolName,
735
+ }, { spaces: 2 });
736
+ return skillDeployDir;
737
+ }
738
+ /**
739
+ * Recursively walk a directory and reject if any symlinks are found.
740
+ * Prevents symlink-based path traversal attacks during skill deployment.
741
+ */
742
+ async rejectSymlinks(dir) {
743
+ const entries = await fs.readdir(dir);
744
+ for (const entry of entries) {
745
+ const fullPath = path.join(dir, entry);
746
+ const lstat = await fs.lstat(fullPath);
747
+ if (lstat.isSymbolicLink()) {
748
+ throw new Error(`Security violation: symlink detected in package: ${fullPath}`);
749
+ }
750
+ if (lstat.isDirectory()) {
751
+ await this.rejectSymlinks(fullPath);
752
+ }
753
+ }
590
754
  }
591
755
  getAuthHeaders() {
592
756
  const headers = {
@@ -622,18 +786,33 @@ class RegistryClient extends events_1.EventEmitter {
622
786
  return { size: totalSize, files: totalFiles };
623
787
  }
624
788
  matchesFilePatterns(filePath, patterns) {
625
- // Simple glob matching - in production would use proper glob library
626
789
  for (const pattern of patterns) {
627
790
  if (pattern === '**/*' || pattern === '*') {
628
791
  return true;
629
792
  }
630
793
  if (pattern.includes('*')) {
631
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
632
- if (regex.test(filePath)) {
633
- return true;
794
+ // Safely convert glob pattern to regex:
795
+ // 1. Escape all regex metacharacters EXCEPT *
796
+ // 2. Replace ** with a full-path wildcard, and * with single-segment wildcard
797
+ const escaped = pattern.replace(/([.+?^${}()|[\]\\])/g, '\\$1');
798
+ const regexStr = escaped
799
+ .replace(/\*\*/g, '\u0000') // Temporary placeholder for **
800
+ .replace(/\*/g, '[^/]*') // * matches within a single path segment
801
+ .replace(/\u0000/g, '.*'); // ** matches across path segments
802
+ try {
803
+ const regex = new RegExp(`^${regexStr}$`);
804
+ if (regex.test(filePath)) {
805
+ return true;
806
+ }
807
+ }
808
+ catch {
809
+ // If regex construction fails, fall back to exact match
810
+ if (filePath === pattern) {
811
+ return true;
812
+ }
634
813
  }
635
814
  }
636
- else if (filePath === pattern) {
815
+ else if (filePath === pattern || filePath.endsWith('/' + pattern)) {
637
816
  return true;
638
817
  }
639
818
  }
@@ -644,38 +823,191 @@ class RegistryClient extends events_1.EventEmitter {
644
823
  return await fs.pathExists(cachePath) ? cachePath : null;
645
824
  }
646
825
  async installFromCache(cachePath, packageName, version, options) {
647
- console.log('[RegistryClient.installFromCache] Installing from cache:', cachePath);
648
826
  this.emit('installingFromCache', { name: packageName, version });
649
- // Read the cached tarball
650
827
  const tarballBuffer = await fs.readFile(cachePath);
651
- console.log('[RegistryClient.installFromCache] Read tarball:', tarballBuffer.length, 'bytes');
652
- // Extract to the workspace
653
- const packageData = { tarball: tarballBuffer, metadata: { name: packageName, version } };
828
+ // Try to read full metadata from the cached .meta sidecar file
829
+ const metadataPath = cachePath + '.meta';
830
+ let metadata = { name: packageName, version };
831
+ if (await fs.pathExists(metadataPath)) {
832
+ try {
833
+ metadata = await fs.readJson(metadataPath);
834
+ }
835
+ catch {
836
+ // Fall back to minimal metadata if .meta file is corrupt
837
+ }
838
+ }
839
+ // If metadata lacks type (old cache entry), extract it from the ZIP's prompd.json/manifest.json
840
+ if (!metadata.type) {
841
+ try {
842
+ let AdmZip;
843
+ AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
844
+ const zip = new AdmZip(tarballBuffer);
845
+ const manifestEntry = zip.getEntry('prompd.json') || zip.getEntry('manifest.json');
846
+ if (manifestEntry) {
847
+ const manifest = JSON.parse(manifestEntry.getData().toString('utf8'));
848
+ if (manifest.type) {
849
+ metadata.type = manifest.type;
850
+ }
851
+ // Backfill the .meta sidecar so future installs don't need to re-extract
852
+ await fs.writeJson(metadataPath, { ...metadata, ...manifest }, { spaces: 2 }).catch(() => { });
853
+ }
854
+ }
855
+ catch {
856
+ // Non-fatal: type will fall back to options.type or 'package'
857
+ }
858
+ }
859
+ const packageData = { tarball: tarballBuffer, metadata };
654
860
  await this.extractAndInstallPackage(packageData, packageName, version, options);
655
- console.log('[RegistryClient.installFromCache] Extraction complete');
656
861
  }
657
862
  async cachePackage(cacheKey, packageData) {
658
863
  const cachePath = path.join(this.cacheDir, 'packages', cacheKey);
659
864
  await fs.ensureDir(path.dirname(cachePath));
660
865
  await fs.writeFile(cachePath, packageData.tarball);
866
+ // Save full metadata alongside the tarball so cache installs preserve package type
867
+ await fs.writeJson(cachePath + '.meta', packageData.metadata, { spaces: 2 });
868
+ }
869
+ /**
870
+ * Add a dependency to the workspace prompd.json file.
871
+ */
872
+ async addWorkspaceDependency(name, version, workspaceRoot) {
873
+ const root = workspaceRoot || (0, package_resolver_1.findProjectRoot)();
874
+ const prompdJsonPath = path.join(root, 'prompd.json');
875
+ try {
876
+ let prompdJson = {};
877
+ if (await fs.pathExists(prompdJsonPath)) {
878
+ const content = await fs.readFile(prompdJsonPath, 'utf8');
879
+ if (content && content.trim() !== '') {
880
+ prompdJson = JSON.parse(content);
881
+ }
882
+ }
883
+ if (!prompdJson.dependencies || typeof prompdJson.dependencies !== 'object') {
884
+ prompdJson.dependencies = {};
885
+ }
886
+ prompdJson.dependencies[name] = version;
887
+ await fs.writeFile(prompdJsonPath, JSON.stringify(prompdJson, null, 2) + '\n');
888
+ }
889
+ catch {
890
+ // Non-fatal: dependency tracking failure shouldn't block install
891
+ }
892
+ }
893
+ /**
894
+ * Remove a dependency from the workspace prompd.json file.
895
+ */
896
+ async removeWorkspaceDependency(name, workspaceRoot) {
897
+ const root = workspaceRoot || (0, package_resolver_1.findProjectRoot)();
898
+ const prompdJsonPath = path.join(root, 'prompd.json');
899
+ try {
900
+ if (!await fs.pathExists(prompdJsonPath))
901
+ return;
902
+ const content = await fs.readFile(prompdJsonPath, 'utf8');
903
+ if (!content || content.trim() === '')
904
+ return;
905
+ const prompdJson = JSON.parse(content);
906
+ if (!prompdJson.dependencies || typeof prompdJson.dependencies !== 'object')
907
+ return;
908
+ delete prompdJson.dependencies[name];
909
+ await fs.writeFile(prompdJsonPath, JSON.stringify(prompdJson, null, 2) + '\n');
910
+ }
911
+ catch {
912
+ // Non-fatal
913
+ }
914
+ }
915
+ /**
916
+ * Uninstall a package by name, removing installed files and the prompd.json dependency entry.
917
+ * Scans type directories to find the installed location.
918
+ */
919
+ async uninstall(packageName, options = {}) {
920
+ const workspaceRoot = options.workspaceRoot || (0, package_resolver_1.findProjectRoot)();
921
+ const os = require('os');
922
+ const installBase = options.global
923
+ ? path.join(os.homedir(), '.prompd')
924
+ : path.join(workspaceRoot, '.prompd');
925
+ // Parse embedded version from ref (e.g. @scope/name@1.0.0)
926
+ let name = packageName;
927
+ const lastAtIndex = packageName.lastIndexOf('@');
928
+ if (lastAtIndex > 0) {
929
+ name = packageName.substring(0, lastAtIndex);
930
+ }
931
+ let removed = false;
932
+ // Scan all type directories for this package
933
+ for (const [type, dir] of Object.entries(types_1.PACKAGE_TYPE_DIRS)) {
934
+ if (type === 'node-template') {
935
+ // Node-templates are stored as .pdpkg files at the type root
936
+ const templatesDir = path.join(installBase, dir);
937
+ if (!await fs.pathExists(templatesDir))
938
+ continue;
939
+ const entries = await fs.readdir(templatesDir);
940
+ for (const entry of entries) {
941
+ if (!entry.endsWith('.pdpkg'))
942
+ continue;
943
+ // Read manifest from archive to match by name
944
+ const pkgPath = path.join(templatesDir, entry);
945
+ try {
946
+ let AdmZip;
947
+ AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
948
+ const zip = new AdmZip(pkgPath);
949
+ const manifestEntry = zip.getEntry('prompd.json') || zip.getEntry('manifest.json');
950
+ if (!manifestEntry)
951
+ continue;
952
+ const manifest = JSON.parse(manifestEntry.getData().toString('utf8'));
953
+ if (manifest.name === name) {
954
+ await fs.remove(pkgPath);
955
+ removed = true;
956
+ }
957
+ }
958
+ catch {
959
+ // Skip unreadable archives
960
+ }
961
+ }
962
+ }
963
+ else {
964
+ // Standard packages: stored in @scope/name/ or name/ directories
965
+ const pkgDir = path.join(installBase, dir, name);
966
+ if (await fs.pathExists(pkgDir)) {
967
+ await fs.remove(pkgDir);
968
+ removed = true;
969
+ }
970
+ }
971
+ }
972
+ if (!removed) {
973
+ throw new Error(`Package '${name}' is not installed`);
974
+ }
975
+ // Remove from workspace prompd.json dependencies
976
+ if (!options.global) {
977
+ await this.removeWorkspaceDependency(name, workspaceRoot);
978
+ }
979
+ this.emit('uninstallComplete', { name });
661
980
  }
662
981
  /**
663
982
  * Load manifest.json from file system abstraction.
664
983
  * Supports both disk-based and in-memory file systems.
665
984
  */
666
985
  async loadManifestFromFS(packagePath, fileSystem) {
986
+ // Try prompd.json first (current format), fall back to manifest.json (legacy)
987
+ const prompdJsonPath = fileSystem.join(packagePath, 'prompd.json');
667
988
  const manifestPath = fileSystem.join(packagePath, 'manifest.json');
668
- const exists = await Promise.resolve(fileSystem.exists(manifestPath));
669
- if (!exists) {
670
- throw new Error('No manifest.json file found');
989
+ let resolvedPath;
990
+ if (await Promise.resolve(fileSystem.exists(prompdJsonPath))) {
991
+ resolvedPath = prompdJsonPath;
992
+ }
993
+ else if (await Promise.resolve(fileSystem.exists(manifestPath))) {
994
+ resolvedPath = manifestPath;
995
+ }
996
+ else {
997
+ throw new Error('No prompd.json (or legacy manifest.json) file found');
671
998
  }
672
- const content = await Promise.resolve(fileSystem.readFile(manifestPath));
999
+ const content = await Promise.resolve(fileSystem.readFile(resolvedPath));
673
1000
  const manifest = JSON.parse(content);
1001
+ // Prevent prototype pollution from untrusted manifests
1002
+ const manifestObj = manifest;
1003
+ delete manifestObj['__proto__'];
1004
+ delete manifestObj['constructor'];
1005
+ delete manifestObj['prototype'];
674
1006
  // Validate required fields
675
1007
  const required = ['name', 'version', 'description', 'author'];
676
1008
  for (const field of required) {
677
1009
  if (!manifest[field]) {
678
- throw new Error(`Missing required field in manifest.json: ${field}`);
1010
+ throw new Error(`Missing required field in ${path.basename(resolvedPath)}: ${field}`);
679
1011
  }
680
1012
  }
681
1013
  // Validate version format
@@ -687,7 +1019,7 @@ class RegistryClient extends events_1.EventEmitter {
687
1019
  manifest.keywords = manifest.keywords || [];
688
1020
  manifest.dependencies = manifest.dependencies || {};
689
1021
  manifest.files = manifest.files || ['**/*.prmd'];
690
- manifest.type = manifest.type || 'collection';
1022
+ manifest.type = manifest.type || 'package';
691
1023
  manifest.category = manifest.category || 'general';
692
1024
  manifest.tags = manifest.tags || [];
693
1025
  manifest.prompdVersion = manifest.prompdVersion || '0.3.3';
@@ -746,41 +1078,73 @@ class RegistryClient extends events_1.EventEmitter {
746
1078
  }
747
1079
  /**
748
1080
  * Upload package Buffer to registry.
1081
+ * Uses form-data's submit() which handles Content-Length, transport, and piping.
749
1082
  */
750
1083
  async uploadPackageBuffer(tarballBuffer, metadata, options) {
751
1084
  const FormData = require('form-data');
752
1085
  const formData = new FormData();
753
1086
  formData.append('package', tarballBuffer, {
754
1087
  filename: `${metadata.name}-${metadata.version}.pdpkg`,
755
- contentType: 'application/gzip'
1088
+ contentType: 'application/zip'
756
1089
  });
757
1090
  formData.append('metadata', JSON.stringify(metadata));
758
1091
  formData.append('access', options.access);
759
1092
  formData.append('tag', options.tag);
760
- const response = await fetch(`${this.registryUrl}/publish`, {
761
- method: 'POST',
762
- headers: {
763
- ...this.getAuthHeaders(),
764
- ...formData.getHeaders()
765
- },
766
- body: formData
1093
+ const token = options.authToken || this.authToken;
1094
+ const url = new URL(`${this.registryUrl}/packages/${this.encodePackageName(metadata.name)}`);
1095
+ const response = await new Promise((resolve, reject) => {
1096
+ formData.submit({
1097
+ protocol: url.protocol,
1098
+ hostname: url.hostname,
1099
+ port: url.port || undefined,
1100
+ path: url.pathname,
1101
+ method: 'PUT',
1102
+ headers: {
1103
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
1104
+ 'User-Agent': 'prompd-cli/0.5.0'
1105
+ }
1106
+ }, (err, res) => {
1107
+ if (err)
1108
+ return reject(err);
1109
+ const chunks = [];
1110
+ res.on('data', (chunk) => chunks.push(chunk));
1111
+ res.on('error', reject);
1112
+ res.on('end', () => {
1113
+ resolve({ statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') });
1114
+ });
1115
+ });
767
1116
  });
768
- if (!response.ok) {
769
- const errorText = await response.text();
770
- throw new Error(`Publish failed: ${response.status} ${response.statusText} - ${errorText}`);
1117
+ if (response.statusCode < 200 || response.statusCode >= 300) {
1118
+ throw new Error(`Publish failed: ${response.statusCode} - ${response.body}`);
771
1119
  }
772
1120
  }
773
1121
  }
774
1122
  exports.RegistryClient = RegistryClient;
1123
+ RegistryClient.MAX_FILE_SIZE_IN_ZIP = 10 * 1024 * 1024; // 10MB per file
1124
+ RegistryClient.MAX_TOTAL_EXTRACTED_SIZE = 500 * 1024 * 1024; // 500MB total
1125
+ RegistryClient.MAX_COMPRESSION_RATIO = 100; // 100:1 max ratio per file
775
1126
  /**
776
1127
  * Default registry configuration
777
1128
  */
778
- const createDefaultRegistryConfig = () => ({
779
- registryUrl: process.env.PROMPD_REGISTRY_URL || 'https://registry.prompdhub.ai',
780
- authToken: process.env.PROMPD_AUTH_TOKEN,
781
- cacheDir: path.join(require('os').homedir(), '.prmd', 'cache'),
782
- timeout: 30000,
783
- maxPackageSize: 50 * 1024 * 1024 // 50MB
784
- });
1129
+ const createDefaultRegistryConfig = () => {
1130
+ const defaultUrl = 'https://registry.prompdhub.ai';
1131
+ const envUrl = process.env.PROMPD_REGISTRY_URL;
1132
+ let registryUrl = defaultUrl;
1133
+ if (envUrl) {
1134
+ if ((0, validation_1.validateRegistryUrl)(envUrl)) {
1135
+ registryUrl = envUrl;
1136
+ }
1137
+ else {
1138
+ console.warn(`Warning: PROMPD_REGISTRY_URL value is invalid, falling back to default: ${defaultUrl}`);
1139
+ }
1140
+ }
1141
+ return {
1142
+ registryUrl,
1143
+ authToken: process.env.PROMPD_AUTH_TOKEN,
1144
+ cacheDir: path.join(require('os').homedir(), '.prmd', 'cache'),
1145
+ timeout: 30000,
1146
+ maxPackageSize: 50 * 1024 * 1024 // 50MB
1147
+ };
1148
+ };
785
1149
  exports.createDefaultRegistryConfig = createDefaultRegistryConfig;
786
1150
  //# sourceMappingURL=registry.js.map