import * as plugins from '../plugins.js'; import type { IStorageConfig, IStorageBackend } from './interfaces.core.js'; /** * Storage abstraction layer for registry * Provides a unified interface over SmartBucket */ export class RegistryStorage implements IStorageBackend { private smartBucket: plugins.smartbucket.SmartBucket; private bucket: plugins.smartbucket.Bucket; private bucketName: string; constructor(private config: IStorageConfig) { this.bucketName = config.bucketName; } /** * Initialize the storage backend */ public async init(): Promise { // Pass config as IS3Descriptor to SmartBucket (bucketName is extra, SmartBucket ignores it) this.smartBucket = new plugins.smartbucket.SmartBucket(this.config as plugins.tsclass.storage.IS3Descriptor); // Ensure bucket exists await this.smartBucket.createBucket(this.bucketName).catch(() => { // Bucket may already exist }); this.bucket = await this.smartBucket.getBucketByName(this.bucketName); } /** * Get an object from storage */ public async getObject(key: string): Promise { try { return await this.bucket.fastGet({ path: key }); } catch (error) { return null; } } /** * Store an object */ public async putObject( key: string, data: Buffer, metadata?: Record ): Promise { // Note: SmartBucket doesn't support metadata yet await this.bucket.fastPut({ path: key, contents: data, overwrite: true, // Always overwrite existing objects }); } /** * Delete an object */ public async deleteObject(key: string): Promise { await this.bucket.fastRemove({ path: key }); } /** * List objects with a prefix (recursively) */ public async listObjects(prefix: string): Promise { const paths: string[] = []; for await (const path of this.bucket.listAllObjects(prefix)) { paths.push(path); } return paths; } /** * Check if an object exists */ public async objectExists(key: string): Promise { return await this.bucket.fastExists({ path: key }); } /** * Get object metadata * Note: SmartBucket may not support metadata retrieval, returning empty object */ public async getMetadata(key: string): Promise | null> { // SmartBucket doesn't expose metadata retrieval directly // This is a limitation we'll document const exists = await this.objectExists(key); return exists ? {} : null; } // ======================================================================== // OCI-SPECIFIC HELPERS // ======================================================================== /** * Get OCI blob by digest */ public async getOciBlob(digest: string): Promise { const path = this.getOciBlobPath(digest); return this.getObject(path); } /** * Store OCI blob */ public async putOciBlob(digest: string, data: Buffer): Promise { const path = this.getOciBlobPath(digest); return this.putObject(path, data); } /** * Check if OCI blob exists */ public async ociBlobExists(digest: string): Promise { const path = this.getOciBlobPath(digest); return this.objectExists(path); } /** * Delete OCI blob */ public async deleteOciBlob(digest: string): Promise { const path = this.getOciBlobPath(digest); return this.deleteObject(path); } /** * Get OCI manifest */ public async getOciManifest(repository: string, digest: string): Promise { const path = this.getOciManifestPath(repository, digest); return this.getObject(path); } /** * Store OCI manifest */ public async putOciManifest( repository: string, digest: string, data: Buffer, contentType: string ): Promise { const path = this.getOciManifestPath(repository, digest); return this.putObject(path, data, { 'Content-Type': contentType }); } /** * Check if OCI manifest exists */ public async ociManifestExists(repository: string, digest: string): Promise { const path = this.getOciManifestPath(repository, digest); return this.objectExists(path); } /** * Delete OCI manifest */ public async deleteOciManifest(repository: string, digest: string): Promise { const path = this.getOciManifestPath(repository, digest); return this.deleteObject(path); } // ======================================================================== // NPM-SPECIFIC HELPERS // ======================================================================== /** * Get NPM packument (package document) */ public async getNpmPackument(packageName: string): Promise { const path = this.getNpmPackumentPath(packageName); const data = await this.getObject(path); return data ? JSON.parse(data.toString('utf-8')) : null; } /** * Store NPM packument */ public async putNpmPackument(packageName: string, packument: any): Promise { const path = this.getNpmPackumentPath(packageName); const data = Buffer.from(JSON.stringify(packument, null, 2), 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'application/json' }); } /** * Check if NPM packument exists */ public async npmPackumentExists(packageName: string): Promise { const path = this.getNpmPackumentPath(packageName); return this.objectExists(path); } /** * Delete NPM packument */ public async deleteNpmPackument(packageName: string): Promise { const path = this.getNpmPackumentPath(packageName); return this.deleteObject(path); } /** * Get NPM tarball */ public async getNpmTarball(packageName: string, version: string): Promise { const path = this.getNpmTarballPath(packageName, version); return this.getObject(path); } /** * Store NPM tarball */ public async putNpmTarball( packageName: string, version: string, tarball: Buffer ): Promise { const path = this.getNpmTarballPath(packageName, version); return this.putObject(path, tarball, { 'Content-Type': 'application/octet-stream' }); } /** * Check if NPM tarball exists */ public async npmTarballExists(packageName: string, version: string): Promise { const path = this.getNpmTarballPath(packageName, version); return this.objectExists(path); } /** * Delete NPM tarball */ public async deleteNpmTarball(packageName: string, version: string): Promise { const path = this.getNpmTarballPath(packageName, version); return this.deleteObject(path); } // ======================================================================== // PATH HELPERS // ======================================================================== private getOciBlobPath(digest: string): string { const hash = digest.split(':')[1]; return `oci/blobs/sha256/${hash}`; } private getOciManifestPath(repository: string, digest: string): string { const hash = digest.split(':')[1]; return `oci/manifests/${repository}/${hash}`; } private getNpmPackumentPath(packageName: string): string { return `npm/packages/${packageName}/index.json`; } private getNpmTarballPath(packageName: string, version: string): string { const safeName = packageName.replace('@', '').replace('/', '-'); return `npm/packages/${packageName}/${safeName}-${version}.tgz`; } // ======================================================================== // MAVEN STORAGE METHODS // ======================================================================== /** * Get Maven artifact */ public async getMavenArtifact( groupId: string, artifactId: string, version: string, filename: string ): Promise { const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); return this.getObject(path); } /** * Store Maven artifact */ public async putMavenArtifact( groupId: string, artifactId: string, version: string, filename: string, data: Buffer ): Promise { const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); return this.putObject(path, data); } /** * Check if Maven artifact exists */ public async mavenArtifactExists( groupId: string, artifactId: string, version: string, filename: string ): Promise { const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); return this.objectExists(path); } /** * Delete Maven artifact */ public async deleteMavenArtifact( groupId: string, artifactId: string, version: string, filename: string ): Promise { const path = this.getMavenArtifactPath(groupId, artifactId, version, filename); return this.deleteObject(path); } /** * Get Maven metadata (maven-metadata.xml) */ public async getMavenMetadata( groupId: string, artifactId: string ): Promise { const path = this.getMavenMetadataPath(groupId, artifactId); return this.getObject(path); } /** * Store Maven metadata (maven-metadata.xml) */ public async putMavenMetadata( groupId: string, artifactId: string, data: Buffer ): Promise { const path = this.getMavenMetadataPath(groupId, artifactId); return this.putObject(path, data); } /** * Delete Maven metadata (maven-metadata.xml) */ public async deleteMavenMetadata( groupId: string, artifactId: string ): Promise { const path = this.getMavenMetadataPath(groupId, artifactId); return this.deleteObject(path); } /** * List Maven versions for an artifact * Returns all version directories under the artifact path */ public async listMavenVersions( groupId: string, artifactId: string ): Promise { const groupPath = groupId.replace(/\./g, '/'); const prefix = `maven/artifacts/${groupPath}/${artifactId}/`; const objects = await this.listObjects(prefix); const versions = new Set(); // Extract version from paths like: maven/artifacts/com/example/my-lib/1.0.0/my-lib-1.0.0.jar for (const obj of objects) { const relativePath = obj.substring(prefix.length); const parts = relativePath.split('/'); if (parts.length >= 1 && parts[0]) { versions.add(parts[0]); } } return Array.from(versions).sort(); } // ======================================================================== // MAVEN PATH HELPERS // ======================================================================== private getMavenArtifactPath( groupId: string, artifactId: string, version: string, filename: string ): string { const groupPath = groupId.replace(/\./g, '/'); return `maven/artifacts/${groupPath}/${artifactId}/${version}/${filename}`; } private getMavenMetadataPath(groupId: string, artifactId: string): string { const groupPath = groupId.replace(/\./g, '/'); return `maven/metadata/${groupPath}/${artifactId}/maven-metadata.xml`; } // ======================================================================== // CARGO-SPECIFIC HELPERS // ======================================================================== /** * Get Cargo config.json */ public async getCargoConfig(): Promise { const data = await this.getObject('cargo/config.json'); return data ? JSON.parse(data.toString('utf-8')) : null; } /** * Store Cargo config.json */ public async putCargoConfig(config: any): Promise { const data = Buffer.from(JSON.stringify(config, null, 2), 'utf-8'); return this.putObject('cargo/config.json', data, { 'Content-Type': 'application/json' }); } /** * Get Cargo index file (newline-delimited JSON) */ public async getCargoIndex(crateName: string): Promise { const path = this.getCargoIndexPath(crateName); const data = await this.getObject(path); if (!data) return null; // Parse newline-delimited JSON const lines = data.toString('utf-8').split('\n').filter(line => line.trim()); return lines.map(line => JSON.parse(line)); } /** * Store Cargo index file */ public async putCargoIndex(crateName: string, entries: any[]): Promise { const path = this.getCargoIndexPath(crateName); // Convert to newline-delimited JSON const data = Buffer.from(entries.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'text/plain' }); } /** * Get Cargo .crate file */ public async getCargoCrate(crateName: string, version: string): Promise { const path = this.getCargoCratePath(crateName, version); return this.getObject(path); } /** * Store Cargo .crate file */ public async putCargoCrate( crateName: string, version: string, crateFile: Buffer ): Promise { const path = this.getCargoCratePath(crateName, version); return this.putObject(path, crateFile, { 'Content-Type': 'application/gzip' }); } /** * Check if Cargo crate exists */ public async cargoCrateExists(crateName: string, version: string): Promise { const path = this.getCargoCratePath(crateName, version); return this.objectExists(path); } /** * Delete Cargo crate (for cleanup, not for unpublishing) */ public async deleteCargoCrate(crateName: string, version: string): Promise { const path = this.getCargoCratePath(crateName, version); return this.deleteObject(path); } // ======================================================================== // CARGO PATH HELPERS // ======================================================================== private getCargoIndexPath(crateName: string): string { const lower = crateName.toLowerCase(); const len = lower.length; if (len === 1) { return `cargo/index/1/${lower}`; } else if (len === 2) { return `cargo/index/2/${lower}`; } else if (len === 3) { return `cargo/index/3/${lower.charAt(0)}/${lower}`; } else { // 4+ characters: {first-two}/{second-two}/{name} const prefix1 = lower.substring(0, 2); const prefix2 = lower.substring(2, 4); return `cargo/index/${prefix1}/${prefix2}/${lower}`; } } private getCargoCratePath(crateName: string, version: string): string { return `cargo/crates/${crateName}/${crateName}-${version}.crate`; } // ======================================================================== // COMPOSER-SPECIFIC HELPERS // ======================================================================== /** * Get Composer package metadata */ public async getComposerPackageMetadata(vendorPackage: string): Promise { const path = this.getComposerMetadataPath(vendorPackage); const data = await this.getObject(path); return data ? JSON.parse(data.toString('utf-8')) : null; } /** * Store Composer package metadata */ public async putComposerPackageMetadata(vendorPackage: string, metadata: any): Promise { const path = this.getComposerMetadataPath(vendorPackage); const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'application/json' }); } /** * Get Composer package ZIP */ public async getComposerPackageZip(vendorPackage: string, reference: string): Promise { const path = this.getComposerZipPath(vendorPackage, reference); return this.getObject(path); } /** * Store Composer package ZIP */ public async putComposerPackageZip(vendorPackage: string, reference: string, zipData: Buffer): Promise { const path = this.getComposerZipPath(vendorPackage, reference); return this.putObject(path, zipData, { 'Content-Type': 'application/zip' }); } /** * Check if Composer package metadata exists */ public async composerPackageMetadataExists(vendorPackage: string): Promise { const path = this.getComposerMetadataPath(vendorPackage); return this.objectExists(path); } /** * Delete Composer package metadata */ public async deleteComposerPackageMetadata(vendorPackage: string): Promise { const path = this.getComposerMetadataPath(vendorPackage); return this.deleteObject(path); } /** * Delete Composer package ZIP */ public async deleteComposerPackageZip(vendorPackage: string, reference: string): Promise { const path = this.getComposerZipPath(vendorPackage, reference); return this.deleteObject(path); } /** * List all Composer packages */ public async listComposerPackages(): Promise { const prefix = 'composer/packages/'; const objects = await this.listObjects(prefix); const packages = new Set(); // Extract vendor/package from paths like: composer/packages/vendor/package/metadata.json for (const obj of objects) { const match = obj.match(/^composer\/packages\/([^\/]+\/[^\/]+)\/metadata\.json$/); if (match) { packages.add(match[1]); } } return Array.from(packages).sort(); } // ======================================================================== // COMPOSER PATH HELPERS // ======================================================================== private getComposerMetadataPath(vendorPackage: string): string { return `composer/packages/${vendorPackage}/metadata.json`; } private getComposerZipPath(vendorPackage: string, reference: string): string { return `composer/packages/${vendorPackage}/${reference}.zip`; } // ======================================================================== // PYPI STORAGE METHODS // ======================================================================== /** * Get PyPI package metadata */ public async getPypiPackageMetadata(packageName: string): Promise { const path = this.getPypiMetadataPath(packageName); const data = await this.getObject(path); return data ? JSON.parse(data.toString('utf-8')) : null; } /** * Store PyPI package metadata */ public async putPypiPackageMetadata(packageName: string, metadata: any): Promise { const path = this.getPypiMetadataPath(packageName); const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'application/json' }); } /** * Check if PyPI package metadata exists */ public async pypiPackageMetadataExists(packageName: string): Promise { const path = this.getPypiMetadataPath(packageName); return this.objectExists(path); } /** * Delete PyPI package metadata */ public async deletePypiPackageMetadata(packageName: string): Promise { const path = this.getPypiMetadataPath(packageName); return this.deleteObject(path); } /** * Get PyPI Simple API index (HTML) */ public async getPypiSimpleIndex(packageName: string): Promise { const path = this.getPypiSimpleIndexPath(packageName); const data = await this.getObject(path); return data ? data.toString('utf-8') : null; } /** * Store PyPI Simple API index (HTML) */ public async putPypiSimpleIndex(packageName: string, html: string): Promise { const path = this.getPypiSimpleIndexPath(packageName); const data = Buffer.from(html, 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' }); } /** * Get PyPI root Simple API index (HTML) */ public async getPypiSimpleRootIndex(): Promise { const path = this.getPypiSimpleRootIndexPath(); const data = await this.getObject(path); return data ? data.toString('utf-8') : null; } /** * Store PyPI root Simple API index (HTML) */ public async putPypiSimpleRootIndex(html: string): Promise { const path = this.getPypiSimpleRootIndexPath(); const data = Buffer.from(html, 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'text/html; charset=utf-8' }); } /** * Get PyPI package file (wheel, sdist) */ public async getPypiPackageFile(packageName: string, filename: string): Promise { const path = this.getPypiPackageFilePath(packageName, filename); return this.getObject(path); } /** * Store PyPI package file (wheel, sdist) */ public async putPypiPackageFile( packageName: string, filename: string, data: Buffer ): Promise { const path = this.getPypiPackageFilePath(packageName, filename); return this.putObject(path, data, { 'Content-Type': 'application/octet-stream' }); } /** * Check if PyPI package file exists */ public async pypiPackageFileExists(packageName: string, filename: string): Promise { const path = this.getPypiPackageFilePath(packageName, filename); return this.objectExists(path); } /** * Delete PyPI package file */ public async deletePypiPackageFile(packageName: string, filename: string): Promise { const path = this.getPypiPackageFilePath(packageName, filename); return this.deleteObject(path); } /** * List all PyPI packages */ public async listPypiPackages(): Promise { const prefix = 'pypi/metadata/'; const objects = await this.listObjects(prefix); const packages = new Set(); // Extract package names from paths like: pypi/metadata/package-name/metadata.json for (const obj of objects) { const match = obj.match(/^pypi\/metadata\/([^\/]+)\/metadata\.json$/); if (match) { packages.add(match[1]); } } return Array.from(packages).sort(); } /** * List all versions of a PyPI package */ public async listPypiPackageVersions(packageName: string): Promise { const prefix = `pypi/packages/${packageName}/`; const objects = await this.listObjects(prefix); const versions = new Set(); // Extract versions from filenames for (const obj of objects) { const filename = obj.split('/').pop(); if (!filename) continue; // Extract version from wheel filename: package-1.0.0-py3-none-any.whl // or sdist filename: package-1.0.0.tar.gz const wheelMatch = filename.match(/^[^-]+-([^-]+)-.*\.whl$/); const sdistMatch = filename.match(/^[^-]+-([^.]+)\.(tar\.gz|zip)$/); if (wheelMatch) versions.add(wheelMatch[1]); else if (sdistMatch) versions.add(sdistMatch[1]); } return Array.from(versions).sort(); } /** * Delete entire PyPI package (all versions and files) */ public async deletePypiPackage(packageName: string): Promise { // Delete metadata await this.deletePypiPackageMetadata(packageName); // Delete Simple API index const simpleIndexPath = this.getPypiSimpleIndexPath(packageName); try { await this.deleteObject(simpleIndexPath); } catch (error) { // Ignore if doesn't exist } // Delete all package files const prefix = `pypi/packages/${packageName}/`; const objects = await this.listObjects(prefix); for (const obj of objects) { await this.deleteObject(obj); } } /** * Delete specific version of a PyPI package */ public async deletePypiPackageVersion(packageName: string, version: string): Promise { const prefix = `pypi/packages/${packageName}/`; const objects = await this.listObjects(prefix); // Delete all files matching this version for (const obj of objects) { const filename = obj.split('/').pop(); if (!filename) continue; // Check if filename contains this version const wheelMatch = filename.match(/^[^-]+-([^-]+)-.*\.whl$/); const sdistMatch = filename.match(/^[^-]+-([^.]+)\.(tar\.gz|zip)$/); const fileVersion = wheelMatch?.[1] || sdistMatch?.[1]; if (fileVersion === version) { await this.deleteObject(obj); } } // Update metadata to remove this version const metadata = await this.getPypiPackageMetadata(packageName); if (metadata && metadata.versions) { delete metadata.versions[version]; await this.putPypiPackageMetadata(packageName, metadata); } } // ======================================================================== // PYPI PATH HELPERS // ======================================================================== private getPypiMetadataPath(packageName: string): string { return `pypi/metadata/${packageName}/metadata.json`; } private getPypiSimpleIndexPath(packageName: string): string { return `pypi/simple/${packageName}/index.html`; } private getPypiSimpleRootIndexPath(): string { return `pypi/simple/index.html`; } private getPypiPackageFilePath(packageName: string, filename: string): string { return `pypi/packages/${packageName}/${filename}`; } // ======================================================================== // RUBYGEMS STORAGE METHODS // ======================================================================== /** * Get RubyGems versions file (compact index) */ public async getRubyGemsVersions(): Promise { const path = this.getRubyGemsVersionsPath(); const data = await this.getObject(path); return data ? data.toString('utf-8') : null; } /** * Store RubyGems versions file (compact index) */ public async putRubyGemsVersions(content: string): Promise { const path = this.getRubyGemsVersionsPath(); const data = Buffer.from(content, 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'text/plain; charset=utf-8' }); } /** * Get RubyGems info file for a gem (compact index) */ public async getRubyGemsInfo(gemName: string): Promise { const path = this.getRubyGemsInfoPath(gemName); const data = await this.getObject(path); return data ? data.toString('utf-8') : null; } /** * Store RubyGems info file for a gem (compact index) */ public async putRubyGemsInfo(gemName: string, content: string): Promise { const path = this.getRubyGemsInfoPath(gemName); const data = Buffer.from(content, 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'text/plain; charset=utf-8' }); } /** * Get RubyGems names file */ public async getRubyGemsNames(): Promise { const path = this.getRubyGemsNamesPath(); const data = await this.getObject(path); return data ? data.toString('utf-8') : null; } /** * Store RubyGems names file */ public async putRubyGemsNames(content: string): Promise { const path = this.getRubyGemsNamesPath(); const data = Buffer.from(content, 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'text/plain; charset=utf-8' }); } /** * Get RubyGems .gem file */ public async getRubyGemsGem(gemName: string, version: string, platform?: string): Promise { const path = this.getRubyGemsGemPath(gemName, version, platform); return this.getObject(path); } /** * Store RubyGems .gem file */ public async putRubyGemsGem( gemName: string, version: string, data: Buffer, platform?: string ): Promise { const path = this.getRubyGemsGemPath(gemName, version, platform); return this.putObject(path, data, { 'Content-Type': 'application/octet-stream' }); } /** * Check if RubyGems .gem file exists */ public async rubyGemsGemExists(gemName: string, version: string, platform?: string): Promise { const path = this.getRubyGemsGemPath(gemName, version, platform); return this.objectExists(path); } /** * Delete RubyGems .gem file */ public async deleteRubyGemsGem(gemName: string, version: string, platform?: string): Promise { const path = this.getRubyGemsGemPath(gemName, version, platform); return this.deleteObject(path); } /** * Get RubyGems metadata */ public async getRubyGemsMetadata(gemName: string): Promise { const path = this.getRubyGemsMetadataPath(gemName); const data = await this.getObject(path); return data ? JSON.parse(data.toString('utf-8')) : null; } /** * Store RubyGems metadata */ public async putRubyGemsMetadata(gemName: string, metadata: any): Promise { const path = this.getRubyGemsMetadataPath(gemName); const data = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8'); return this.putObject(path, data, { 'Content-Type': 'application/json' }); } /** * Check if RubyGems metadata exists */ public async rubyGemsMetadataExists(gemName: string): Promise { const path = this.getRubyGemsMetadataPath(gemName); return this.objectExists(path); } /** * Delete RubyGems metadata */ public async deleteRubyGemsMetadata(gemName: string): Promise { const path = this.getRubyGemsMetadataPath(gemName); return this.deleteObject(path); } /** * List all RubyGems */ public async listRubyGems(): Promise { const prefix = 'rubygems/metadata/'; const objects = await this.listObjects(prefix); const gems = new Set(); // Extract gem names from paths like: rubygems/metadata/gem-name/metadata.json for (const obj of objects) { const match = obj.match(/^rubygems\/metadata\/([^\/]+)\/metadata\.json$/); if (match) { gems.add(match[1]); } } return Array.from(gems).sort(); } /** * List all versions of a RubyGem */ public async listRubyGemsVersions(gemName: string): Promise { const prefix = `rubygems/gems/`; const objects = await this.listObjects(prefix); const versions = new Set(); // Extract versions from filenames: gem-name-version[-platform].gem const gemPrefix = `${gemName}-`; for (const obj of objects) { const filename = obj.split('/').pop(); if (!filename || !filename.startsWith(gemPrefix) || !filename.endsWith('.gem')) continue; // Remove gem name prefix and .gem suffix const versionPart = filename.substring(gemPrefix.length, filename.length - 4); // Split on last hyphen to separate version from platform const lastHyphen = versionPart.lastIndexOf('-'); const version = lastHyphen > 0 ? versionPart.substring(0, lastHyphen) : versionPart; versions.add(version); } return Array.from(versions).sort(); } /** * Delete entire RubyGem (all versions and files) */ public async deleteRubyGem(gemName: string): Promise { // Delete metadata await this.deleteRubyGemsMetadata(gemName); // Delete all gem files const prefix = `rubygems/gems/`; const objects = await this.listObjects(prefix); const gemPrefix = `${gemName}-`; for (const obj of objects) { const filename = obj.split('/').pop(); if (filename && filename.startsWith(gemPrefix) && filename.endsWith('.gem')) { await this.deleteObject(obj); } } } /** * Delete specific version of a RubyGem */ public async deleteRubyGemsVersion(gemName: string, version: string, platform?: string): Promise { // Delete gem file await this.deleteRubyGemsGem(gemName, version, platform); // Update metadata to remove this version const metadata = await this.getRubyGemsMetadata(gemName); if (metadata && metadata.versions) { const versionKey = platform ? `${version}-${platform}` : version; delete metadata.versions[versionKey]; await this.putRubyGemsMetadata(gemName, metadata); } } // ======================================================================== // RUBYGEMS PATH HELPERS // ======================================================================== private getRubyGemsVersionsPath(): string { return 'rubygems/versions'; } private getRubyGemsInfoPath(gemName: string): string { return `rubygems/info/${gemName}`; } private getRubyGemsNamesPath(): string { return 'rubygems/names'; } private getRubyGemsGemPath(gemName: string, version: string, platform?: string): string { const filename = platform ? `${gemName}-${version}-${platform}.gem` : `${gemName}-${version}.gem`; return `rubygems/gems/${filename}`; } private getRubyGemsMetadataPath(gemName: string): string { return `rubygems/metadata/${gemName}/metadata.json`; } }