@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
JavaScript
/**
* 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