UNPKG

recoder-code

Version:

🚀 AI-powered development platform - Chat with 32+ models, build projects, automate workflows. Free models included!

741 lines (633 loc) • 23.5 kB
/** * Package Service - Handles plugin package operations */ import { Repository, Like, In } from 'typeorm'; import { Package } from '../entities/Package'; import { PackageVersion } from '../entities/PackageVersion'; import { User } from '../entities/User'; //import { PackageStats } from '../entities/PackageStats'; import * as semver from 'semver'; import * as crypto from 'crypto'; import * as tar from 'tar'; import * as path from 'path'; import * as fs from 'fs/promises'; import { createReadStream, createWriteStream } from 'fs'; import { pipeline } from 'stream/promises'; import * as yauzl from 'yauzl'; import { SecurityService } from './SecurityService'; import { StorageService } from './StorageService'; import { ValidationService } from './ValidationService'; // import { NotificationService } from './NotificationService'; // import { QueueService } from './QueueService'; import config from '../config'; import { AppDataSource } from '../database'; // Simple logger for now const logger = { info: (msg: string, ...args: any[]) => console.log(`[INFO] ${msg}`, ...args), error: (msg: string, ...args: any[]) => console.error(`[ERROR] ${msg}`, ...args), warn: (msg: string, ...args: any[]) => console.warn(`[WARN] ${msg}`, ...args) }; export interface PackageStats { packageName: string; totalDownloads: number; monthlyDownloads: number; weeklyDownloads: number; lastUpdateDate: Date; } export interface PublishPackageData { name: string; version: string; description?: string; keywords?: string[]; author?: { name: string; email?: string; url?: string; }; license?: string; homepage?: string; repository?: { type: string; url: string; }; bugs?: { url?: string; email?: string; }; main?: string; engines?: { node?: string; 'recoder-code'?: string; }; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; contributes?: { commands?: any[]; menus?: any[]; keybindings?: any[]; languages?: any[]; themes?: any[]; }; activationEvents?: string[]; categories?: string[]; tags?: string[]; dist?: { tarball: string; shasum: string; integrity?: string; }; readme?: string; _attachments?: Record<string, any>; } export interface SearchOptions { query?: string; category?: string; keywords?: string[]; author?: string; license?: string; minDownloads?: number; sortBy?: 'relevance' | 'downloads' | 'updated' | 'created'; sortOrder?: 'asc' | 'desc'; limit?: number; offset?: number; } export class PackageService { private packageRepo: Repository<Package>; private versionRepo: Repository<PackageVersion>; private userRepo: Repository<User>; private securityService: SecurityService; private storageService: StorageService; private validationService: ValidationService; constructor() { this.packageRepo = AppDataSource.getRepository(Package); this.versionRepo = AppDataSource.getRepository(PackageVersion); this.userRepo = AppDataSource.getRepository(User); this.securityService = new SecurityService(); this.storageService = new StorageService(); this.validationService = new ValidationService(config); } async publishPackage( packageData: PublishPackageData, user: User, tarballBuffer?: Buffer ): Promise<{ success: boolean; package?: Package; version?: PackageVersion; errors?: string[] }> { const errors: string[] = []; try { logger.info(`Publishing package: ${packageData.name}@${packageData.version} by ${user.username}`); // Basic package data validation if (!packageData.name || !packageData.version) { return { success: false, errors: ['Package name and version are required'] }; } // Check if package exists let existingPackage = await this.packageRepo.findOne({ where: { name: packageData.name }, relations: ['versions', 'maintainers'] }); // If package doesn't exist, create it if (!existingPackage) { existingPackage = this.packageRepo.create({ name: packageData.name, description: packageData.description || '', keywords: packageData.keywords || [], author: { name: packageData.author?.name || user.username, email: packageData.author?.email || user.email }, license: packageData.license || 'MIT', homepage: packageData.homepage, repository: packageData.repository, bugs: packageData.bugs, categories: packageData.categories || [], owner: user, created_at: new Date(), updated_at: new Date() }); await this.packageRepo.save(existingPackage); logger.info(`Created new package: ${packageData.name}`); } else { // Check if user has permission to publish const isOwner = existingPackage.owner?.id === user.id; if (!isOwner && !user.is_admin) { return { success: false, errors: ['You do not have permission to publish to this package'] }; } // Update package metadata existingPackage.description = packageData.description || existingPackage.description; existingPackage.keywords = packageData.keywords || existingPackage.keywords; existingPackage.license = packageData.license || existingPackage.license; existingPackage.homepage = packageData.homepage || existingPackage.homepage; existingPackage.repository = packageData.repository || existingPackage.repository; existingPackage.bugs = packageData.bugs || existingPackage.bugs; existingPackage.categories = packageData.categories || existingPackage.categories; existingPackage.updated_at = new Date(); await this.packageRepo.save(existingPackage); } // Check if version already exists const existingVersion = await this.versionRepo.findOne({ where: { package: { id: existingPackage.id }, version: packageData.version } }); if (existingVersion) { return { success: false, errors: [`Version ${packageData.version} already exists`] }; } // Process tarball let tarballPath: string | undefined; let shasum: string | undefined; let integrity: string | undefined; let size = 0; if (tarballBuffer) { // Basic tarball validation if (tarballBuffer.length === 0) { return { success: false, errors: ['Empty tarball'] }; } if (tarballBuffer.length > 50 * 1024 * 1024) { // 50MB limit return { success: false, errors: ['Tarball too large'] }; } // Calculate checksums shasum = crypto.createHash('sha1').update(tarballBuffer).digest('hex'); integrity = `sha512-${crypto.createHash('sha512').update(tarballBuffer).digest('base64')}`; size = tarballBuffer.length; // Store tarball (simplified) tarballPath = `tarballs/${existingPackage.name}-${packageData.version}.tgz`; } // Create package version const packageVersion = this.versionRepo.create({ package: existingPackage, package_id: existingPackage.id, version: packageData.version, description: packageData.description || '', main: packageData.main || 'index.js', engines: packageData.engines || {}, dependencies: packageData.dependencies || {}, dev_dependencies: packageData.devDependencies || {}, peer_dependencies: packageData.peerDependencies || {}, dist: { tarball: tarballPath || '', shasum: shasum || '', integrity: integrity, size: size }, published_by: user, published_by_id: user.id, published_at: new Date() }); await this.versionRepo.save(packageVersion); // Update package stats await this.updatePackageStats(existingPackage.id, 'publish'); // Background tasks would be queued here // await this.queueService.addJob('package-published', {...}); // Notifications would be sent here // await this.notificationService.notifyPackagePublished(...); logger.info(`Successfully published: ${packageData.name}@${packageData.version}`); return { success: true, package: existingPackage, version: packageVersion }; } catch (error) { logger.error(`Failed to publish package: ${(error as Error)?.message || String(error)}`, error); return { success: false, errors: [`Internal error: ${(error as Error)?.message || String(error)}`] }; } } async getPackage(name: string): Promise<Package | null> { return this.packageRepo.findOne({ where: { name }, relations: ['versions', 'owner'] }); } async getPackageVersion(name: string, version: string): Promise<PackageVersion | null> { return this.versionRepo.findOne({ where: { package: { name }, version }, relations: ['package', 'publishedBy'] }); } async searchPackages(options: SearchOptions): Promise<{ packages: Package[]; total: number; page: number; limit: number; }> { const { query, category, keywords, author, license, minDownloads = 0, sortBy = 'relevance', sortOrder = 'desc', limit = 20, offset = 0 } = options; const queryBuilder = this.packageRepo.createQueryBuilder('package') .leftJoinAndSelect('package.versions', 'versions') .leftJoinAndSelect('package.owner', 'owner'); // Text search if (query) { queryBuilder.andWhere( '(package.name ILIKE :query OR package.description ILIKE :query OR package.keywords::text ILIKE :query)', { query: `%${query}%` } ); } // Category filter if (category) { queryBuilder.andWhere(':category = ANY(package.categories)', { category }); } // Keywords filter if (keywords && keywords.length > 0) { queryBuilder.andWhere('package.keywords && :keywords', { keywords }); } // Author filter if (author) { queryBuilder.andWhere('package.author ILIKE :author', { author: `%${author}%` }); } // License filter if (license) { queryBuilder.andWhere('package.license = :license', { license }); } // Minimum downloads filter if (minDownloads > 0) { queryBuilder.andWhere('package.download_count >= :minDownloads', { minDownloads }); } // Sorting switch (sortBy) { case 'downloads': queryBuilder.orderBy('package.download_count', sortOrder.toUpperCase() as 'ASC' | 'DESC'); break; case 'updated': queryBuilder.orderBy('package.updated_at', sortOrder.toUpperCase() as 'ASC' | 'DESC'); break; case 'created': queryBuilder.orderBy('package.created_at', sortOrder.toUpperCase() as 'ASC' | 'DESC'); break; default: // Relevance (default) if (query) { queryBuilder.orderBy('package.download_count', 'DESC'); } else { queryBuilder.orderBy('package.download_count', 'DESC'); } } // Pagination queryBuilder.skip(offset).take(limit); const [packages, total] = await queryBuilder.getManyAndCount(); return { packages, total, page: Math.floor(offset / limit) + 1, limit }; } async downloadPackage(name: string, version: string): Promise<{ success: boolean; stream?: NodeJS.ReadableStream; filename?: string; contentType?: string; size?: number; error?: string; }> { try { const packageVersion = await this.getPackageVersion(name, version); if (!packageVersion) { return { success: false, error: 'Package version not found' }; } if (!packageVersion.dist?.tarball) { return { success: false, error: 'Tarball not available' }; } // Update download stats await this.updatePackageStats(packageVersion.package.id, 'download'); // Get download stream (simplified) const stream = undefined; // Would implement actual stream here const filename = `${name}-${version}.tgz`; return { success: true, stream, filename, contentType: 'application/octet-stream', size: packageVersion.dist?.size }; } catch (error) { logger.error(`Failed to download package: ${(error as Error)?.message || String(error)}`, error); return { success: false, error: 'Download failed' }; } } async unpublishPackage(name: string, version: string, user: User): Promise<{ success: boolean; error?: string; }> { try { const pkg = await this.getPackage(name); if (!pkg) { return { success: false, error: 'Package not found' }; } // Check permissions const isMaintainer = pkg.maintainers.some(m => m.name === user.username || m.email === user.email); if (!isMaintainer && !user.is_admin) { return { success: false, error: 'Permission denied' }; } const packageVersion = await this.getPackageVersion(name, version); if (!packageVersion) { return { success: false, error: 'Version not found' }; } // Check if this is the only version const versionCount = await this.versionRepo.count({ where: { package: { id: pkg.id } } }); if (versionCount === 1) { // Delete entire package await this.deletePackage(name, user); } else { // Delete version await this.versionRepo.remove(packageVersion); // Delete tarball if (packageVersion.dist?.tarball) { // await this.storageService.deleteTarball(packageVersion.dist?.tarball); } // Update package timestamp pkg.updated_at = new Date(); await this.packageRepo.save(pkg); } logger.info(`Unpublished: ${name}@${version} by ${user.username}`); return { success: true }; } catch (error) { logger.error(`Failed to unpublish package: ${(error as Error)?.message || String(error)}`, error); return { success: false, error: 'Unpublish failed' }; } } async deletePackage(name: string, user: User): Promise<{ success: boolean; error?: string; }> { try { const pkg = await this.getPackage(name); if (!pkg) { return { success: false, error: 'Package not found' }; } // Check permissions const isMaintainer = pkg.maintainers.some(m => m.name === user.username || m.email === user.email); if (!isMaintainer && !user.is_admin) { return { success: false, error: 'Permission denied' }; } // Delete all tarballs for (const version of pkg.versions) { if (version.dist?.tarball) { // await this.storageService.deleteTarball(version.dist?.tarball); } } // Delete package and all related data (cascade) await this.packageRepo.remove(pkg); logger.info(`Deleted package: ${name} by ${user.username}`); return { success: true }; } catch (error) { logger.error(`Failed to delete package: ${(error as Error)?.message || String(error)}`, error); return { success: false, error: 'Delete failed' }; } } async getPackageStats(name: string): Promise<PackageStats | null> { const pkg = await this.getPackage(name); if (!pkg || !pkg.stats) return null; return { packageName: pkg.name, totalDownloads: pkg.download_count || 0, monthlyDownloads: pkg.stats.monthly_downloads || 0, weeklyDownloads: pkg.stats.weekly_downloads || 0, lastUpdateDate: pkg.updated_at }; } async getPopularPackages(limit = 10): Promise<Package[]> { return this.packageRepo.createQueryBuilder('package') .leftJoinAndSelect('package.versions', 'versions') .leftJoinAndSelect('package.owner', 'owner') .orderBy('package.download_count', 'DESC') .take(limit) .getMany(); } async getRecentlyUpdated(limit = 10): Promise<Package[]> { return this.packageRepo.find({ relations: ['versions', 'owner'], order: { updated_at: 'DESC' }, take: limit }); } async addMaintainer(packageName: string, username: string, currentUser: User): Promise<{ success: boolean; error?: string; }> { try { const pkg = await this.getPackage(packageName); if (!pkg) { return { success: false, error: 'Package not found' }; } // Check permissions const isMaintainer = pkg.maintainers.some(m => m.name === currentUser.username || m.email === currentUser.email); if (!isMaintainer && !currentUser.is_admin) { return { success: false, error: 'Permission denied' }; } const newMaintainer = await this.userRepo.findOne({ where: { username } }); if (!newMaintainer) { return { success: false, error: 'User not found' }; } // Check if already a maintainer const isAlreadyMaintainer = pkg.maintainers.some(m => m.name === newMaintainer.username || m.email === newMaintainer.email); if (isAlreadyMaintainer) { return { success: false, error: 'User is already a maintainer' }; } pkg.maintainers.push({ name: newMaintainer.username, email: newMaintainer.email }); await this.packageRepo.save(pkg); logger.info(`Added maintainer ${username} to package ${packageName}`); return { success: true }; } catch (error) { logger.error(`Failed to add maintainer: ${(error as Error)?.message || String(error)}`, error); return { success: false, error: 'Failed to add maintainer' }; } } async removeMaintainer(packageName: string, username: string, currentUser: User): Promise<{ success: boolean; error?: string; }> { try { const pkg = await this.getPackage(packageName); if (!pkg) { return { success: false, error: 'Package not found' }; } // Check permissions const isMaintainer = pkg.maintainers.some(m => m.name === currentUser.username || m.email === currentUser.email); if (!isMaintainer && !currentUser.is_admin) { return { success: false, error: 'Permission denied' }; } // Can't remove the last maintainer if (pkg.maintainers.length === 1) { return { success: false, error: 'Cannot remove the last maintainer' }; } const maintainerToRemove = pkg.maintainers.find(m => m.name === username); if (!maintainerToRemove) { return { success: false, error: 'User is not a maintainer' }; } pkg.maintainers = pkg.maintainers.filter(m => m.name !== maintainerToRemove.name); await this.packageRepo.save(pkg); logger.info(`Removed maintainer ${username} from package ${packageName}`); return { success: true }; } catch (error) { logger.error(`Failed to remove maintainer: ${(error as Error)?.message || String(error)}`, error); return { success: false, error: 'Failed to remove maintainer' }; } } private async updatePackageStats(packageId: string, action: 'download' | 'publish'): Promise<void> { const pkg = await this.packageRepo.findOne({ where: { id: packageId } }); if (!pkg) return; switch (action) { case 'download': pkg.download_count += 1; if (!pkg.stats) { pkg.stats = { weekly_downloads: 0, monthly_downloads: 0, yearly_downloads: 0, dependents: 0, dependencies: 0 }; } pkg.stats.weekly_downloads += 1; pkg.stats.monthly_downloads += 1; pkg.stats.yearly_downloads += 1; break; case 'publish': pkg.version_count += 1; pkg.last_published = new Date(); break; } await this.packageRepo.save(pkg); } async getLatestVersion(packageName: string): Promise<string | null> { const versions = await this.versionRepo.find({ where: { package: { name: packageName } }, select: ['version'] }); if (versions.length === 0) return null; const sortedVersions = versions .map(v => v.version) .sort((a, b) => semver.rcompare(a, b)); return sortedVersions[0]; } async getVersions(packageName: string): Promise<string[]> { const versions = await this.versionRepo.find({ where: { package: { name: packageName } }, select: ['version'], order: { published_at: 'DESC' } }); return versions.map(v => v.version); } async getPackageVersions(packageName: string): Promise<PackageVersion[]> { return await this.versionRepo.find({ where: { package: { name: packageName } }, order: { published_at: 'DESC' } }); } async incrementDownloadCount(packageName: string, version: string): Promise<void> { try { const packageVersion = await this.getPackageVersion(packageName, version); if (packageVersion) { packageVersion.download_count = (packageVersion.download_count || 0) + 1; await this.versionRepo.save(packageVersion); } } catch (error) { logger.error(`Failed to increment download count for ${packageName}@${version}:`, error); } } async unpublishVersion(packageName: string, version: string, user: User): Promise<{ success: boolean; error?: string; }> { return this.unpublishPackage(packageName, version, user); } async deprecateVersion(packageName: string, version: string, user: User, message?: string): Promise<{ success: boolean; error?: string; }> { try { const packageVersion = await this.getPackageVersion(packageName, version); if (!packageVersion) { return { success: false, error: 'Version not found' }; } const pkg = await this.getPackage(packageName); if (!pkg) { return { success: false, error: 'Package not found' }; } // Check permissions const isMaintainer = pkg.maintainers.some(m => m.name === user.username || m.email === user.email); if (!isMaintainer && !user.is_admin) { return { success: false, error: 'Permission denied' }; } // Mark version as deprecated packageVersion.deprecate(message); await this.versionRepo.save(packageVersion); logger.info(`Deprecated version: ${packageName}@${version} by ${user.username}`); return { success: true }; } catch (error) { logger.error(`Failed to deprecate version: ${(error as Error)?.message || String(error)}`, error); return { success: false, error: 'Deprecation failed' }; } } async addCollaborator(packageName: string, username: string, currentUser: User): Promise<{ success: boolean; error?: string; }> { return this.addMaintainer(packageName, username, currentUser); } async removeCollaborator(packageName: string, username: string, currentUser: User): Promise<{ success: boolean; error?: string; }> { return this.removeMaintainer(packageName, username, currentUser); } async getPackageCount(): Promise<number> { try { return await this.packageRepo.count(); } catch (error) { logger.error('Failed to get package count:', error); throw error; } } }