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
text/typescript
/**
* 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;
}
}
}