UNPKG

@tryloop/oats

Version:

🌾 OATS - OpenAPI TypeScript Sync. The missing link between your OpenAPI specs and TypeScript applications. Automatically watch, generate, and sync TypeScript clients from your API definitions.

283 lines 11.3 kB
/** * Package Link Manager * * Manages npm/yarn/pnpm package linking and unlinking operations * with proper error handling and verification * * @module @oatsjs/utils/link-manager */ import { exec } from 'child_process'; import { promisify } from 'util'; import { existsSync } from 'fs'; import { join } from 'path'; import chalk from 'chalk'; import { Logger } from './logger.js'; const execAsync = promisify(exec); export class LinkManager { static instance; logger; linkedPackages = new Map(); isShuttingDown = false; constructor() { this.logger = new Logger('LinkManager'); } /** * Get singleton instance */ static getInstance() { if (!LinkManager.instance) { LinkManager.instance = new LinkManager(); } return LinkManager.instance; } /** * Detect package manager for a project */ detectPackageManager(projectPath) { if (existsSync(join(projectPath, 'yarn.lock'))) return 'yarn'; if (existsSync(join(projectPath, 'pnpm-lock.yaml'))) return 'pnpm'; return 'npm'; } /** * Execute a command with error handling */ async runCommand(command, cwd) { try { const result = await execAsync(command, { cwd }); return result; } catch (error) { // Include the original error details throw new Error(`Command failed: ${command}\n${error.message}\n${error.stderr || ''}`); } } /** * Link a package from client to frontend */ async linkPackage(packageName, clientPath, frontendPath, config) { // Check if already linked if (this.linkedPackages.has(packageName)) { this.logger.debug(`Package ${packageName} is already linked`); return; } const pm = this.detectPackageManager(frontendPath); if (!config?.log?.quiet) { console.log(chalk.blue(`🔗 Linking ${packageName} to frontend...`)); } try { // Step 1: Create global link from client directory this.logger.debug(`Creating ${pm} link for ${packageName}...`); await this.runCommand(`${pm} link`, clientPath); // Step 2: Link to frontend project this.logger.debug(`Linking ${packageName} to frontend project...`); await this.runCommand(`${pm} link ${packageName}`, frontendPath); // Store link information this.linkedPackages.set(packageName, { packageName, clientPath, frontendPath, linkedAt: new Date(), }); if (!config?.log?.quiet) { console.log(chalk.green(`✅ Linked ${packageName} to frontend`)); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (!config?.log?.quiet) { console.log(chalk.red(`❌ Failed to link ${packageName}: ${errorMessage}`)); } throw error; } } /** * Unlink a specific package */ async unlinkPackage(linkInfo, skipUnlink = false) { const { packageName, clientPath, frontendPath } = linkInfo; if (skipUnlink) { this.logger.debug(`Skipping unlink for ${packageName} (configured)`); return { success: true }; } try { // Step 1: Unlink from frontend project (if exists) if (frontendPath) { const frontendPm = this.detectPackageManager(frontendPath); try { this.logger.debug(`Unlinking ${packageName} from frontend project...`); await this.runCommand(`${frontendPm} unlink ${packageName}`, frontendPath); this.logger.debug(`Successfully unlinked ${packageName} from frontend`); } catch (frontendErr) { // Non-fatal: package might not be linked this.logger.debug(`Frontend unlink note: ${frontendErr instanceof Error ? frontendErr.message : frontendErr}`); } } // Step 2: Remove global link from client directory const clientPm = this.detectPackageManager(clientPath); try { this.logger.debug(`Removing global link for ${packageName}...`); await this.runCommand(`${clientPm} unlink`, clientPath); this.logger.debug(`Successfully removed global link for ${packageName}`); } catch (globalErr) { // Non-fatal: global link might not exist this.logger.debug(`Global unlink note: ${globalErr instanceof Error ? globalErr.message : globalErr}`); } // Step 3: Verify unlinking for yarn if (frontendPath && this.detectPackageManager(frontendPath) === 'yarn') { try { const { stdout } = await execAsync('yarn link --list 2>/dev/null || true'); if (stdout.includes(packageName)) { throw new Error(`Package ${packageName} still appears in yarn link registry`); } } catch (verifyErr) { this.logger.debug(`Could not verify yarn link status: ${verifyErr instanceof Error ? verifyErr.message : verifyErr}`); } } return { success: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logger.warn(`Failed to unlink ${packageName}: ${errorMessage}`); return { success: false, error: errorMessage }; } } /** * Unlink all packages on shutdown */ async unlinkAll(config) { if (this.isShuttingDown) { this.logger.warn('Unlink already in progress'); return; } this.isShuttingDown = true; if (this.linkedPackages.size === 0) { this.logger.debug('No packages to unlink'); this.isShuttingDown = false; return; } // Check if unlinking is disabled const skipUnlink = config?.sync?.skipUnlinkOnShutdown ?? false; if (skipUnlink) { console.log(chalk.gray('📦 Skipping package unlinking (configured)')); this.linkedPackages.clear(); this.isShuttingDown = false; return; } console.log(chalk.yellow(`📦 Unlinking ${this.linkedPackages.size} package(s)...`)); let successCount = 0; let failureCount = 0; const failures = []; // Process all packages for (const [packageName, linkInfo] of this.linkedPackages) { const result = await this.unlinkPackage(linkInfo, skipUnlink); if (result.success) { console.log(chalk.green(` ✓ Unlinked ${packageName}`)); successCount++; } else { console.log(chalk.yellow(` ⚠ Could not unlink ${packageName}: ${result.error}`)); failureCount++; failures.push({ packageName, error: result.error || 'Unknown error' }); } } // Summary if (successCount > 0) { console.log(chalk.green(`✅ Successfully unlinked ${successCount} package(s)`)); } if (failureCount > 0) { console.log(chalk.yellow(`⚠️ Failed to unlink ${failureCount} package(s)`)); // Suggest checking link status const frontendPaths = Array.from(new Set(Array.from(this.linkedPackages.values()) .map((info) => info.frontendPath) .filter(Boolean))); if (frontendPaths.length > 0) { const pm = this.detectPackageManager(frontendPaths[0]); console.log(chalk.gray(` Run '${pm} link --list' to check link status`)); } } // Collect frontend paths before clearing const frontendPaths = Array.from(new Set(Array.from(this.linkedPackages.values()) .map(info => info.frontendPath) .filter((path) => Boolean(path)))); // Clear all packages this.linkedPackages.clear(); // Run yarn/npm install --force in frontend if configured if ((config?.sync?.runInstallAfterUnlink ?? false) && frontendPaths.length > 0) { await this.runInstallInFrontends(frontendPaths, config); } this.isShuttingDown = false; } /** * Run install command in frontend directories after unlinking */ async runInstallInFrontends(frontendPaths, config) { if (frontendPaths.length === 0) { this.logger.debug('No frontend paths to run install in'); return; } console.log(chalk.blue('📦 Running install to restore packages from registry...')); for (const frontendPath of frontendPaths) { if (!frontendPath) continue; const pm = this.detectPackageManager(frontendPath); const installCommand = pm === 'yarn' ? 'yarn install --force' : `${pm} install --force`; try { console.log(chalk.gray(` Running ${installCommand} in ${frontendPath}...`)); const { stdout } = await this.runCommand(installCommand, frontendPath); // Only show output in debug mode if (config?.log?.level === 'debug' && stdout.trim()) { console.log(chalk.gray(stdout)); } console.log(chalk.green(` ✓ Packages restored in ${frontendPath}`)); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.log(chalk.yellow(` ⚠ Failed to run install: ${errorMessage}`)); this.logger.warn(`Failed to run install in ${frontendPath}:`, error); } } } /** * Get information about linked packages */ getLinkedPackages() { return new Map(this.linkedPackages); } /** * Check if a package is linked */ isPackageLinked(packageName) { return this.linkedPackages.has(packageName); } /** * Clear all linked packages without unlinking * (useful for testing or when packages are already unlinked externally) */ clearLinkedPackages() { this.linkedPackages.clear(); } /** * Get link status summary */ getLinkStatus() { const packages = Array.from(this.linkedPackages.keys()); const dates = Array.from(this.linkedPackages.values()).map((info) => info.linkedAt); return { count: this.linkedPackages.size, packages, oldestLink: dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : undefined, }; } } // Export singleton instance export const linkManager = LinkManager.getInstance(); //# sourceMappingURL=link-manager.js.map