@push.rocks/smartregistry 1.7.0 → 2.0.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.
@@ -85,7 +85,7 @@ export class RubyGemsRegistry extends BaseRegistry {
85
85
 
86
86
  // Compact Index endpoints
87
87
  if (path === '/versions' && context.method === 'GET') {
88
- return this.handleVersionsFile();
88
+ return this.handleVersionsFile(context);
89
89
  }
90
90
 
91
91
  if (path === '/names' && context.method === 'GET') {
@@ -104,15 +104,30 @@ export class RubyGemsRegistry extends BaseRegistry {
104
104
  return this.handleDownload(downloadMatch[1]);
105
105
  }
106
106
 
107
+ // Legacy specs endpoints (Marshal format)
108
+ if (path === '/specs.4.8.gz' && context.method === 'GET') {
109
+ return this.handleSpecs(false);
110
+ }
111
+
112
+ if (path === '/latest_specs.4.8.gz' && context.method === 'GET') {
113
+ return this.handleSpecs(true);
114
+ }
115
+
116
+ // Quick gemspec endpoint: GET /quick/Marshal.4.8/{gem}-{version}.gemspec.rz
117
+ const quickMatch = path.match(/^\/quick\/Marshal\.4\.8\/(.+)\.gemspec\.rz$/);
118
+ if (quickMatch && context.method === 'GET') {
119
+ return this.handleQuickGemspec(quickMatch[1]);
120
+ }
121
+
107
122
  // API v1 endpoints
108
123
  if (path.startsWith('/api/v1/')) {
109
- return this.handleApiRequest(path.substring(8), context, token);
124
+ return this.handleApiRequest(path.substring(7), context, token);
110
125
  }
111
126
 
112
127
  return {
113
128
  status: 404,
114
129
  headers: { 'Content-Type': 'application/json' },
115
- body: Buffer.from(JSON.stringify({ message: 'Not Found' })),
130
+ body: { error: 'Not Found' },
116
131
  };
117
132
  }
118
133
 
@@ -141,20 +156,36 @@ export class RubyGemsRegistry extends BaseRegistry {
141
156
 
142
157
  /**
143
158
  * Handle /versions endpoint (Compact Index)
159
+ * Supports conditional GET with If-None-Match header
144
160
  */
145
- private async handleVersionsFile(): Promise<IResponse> {
161
+ private async handleVersionsFile(context: IRequestContext): Promise<IResponse> {
146
162
  const content = await this.storage.getRubyGemsVersions();
147
163
 
148
164
  if (!content) {
149
165
  return this.errorResponse(500, 'Versions file not initialized');
150
166
  }
151
167
 
168
+ const etag = `"${await helpers.calculateMD5(content)}"`;
169
+
170
+ // Handle conditional GET with If-None-Match
171
+ const ifNoneMatch = context.headers['if-none-match'] || context.headers['If-None-Match'];
172
+ if (ifNoneMatch && ifNoneMatch === etag) {
173
+ return {
174
+ status: 304,
175
+ headers: {
176
+ 'ETag': etag,
177
+ 'Cache-Control': 'public, max-age=60',
178
+ },
179
+ body: null,
180
+ };
181
+ }
182
+
152
183
  return {
153
184
  status: 200,
154
185
  headers: {
155
186
  'Content-Type': 'text/plain; charset=utf-8',
156
187
  'Cache-Control': 'public, max-age=60',
157
- 'ETag': `"${await helpers.calculateMD5(content)}"`
188
+ 'ETag': etag
158
189
  },
159
190
  body: Buffer.from(content),
160
191
  };
@@ -289,14 +320,23 @@ export class RubyGemsRegistry extends BaseRegistry {
289
320
  return this.errorResponse(400, 'No gem file provided');
290
321
  }
291
322
 
292
- // For now, we expect metadata in query params or headers
293
- // Full implementation would parse .gem file (tar + gzip + Marshal)
294
- const gemName = context.query?.name || context.headers['x-gem-name'];
295
- const version = context.query?.version || context.headers['x-gem-version'];
296
- const platform = context.query?.platform || context.headers['x-gem-platform'];
323
+ // Try to get metadata from query params or headers first
324
+ let gemName = context.query?.name || context.headers['x-gem-name'] as string | undefined;
325
+ let version = context.query?.version || context.headers['x-gem-version'] as string | undefined;
326
+ let platform = context.query?.platform || context.headers['x-gem-platform'] as string | undefined;
327
+
328
+ // If not provided, try to extract from gem binary
329
+ if (!gemName || !version || !platform) {
330
+ const extracted = await helpers.extractGemMetadata(gemData);
331
+ if (extracted) {
332
+ gemName = gemName || extracted.name;
333
+ version = version || extracted.version;
334
+ platform = platform || extracted.platform;
335
+ }
336
+ }
297
337
 
298
338
  if (!gemName || !version) {
299
- return this.errorResponse(400, 'Gem name and version required');
339
+ return this.errorResponse(400, 'Gem name and version required (provide in query, headers, or valid gem format)');
300
340
  }
301
341
 
302
342
  // Validate gem name
@@ -351,13 +391,13 @@ export class RubyGemsRegistry extends BaseRegistry {
351
391
  });
352
392
 
353
393
  return {
354
- status: 200,
394
+ status: 201,
355
395
  headers: { 'Content-Type': 'application/json' },
356
- body: Buffer.from(JSON.stringify({
396
+ body: {
357
397
  message: 'Gem uploaded successfully',
358
398
  name: gemName,
359
399
  version,
360
- })),
400
+ },
361
401
  };
362
402
  } catch (error) {
363
403
  this.logger.log('error', 'Upload failed', { error: (error as Error).message });
@@ -409,10 +449,10 @@ export class RubyGemsRegistry extends BaseRegistry {
409
449
  return {
410
450
  status: 200,
411
451
  headers: { 'Content-Type': 'application/json' },
412
- body: Buffer.from(JSON.stringify({
452
+ body: {
413
453
  success: true,
414
454
  message: 'Gem yanked successfully'
415
- })),
455
+ },
416
456
  };
417
457
  }
418
458
 
@@ -459,10 +499,10 @@ export class RubyGemsRegistry extends BaseRegistry {
459
499
  return {
460
500
  status: 200,
461
501
  headers: { 'Content-Type': 'application/json' },
462
- body: Buffer.from(JSON.stringify({
502
+ body: {
463
503
  success: true,
464
504
  message: 'Gem unyanked successfully'
465
- })),
505
+ },
466
506
  };
467
507
  }
468
508
 
@@ -489,7 +529,7 @@ export class RubyGemsRegistry extends BaseRegistry {
489
529
  'Content-Type': 'application/json',
490
530
  'Cache-Control': 'public, max-age=300'
491
531
  },
492
- body: Buffer.from(JSON.stringify(response)),
532
+ body: response,
493
533
  };
494
534
  }
495
535
 
@@ -517,7 +557,7 @@ export class RubyGemsRegistry extends BaseRegistry {
517
557
  return {
518
558
  status: 200,
519
559
  headers: { 'Content-Type': 'application/json' },
520
- body: Buffer.from(JSON.stringify(response)),
560
+ body: response,
521
561
  };
522
562
  }
523
563
 
@@ -584,15 +624,109 @@ export class RubyGemsRegistry extends BaseRegistry {
584
624
  }
585
625
  }
586
626
 
627
+ /**
628
+ * Handle /specs.4.8.gz and /latest_specs.4.8.gz endpoints
629
+ * Returns gzipped Marshal array of [name, version, platform] tuples
630
+ * @param latestOnly - If true, only return latest version of each gem
631
+ */
632
+ private async handleSpecs(latestOnly: boolean): Promise<IResponse> {
633
+ try {
634
+ const names = await this.storage.getRubyGemsNames();
635
+ if (!names) {
636
+ return {
637
+ status: 200,
638
+ headers: {
639
+ 'Content-Type': 'application/octet-stream',
640
+ },
641
+ body: await helpers.generateSpecsGz([]),
642
+ };
643
+ }
644
+
645
+ const gemNames = names.split('\n').filter(l => l && l !== '---');
646
+ const specs: Array<[string, string, string]> = [];
647
+
648
+ for (const gemName of gemNames) {
649
+ const metadata = await this.storage.getRubyGemsMetadata(gemName);
650
+ if (!metadata) continue;
651
+
652
+ const versions = (Object.values(metadata.versions) as IRubyGemsVersionMetadata[])
653
+ .filter(v => !v.yanked)
654
+ .sort((a, b) => {
655
+ // Sort by version descending
656
+ return b.version.localeCompare(a.version, undefined, { numeric: true });
657
+ });
658
+
659
+ if (latestOnly && versions.length > 0) {
660
+ // Only include latest version
661
+ const latest = versions[0];
662
+ specs.push([gemName, latest.version, latest.platform || 'ruby']);
663
+ } else {
664
+ // Include all versions
665
+ for (const v of versions) {
666
+ specs.push([gemName, v.version, v.platform || 'ruby']);
667
+ }
668
+ }
669
+ }
670
+
671
+ const gzippedSpecs = await helpers.generateSpecsGz(specs);
672
+
673
+ return {
674
+ status: 200,
675
+ headers: {
676
+ 'Content-Type': 'application/octet-stream',
677
+ },
678
+ body: gzippedSpecs,
679
+ };
680
+ } catch (error) {
681
+ this.logger.log('error', 'Failed to generate specs', { error: (error as Error).message });
682
+ return this.errorResponse(500, 'Failed to generate specs');
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Handle /quick/Marshal.4.8/{gem}-{version}.gemspec.rz endpoint
688
+ * Returns compressed gemspec for a specific gem version
689
+ * @param gemVersionStr - Gem name and version string (e.g., "rails-7.0.0" or "rails-7.0.0-x86_64-linux")
690
+ */
691
+ private async handleQuickGemspec(gemVersionStr: string): Promise<IResponse> {
692
+ // Parse the gem-version string
693
+ const parsed = helpers.parseGemFilename(gemVersionStr + '.gem');
694
+ if (!parsed) {
695
+ return this.errorResponse(400, 'Invalid gemspec path');
696
+ }
697
+
698
+ const metadata = await this.storage.getRubyGemsMetadata(parsed.name);
699
+ if (!metadata) {
700
+ return this.errorResponse(404, 'Gem not found');
701
+ }
702
+
703
+ const versionKey = parsed.platform ? `${parsed.version}-${parsed.platform}` : parsed.version;
704
+ const versionMeta = metadata.versions[versionKey];
705
+ if (!versionMeta) {
706
+ return this.errorResponse(404, 'Version not found');
707
+ }
708
+
709
+ // Generate a minimal gemspec representation
710
+ const gemspecData = await helpers.generateGemspecRz(parsed.name, versionMeta);
711
+
712
+ return {
713
+ status: 200,
714
+ headers: {
715
+ 'Content-Type': 'application/octet-stream',
716
+ },
717
+ body: gemspecData,
718
+ };
719
+ }
720
+
587
721
  /**
588
722
  * Helper: Create error response
589
723
  */
590
724
  private errorResponse(status: number, message: string): IResponse {
591
- const error: IRubyGemsError = { message, status };
725
+ const error: IRubyGemsError = { error: message, status };
592
726
  return {
593
727
  status,
594
728
  headers: { 'Content-Type': 'application/json' },
595
- body: Buffer.from(JSON.stringify(error)),
729
+ body: error,
596
730
  };
597
731
  }
598
732
  }
@@ -3,6 +3,8 @@
3
3
  * Compact Index generation, dependency formatting, etc.
4
4
  */
5
5
 
6
+ import * as plugins from '../plugins.js';
7
+
6
8
  import type {
7
9
  IRubyGemsVersion,
8
10
  IRubyGemsDependency,
@@ -396,3 +398,176 @@ export async function extractGemSpec(gemData: Buffer): Promise<any | null> {
396
398
  return null;
397
399
  }
398
400
  }
401
+
402
+ /**
403
+ * Extract basic metadata from a gem file
404
+ * Gem files are plain tar archives (NOT gzipped) containing:
405
+ * - metadata.gz: gzipped YAML with gem specification
406
+ * - data.tar.gz: gzipped tar with actual gem files
407
+ * This function extracts and parses the metadata.gz to get name/version/platform
408
+ * @param gemData - Gem file data
409
+ * @returns Extracted metadata or null
410
+ */
411
+ export async function extractGemMetadata(gemData: Buffer): Promise<{
412
+ name: string;
413
+ version: string;
414
+ platform?: string;
415
+ } | null> {
416
+ try {
417
+ // Step 1: Extract the plain tar archive to get metadata.gz
418
+ const smartArchive = plugins.smartarchive.SmartArchive.create();
419
+ const files = await smartArchive.buffer(gemData).toSmartFiles();
420
+
421
+ // Find metadata.gz
422
+ const metadataFile = files.find(f => f.path === 'metadata.gz' || f.relative === 'metadata.gz');
423
+ if (!metadataFile) {
424
+ return null;
425
+ }
426
+
427
+ // Step 2: Decompress the gzipped metadata
428
+ const gzipTools = new plugins.smartarchive.GzipTools();
429
+ const metadataYaml = await gzipTools.decompress(metadataFile.contentBuffer);
430
+ const yamlContent = metadataYaml.toString('utf-8');
431
+
432
+ // Step 3: Parse the YAML to extract name, version, platform
433
+ // Look for name: field in YAML
434
+ const nameMatch = yamlContent.match(/name:\s*([^\n\r]+)/);
435
+
436
+ // Look for version in Ruby YAML format: version: !ruby/object:Gem::Version\n version: X.X.X
437
+ const versionMatch = yamlContent.match(/version:\s*!ruby\/object:Gem::Version[\s\S]*?version:\s*['"]?([^'"\n\r]+)/);
438
+
439
+ // Also try simpler version format
440
+ const simpleVersionMatch = !versionMatch ? yamlContent.match(/^version:\s*['"]?(\d[^'"\n\r]*)/m) : null;
441
+
442
+ // Look for platform
443
+ const platformMatch = yamlContent.match(/platform:\s*([^\n\r]+)/);
444
+
445
+ const name = nameMatch?.[1]?.trim();
446
+ const version = versionMatch?.[1]?.trim() || simpleVersionMatch?.[1]?.trim();
447
+ const platform = platformMatch?.[1]?.trim();
448
+
449
+ if (name && version) {
450
+ return {
451
+ name,
452
+ version,
453
+ platform: platform && platform !== 'ruby' ? platform : undefined,
454
+ };
455
+ }
456
+
457
+ return null;
458
+ } catch (error) {
459
+ // Log error for debugging but return null gracefully
460
+ console.error('Failed to extract gem metadata:', error);
461
+ return null;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Generate gzipped specs array for /specs.4.8.gz and /latest_specs.4.8.gz
467
+ * The format is a gzipped Ruby Marshal array of [name, version, platform] tuples
468
+ * Since we can't easily generate Ruby Marshal format, we'll use a simple format
469
+ * that represents the same data structure as a gzipped binary blob
470
+ * @param specs - Array of [name, version, platform] tuples
471
+ * @returns Gzipped specs data
472
+ */
473
+ export async function generateSpecsGz(specs: Array<[string, string, string]>): Promise<Buffer> {
474
+ const gzipTools = new plugins.smartarchive.GzipTools();
475
+
476
+ // Create a simplified binary representation
477
+ // Real RubyGems uses Ruby Marshal format, but for compatibility we'll create
478
+ // a gzipped representation that tools can recognize as valid
479
+
480
+ // Format: Simple binary encoding of specs array
481
+ // Each spec: name_length(2 bytes) + name + version_length(2 bytes) + version + platform_length(2 bytes) + platform
482
+ const parts: Buffer[] = [];
483
+
484
+ // Header: number of specs (4 bytes)
485
+ const headerBuf = Buffer.alloc(4);
486
+ headerBuf.writeUInt32LE(specs.length, 0);
487
+ parts.push(headerBuf);
488
+
489
+ for (const [name, version, platform] of specs) {
490
+ const nameBuf = Buffer.from(name, 'utf-8');
491
+ const versionBuf = Buffer.from(version, 'utf-8');
492
+ const platformBuf = Buffer.from(platform, 'utf-8');
493
+
494
+ const nameLenBuf = Buffer.alloc(2);
495
+ nameLenBuf.writeUInt16LE(nameBuf.length, 0);
496
+
497
+ const versionLenBuf = Buffer.alloc(2);
498
+ versionLenBuf.writeUInt16LE(versionBuf.length, 0);
499
+
500
+ const platformLenBuf = Buffer.alloc(2);
501
+ platformLenBuf.writeUInt16LE(platformBuf.length, 0);
502
+
503
+ parts.push(nameLenBuf, nameBuf, versionLenBuf, versionBuf, platformLenBuf, platformBuf);
504
+ }
505
+
506
+ const uncompressed = Buffer.concat(parts);
507
+ return gzipTools.compress(uncompressed);
508
+ }
509
+
510
+ /**
511
+ * Generate compressed gemspec for /quick/Marshal.4.8/{gem}-{version}.gemspec.rz
512
+ * The format is a zlib-compressed Ruby Marshal representation of the gemspec
513
+ * Since we can't easily generate Ruby Marshal, we'll create a simplified format
514
+ * @param name - Gem name
515
+ * @param versionMeta - Version metadata
516
+ * @returns Zlib-compressed gemspec data
517
+ */
518
+ export async function generateGemspecRz(
519
+ name: string,
520
+ versionMeta: {
521
+ version: string;
522
+ platform?: string;
523
+ checksum: string;
524
+ dependencies?: Array<{ name: string; requirement: string }>;
525
+ }
526
+ ): Promise<Buffer> {
527
+ const zlib = await import('zlib');
528
+ const { promisify } = await import('util');
529
+ const deflate = promisify(zlib.deflate);
530
+
531
+ // Create a YAML-like representation that can be parsed
532
+ const gemspecYaml = `--- !ruby/object:Gem::Specification
533
+ name: ${name}
534
+ version: !ruby/object:Gem::Version
535
+ version: ${versionMeta.version}
536
+ platform: ${versionMeta.platform || 'ruby'}
537
+ authors: []
538
+ date: ${new Date().toISOString().split('T')[0]}
539
+ dependencies: []
540
+ description:
541
+ email:
542
+ executables: []
543
+ extensions: []
544
+ extra_rdoc_files: []
545
+ files: []
546
+ homepage:
547
+ licenses: []
548
+ metadata: {}
549
+ post_install_message:
550
+ rdoc_options: []
551
+ require_paths:
552
+ - lib
553
+ required_ruby_version: !ruby/object:Gem::Requirement
554
+ requirements:
555
+ - - ">="
556
+ - !ruby/object:Gem::Version
557
+ version: '0'
558
+ required_rubygems_version: !ruby/object:Gem::Requirement
559
+ requirements:
560
+ - - ">="
561
+ - !ruby/object:Gem::Version
562
+ version: '0'
563
+ requirements: []
564
+ rubygems_version: 3.0.0
565
+ signing_key:
566
+ specification_version: 4
567
+ summary:
568
+ test_files: []
569
+ `;
570
+
571
+ // Use zlib deflate (not gzip) for .rz files
572
+ return deflate(Buffer.from(gemspecYaml, 'utf-8'));
573
+ }
@@ -211,7 +211,7 @@ export interface IRubyGemsDependenciesResponse {
211
211
  */
212
212
  export interface IRubyGemsError {
213
213
  /** Error message */
214
- message: string;
214
+ error: string;
215
215
  /** HTTP status code */
216
216
  status?: number;
217
217
  }