/* * Copyright 2025 the original author or authors. *

* Licensed under the Moderne Source Available License (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at *

* https://docs.moderne.io/licensing/moderne-source-available-license *

* Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as crypto from 'crypto'; import {execSync} from 'child_process'; function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch (error: any) { // ESRCH: no such process. EPERM: process exists but is owned by another user. return error.code === 'EPERM'; } } interface BaseWorkspaceOptions { /** * Optional target directory. If provided, creates workspace in this directory * instead of a hash-based temp directory. Caller is responsible for directory lifecycle. */ targetDir?: string; } interface DependenciesWorkspaceOptions extends BaseWorkspaceOptions { /** * NPM dependencies (package name to version mapping). */ dependencies: Record; packageJsonContent?: never; packageLockContent?: never; } interface PackageJsonWorkspaceOptions extends BaseWorkspaceOptions { /** * package.json content as a string. Dependencies are extracted from it * and the content is written to the workspace. */ packageJsonContent: string; dependencies?: never; /** * Optional package-lock.json content. If provided: * - The lock file content is used as the cache key (more precise than dependency hash) * - `npm ci` is used instead of `npm install` (faster, deterministic) */ packageLockContent?: string; /** * Optional workspace member package.json files. * Keys are relative paths (e.g., "packages/foo/package.json"), values are content. */ workspacePackages?: Record; } /** * Options for creating a dependency workspace. * Provide either `dependencies` or `packageJsonContent`, but not both. */ export type WorkspaceOptions = DependenciesWorkspaceOptions | PackageJsonWorkspaceOptions; /** * Manages workspace directories for TypeScript compilation with dependencies. * Creates temporary workspaces with package.json and installed node_modules * to enable proper type attribution for templates. */ export class DependencyWorkspace { private static readonly WORKSPACE_BASE = path.join(os.tmpdir(), 'openrewrite-js-workspaces'); private static readonly cache = new Map(); /** * Gets or creates a workspace directory for the given dependencies. * Workspaces are cached by dependency hash (or lock file hash if provided) to avoid repeated npm installs. * * @param options Workspace options including dependencies or package.json content * @returns Path to the workspace directory */ static async getOrCreateWorkspace(options: WorkspaceOptions): Promise { // Extract dependencies from package.json content if provided let dependencies: Record | undefined = options.dependencies; let parsedPackageJson: Record | undefined; let workspacePackages: Record | undefined; if (options.packageJsonContent) { parsedPackageJson = JSON.parse(options.packageJsonContent); dependencies = { ...parsedPackageJson?.dependencies, ...parsedPackageJson?.devDependencies }; workspacePackages = options.workspacePackages; // For workspaces, also collect dependencies from workspace members if (workspacePackages) { for (const content of Object.values(workspacePackages)) { const memberPkg = JSON.parse(content); dependencies = { ...dependencies, ...memberPkg?.dependencies, ...memberPkg?.devDependencies }; } } } // For workspaces without explicit dependencies in root, we still need to run install const hasWorkspaces = parsedPackageJson?.workspaces && Array.isArray(parsedPackageJson.workspaces); if ((!dependencies || Object.keys(dependencies).length === 0) && !hasWorkspaces) { throw new Error('No dependencies provided'); } // Use the refactored internal method return this.createWorkspace( dependencies || {}, parsedPackageJson, options.packageJsonContent, options.packageLockContent, options.targetDir, workspacePackages ); } /** * Internal method that handles workspace creation. */ private static async createWorkspace( dependencies: Record, parsedPackageJson: Record | undefined, packageJsonContent: string | undefined, packageLockContent: string | undefined, targetDir: string | undefined, workspacePackages?: Record ): Promise { // Determine hash based on lock file (most precise) or dependencies // Note: We always hash dependencies (not packageJsonContent) because whitespace/formatting // differences in package.json shouldn't create different workspaces // For workspaces, include workspace package paths in the hash let hash: string; if (packageLockContent) { hash = this.hashContent(packageLockContent); } else if (workspacePackages) { // Include workspace package paths in hash for workspace setups const workspacePaths = Object.keys(workspacePackages).sort().join(','); hash = this.hashContent(this.hashDependencies(dependencies) + ':' + workspacePaths); } else { hash = this.hashDependencies(dependencies); } // Determine npm command: use `npm ci` when lock file is provided (faster, deterministic) // Use --loglevel=error to suppress normal output but still capture errors in stderr const npmCommand = packageLockContent ? 'npm ci --loglevel=error' : 'npm install --loglevel=error'; // Helper to write package files to a directory const writePackageFiles = (dir: string) => { // Write package.json (use provided content or generate from parsed/dependencies) if (packageJsonContent) { fs.writeFileSync(path.join(dir, 'package.json'), packageJsonContent); } else if (parsedPackageJson) { fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(parsedPackageJson, null, 2)); } else { const packageJson = { name: "openrewrite-template-workspace", version: "1.0.0", private: true, dependencies: dependencies }; fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(packageJson, null, 2)); } // Write package-lock.json if provided if (packageLockContent) { fs.writeFileSync(path.join(dir, 'package-lock.json'), packageLockContent); } // Write workspace member package.json files if (workspacePackages) { for (const [relativePath, content] of Object.entries(workspacePackages)) { const fullPath = path.join(dir, relativePath); const memberDir = path.dirname(fullPath); if (!fs.existsSync(memberDir)) { fs.mkdirSync(memberDir, {recursive: true}); } fs.writeFileSync(fullPath, content); } } }; // For workspaces, skip dependency validation (combined deps don't match root package.json) const depsForValidation = workspacePackages ? undefined : dependencies; if (targetDir) { // Use provided directory - check if it's already valid if (this.isWorkspaceValid(targetDir, depsForValidation)) { return targetDir; } // Create/update workspace in target directory fs.mkdirSync(targetDir, {recursive: true}); // Check if we can reuse a cached workspace by symlinking node_modules const cachedWorkspaceDir = path.join(this.WORKSPACE_BASE, hash); const cachedNodeModules = path.join(cachedWorkspaceDir, 'node_modules'); if (fs.existsSync(cachedNodeModules) && this.isWorkspaceValid(cachedWorkspaceDir, depsForValidation)) { // Symlink node_modules from cached workspace try { const targetNodeModules = path.join(targetDir, 'node_modules'); // Remove existing node_modules if present (might be invalid) if (fs.existsSync(targetNodeModules)) { fs.rmSync(targetNodeModules, {recursive: true, force: true}); } // Create symlink to cached node_modules fs.symlinkSync(cachedNodeModules, targetNodeModules, 'dir'); // Write package files writePackageFiles(targetDir); return targetDir; } catch (symlinkError) { // Symlink failed (e.g., cross-device, permissions) - fall through to npm install } } try { writePackageFiles(targetDir); // Run npm install or npm ci execSync(npmCommand, { cwd: targetDir, stdio: 'pipe' // Suppress output }); return targetDir; } catch (error: any) { const stderr = error?.stderr?.toString()?.trim(); const errorMsg = stderr || error?.message || String(error); throw new Error(`Failed to create dependency workspace: ${errorMsg}`); } } // Use hash-based cached workspace // Check cache const cached = this.cache.get(hash); if (cached && fs.existsSync(cached) && this.isWorkspaceValid(cached, depsForValidation)) { return cached; } // Final workspace location const workspaceDir = path.join(this.WORKSPACE_BASE, hash); // Check if valid workspace already exists on disk (cross-VM reuse) if (fs.existsSync(workspaceDir) && this.isWorkspaceValid(workspaceDir, depsForValidation)) { this.cache.set(hash, workspaceDir); return workspaceDir; } // Ensure base directory exists if (!fs.existsSync(this.WORKSPACE_BASE)) { fs.mkdirSync(this.WORKSPACE_BASE, {recursive: true}); } // Create workspace in temporary location to ensure atomicity // This prevents reusing partially created workspaces from crashes // and handles concurrency with other Node processes const tempSuffix = `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; const tempWorkspaceDir = path.join(this.WORKSPACE_BASE, hash + tempSuffix); try { // Create temporary workspace directory fs.mkdirSync(tempWorkspaceDir, {recursive: true}); // Write package files writePackageFiles(tempWorkspaceDir); // Run npm install or npm ci execSync(npmCommand, { cwd: tempWorkspaceDir, stdio: 'pipe' // Suppress output }); // Atomically move to final location with retry logic for concurrency let moved = false; let retries = 3; while (!moved && retries > 0) { try { // Attempt atomic rename (works on POSIX, fails on Windows if target exists) fs.renameSync(tempWorkspaceDir, workspaceDir); moved = true; } catch (error: any) { // Handle concurrent creation by another process if (error.code === 'EEXIST' || error.code === 'ENOTEMPTY' || error.code === 'EISDIR' || (error.code === 'EPERM' && fs.existsSync(workspaceDir))) { // Target exists - check if it's valid if (this.isWorkspaceValid(workspaceDir, depsForValidation)) { // Another process created a valid workspace - use theirs moved = true; // Don't try again } else { // Invalid workspace exists - try to remove and retry try { fs.rmSync(workspaceDir, {recursive: true, force: true}); retries--; } catch (removeError) { // Another process might be using it, give up retries = 0; } } } else if (error.code === 'EXDEV') { // Cross-device link - fallback to copy+remove (not atomic, but rare) try { fs.cpSync(tempWorkspaceDir, workspaceDir, {recursive: true}); moved = true; } catch (copyError) { // Check if another process created it while we were copying if (this.isWorkspaceValid(workspaceDir, depsForValidation)) { moved = true; } else { throw error; } } } else { // Unexpected error throw error; } } } // Clean up temp directory try { if (fs.existsSync(tempWorkspaceDir)) { fs.rmSync(tempWorkspaceDir, {recursive: true, force: true}); } } catch { // Ignore cleanup errors } // Verify final workspace is valid (might be from another process) if (!this.isWorkspaceValid(workspaceDir, depsForValidation)) { throw new Error('Failed to create valid workspace due to concurrent modifications'); } // Cache the workspace this.cache.set(hash, workspaceDir); return workspaceDir; } catch (error: any) { // Clean up temporary workspace on failure try { if (fs.existsSync(tempWorkspaceDir)) { fs.rmSync(tempWorkspaceDir, {recursive: true, force: true}); } } catch { // Ignore cleanup errors } const stderr = error?.stderr?.toString()?.trim(); const errorMsg = stderr || error?.message || String(error); throw new Error(`Failed to create dependency workspace: ${errorMsg}`); } } /** * Generates a hash from dependencies for caching. */ private static hashDependencies(dependencies: Record): string { // Sort keys for consistent hashing const sorted = Object.keys(dependencies).sort(); const content = sorted.map(key => `${key}:${dependencies[key]}`).join(','); return this.hashContent(content); } /** * Generates a hash from arbitrary content for caching. */ private static hashContent(content: string): string { return crypto.createHash('sha256').update(content).digest('hex').substring(0, 16); } /** * Checks if a workspace is valid (has node_modules and matching package.json). * Handles both real node_modules directories and symlinks to cached workspaces. * * @param workspaceDir Directory to check * @param expectedDependencies Optional dependencies to check against package.json */ private static isWorkspaceValid(workspaceDir: string, expectedDependencies?: Record): boolean { const nodeModules = path.join(workspaceDir, 'node_modules'); const packageJsonPath = path.join(workspaceDir, 'package.json'); // Check node_modules exists (as directory or symlink) if (!fs.existsSync(nodeModules) || !fs.existsSync(packageJsonPath)) { return false; } // If node_modules is a symlink, verify the target still exists try { const stats = fs.lstatSync(nodeModules); if (stats.isSymbolicLink()) { const target = fs.readlinkSync(nodeModules); const absoluteTarget = path.isAbsolute(target) ? target : path.resolve(path.dirname(nodeModules), target); if (!fs.existsSync(absoluteTarget)) { return false; } } } catch { return false; } // If dependencies provided, check if they match if (expectedDependencies) { try { const packageJsonContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); // Merge dependencies and devDependencies (same as getOrCreateWorkspace) const existingDeps = { ...packageJsonContent.dependencies, ...packageJsonContent.devDependencies }; // Check if all expected dependencies match const expectedKeys = Object.keys(expectedDependencies).sort(); const existingKeys = Object.keys(existingDeps).sort(); if (expectedKeys.length !== existingKeys.length) { return false; } for (let i = 0; i < expectedKeys.length; i++) { if (expectedKeys[i] !== existingKeys[i] || expectedDependencies[expectedKeys[i]] !== existingDeps[existingKeys[i]]) { return false; } } } catch (error) { return false; } } return true; } /** * Cleans up old workspace directories. * Removes workspaces older than the specified age. * Also removes all temporary directories (*.tmp-*) regardless of age, * as these indicate incomplete/crashed operations. * * @param maxAgeMs Maximum age in milliseconds (default: 24 hours) */ static cleanupOldWorkspaces(maxAgeMs: number = 24 * 60 * 60 * 1000): void { if (!fs.existsSync(this.WORKSPACE_BASE)) { return; } const now = Date.now(); const entries = fs.readdirSync(this.WORKSPACE_BASE, {withFileTypes: true}); for (const entry of entries) { if (!entry.isDirectory()) { continue; } const workspaceDir = path.join(this.WORKSPACE_BASE, entry.name); // Temporary directories are named `.tmp--...`. Only reap one // whose owning process is gone — otherwise a shutdown here would delete // another live process's install cwd mid-run (npm then exits 7). Names // without a parseable pid fall back to age-based reaping. const tmpIdx = entry.name.indexOf('.tmp-'); if (tmpIdx >= 0) { const ownerPid = parseInt(entry.name.substring(tmpIdx + 5).split('-')[0], 10); if (!isNaN(ownerPid) && isProcessAlive(ownerPid)) { continue; } if (isNaN(ownerPid)) { try { if (now - fs.statSync(workspaceDir).mtimeMs <= maxAgeMs) { continue; } } catch (error) { // fall through to deletion } } try { fs.rmSync(workspaceDir, {recursive: true, force: true}); } catch (error) { // Ignore errors, might be in use by another process } continue; } // Clean up old regular workspaces try { const stats = fs.statSync(workspaceDir); const age = now - stats.mtimeMs; if (age > maxAgeMs) { fs.rmSync(workspaceDir, {recursive: true, force: true}); // Remove from cache for (const [hash, dir] of this.cache.entries()) { if (dir === workspaceDir) { this.cache.delete(hash); break; } } } } catch (error) { // Ignore errors, workspace might be in use } } } /** * Clears all cached workspaces. */ static clearCache(): void { this.cache.clear(); } }