/* * 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 { createNodeResolutionResultMarker, findNodeResolutionResult, PackageJsonContent, PackageLockContent, PackageManager } from "./node-resolution-result"; import {replaceMarkerByKind} from "../markers"; import {Json, JsonParser, JsonVisitor} from "../json"; import {isDocuments, Yaml, YamlParser, YamlVisitor} from "../yaml"; import {PlainTextParser} from "../text"; import {SourceFile} from "../tree"; import {TreeVisitor} from "../visitor"; import {ExecutionContext} from "../execution"; import {getPlatformCommand} from "../shell-utils"; import * as fs from "fs"; import * as fsp from "fs/promises"; import * as path from "path"; import * as os from "os"; import {spawnSync} from "child_process"; import {isDeepStrictEqual} from "util"; import * as YAML from "yaml"; /** * Configuration for each package manager. */ interface PackageManagerConfig { /** The lock file name for this package manager */ lockFile: string; /** Command to install dependencies and update lock file only (no node_modules) */ installLockOnlyCommand: string[]; /** Command to install dependencies fully */ installCommand: string[]; /** Command to list dependencies in JSON format (if supported) */ listCommand?: string[]; } /** * Package manager configurations. */ const PACKAGE_MANAGER_CONFIGS: Record = { [PackageManager.Npm]: { lockFile: 'package-lock.json', // --ignore-scripts prevents prepublish/prepare scripts from running in temp directory. // --legacy-peer-deps disables npm 7+'s strict peer-dep enforcement. The install here is // purely a validity check on the bumped package.json; the resulting node_modules is not // shipped to the user. Strict peer enforcement creates false negatives in common // ecosystem upgrades (e.g. Angular major bumps where third-party libs lag), which would // otherwise prevent the bump from being written even when it is correct. installLockOnlyCommand: ['npm', 'install', '--package-lock-only', '--ignore-scripts', '--legacy-peer-deps'], installCommand: ['npm', 'install', '--ignore-scripts', '--legacy-peer-deps'], listCommand: ['npm', 'list', '--json', '--all'], }, [PackageManager.YarnClassic]: { lockFile: 'yarn.lock', // Yarn Classic doesn't have a lock-only mode; --ignore-scripts prevents lifecycle scripts installLockOnlyCommand: ['yarn', 'install', '--ignore-scripts'], installCommand: ['yarn', 'install', '--ignore-scripts'], listCommand: ['yarn', 'list', '--json'], }, [PackageManager.YarnBerry]: { lockFile: 'yarn.lock', // --mode skip-build skips post-install scripts in Yarn Berry installLockOnlyCommand: ['yarn', 'install', '--mode', 'skip-build'], installCommand: ['yarn', 'install', '--mode', 'skip-build'], listCommand: ['yarn', 'info', '--all', '--json'], }, [PackageManager.Pnpm]: { lockFile: 'pnpm-lock.yaml', // --ignore-scripts prevents lifecycle scripts from running in temp directory. // --no-strict-peer-dependencies disables pnpm's strict peer-dep enforcement (see the // `npm` entry above for rationale). installLockOnlyCommand: ['pnpm', 'install', '--lockfile-only', '--ignore-scripts', '--no-strict-peer-dependencies'], installCommand: ['pnpm', 'install', '--ignore-scripts', '--no-strict-peer-dependencies'], listCommand: ['pnpm', 'list', '--json', '--depth=Infinity'], }, [PackageManager.Bun]: { lockFile: 'bun.lock', // Bun doesn't have a lock-only mode; --ignore-scripts prevents lifecycle scripts installLockOnlyCommand: ['bun', 'install', '--ignore-scripts'], installCommand: ['bun', 'install', '--ignore-scripts'], }, }; /** * Configuration for lock file detection. */ export interface LockFileDetectionConfig { /** The lock file name */ filename: string; /** The package manager, or a function to detect it from file content */ packageManager: PackageManager | ((content: string) => PackageManager); /** If true, prefer walking node_modules over parsing lock file (lock file may omit details) */ preferNodeModules?: boolean; } /** * Lock file detection configuration with priority order. * Priority order determines which package manager is detected when multiple lock files exist. */ const LOCK_FILE_DETECTION: ReadonlyArray = [ {filename: 'package-lock.json', packageManager: PackageManager.Npm}, {filename: 'bun.lock', packageManager: PackageManager.Bun}, {filename: 'pnpm-lock.yaml', packageManager: PackageManager.Pnpm, preferNodeModules: true}, { filename: 'yarn.lock', packageManager: (content) => content.includes('__metadata:') ? PackageManager.YarnBerry : PackageManager.YarnClassic, // yarn.lock omits transitive dependency details (engines/license), so prefer node_modules preferNodeModules: true }, ]; /** * Lock file names that should be parsed as JSON/JSONC format. */ export const JSON_LOCK_FILE_NAMES = ['bun.lock', 'package-lock.json'] as const; /** * Lock file names that should be parsed as YAML format. */ export const YAML_LOCK_FILE_NAMES = ['pnpm-lock.yaml'] as const; /** * Lock file names that should be parsed as plain text (custom formats like yarn.lock v1). * Note: yarn.lock for Yarn Berry (v2+) is actually YAML format and should be parsed as such. * Use `getLockFileFormat` with content to determine the correct format for yarn.lock. */ export const TEXT_LOCK_FILE_NAMES = ['yarn.lock'] as const; /** * Detects if a yarn.lock file is Yarn Berry (v2+) format based on content. * Yarn Berry lock files contain a `__metadata:` key which is not present in Classic. * * @param content The yarn.lock file content * @returns true if this is a Yarn Berry lock file (YAML format), false for Classic */ export function isYarnBerryLockFile(content: string): boolean { return content.includes('__metadata:'); } /** * Result of running a package manager command. */ interface PackageManagerResult { success: boolean; stdout?: string; stderr?: string; error?: string; } /** * Options for running package manager install. */ interface InstallOptions { /** Working directory */ cwd: string; /** If true, only update lock file without installing to node_modules */ lockOnly?: boolean; /** Timeout in milliseconds (default: 120000 = 2 minutes) */ timeout?: number; /** Additional environment variables */ env?: Record; } /** * Detects the package manager used in a directory by checking for lock files. * * @param dir The directory to check * @returns The detected package manager, or undefined if none found */ export function detectPackageManager(dir: string): PackageManager | undefined { for (const config of LOCK_FILE_DETECTION) { const lockPath = path.join(dir, config.filename); if (fs.existsSync(lockPath)) { if (typeof config.packageManager === 'function') { try { const content = fs.readFileSync(lockPath, 'utf-8'); return config.packageManager(content); } catch { continue; } } return config.packageManager; } } return undefined; } /** * Gets the lock file detection configuration. * Returns the array of lock file configs in priority order. */ export function getLockFileDetectionConfig(): ReadonlyArray { return LOCK_FILE_DETECTION; } /** * Gets the lock file name for a package manager. */ export function getLockFileName(pm: PackageManager): string { return PACKAGE_MANAGER_CONFIGS[pm].lockFile; } /** * Gets all supported lock file names. */ export function getAllLockFileNames(): string[] { return LOCK_FILE_DETECTION.map(c => c.filename); } /** * Runs the package manager install command. */ function runInstall(pm: PackageManager, options: InstallOptions): PackageManagerResult { const config = PACKAGE_MANAGER_CONFIGS[pm]; const command = options.lockOnly ? config.installLockOnlyCommand : config.installCommand; const [cmd, ...args] = command; try { const result = spawnSync(getPlatformCommand(cmd), args, { cwd: options.cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: options.timeout ?? 120000, env: options.env ? {...process.env, ...options.env} : process.env, ...(os.platform() === 'win32' ? { shell: true } : {}) }); if (result.error) { return { success: false, error: result.error.message, stderr: result.stderr, }; } if (result.status !== 0) { return { success: false, stdout: result.stdout, stderr: result.stderr, error: `Command exited with code ${result.status}`, }; } return { success: true, stdout: result.stdout, stderr: result.stderr, }; } catch (error: any) { return { success: false, error: error.message, }; } } /** * Runs a package manager list command to get dependency information. * * @param pm The package manager to use * @param cwd Working directory * @param timeout Timeout in milliseconds * @returns The JSON output, or undefined if failed */ export function runList(pm: PackageManager, cwd: string, timeout: number = 30000): string | undefined { const config = PACKAGE_MANAGER_CONFIGS[pm]; if (!config.listCommand) { return undefined; } const [cmd, ...args] = config.listCommand; const result = spawnSync(getPlatformCommand(cmd), args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout, ...(os.platform() === 'win32' ? { shell: true } : {}) }); if (result.error || result.status !== 0) { return undefined; } return result.stdout; } /** * Result of running install in a temporary directory. */ export interface TempInstallResult { /** Whether the install succeeded */ success: boolean; /** The updated lock file content (if successful and lock file exists) */ lockFileContent?: string; /** Error message (if failed) */ error?: string; } /** * Generic accumulator for dependency recipes that run package manager operations. * Used by scanning recipes to track state across scanning and editing phases. * * @typeParam T The recipe-specific project update info type */ export interface DependencyRecipeAccumulator { /** Projects that need updating: packageJsonPath -> update info */ projectsToUpdate: Map; /** After running package manager, store the updated lock file content */ updatedLockFiles: Map; /** Updated package.json content (after npm install may have modified it) */ updatedPackageJsons: Map; /** Track which projects have been processed (npm install has run) */ processedProjects: Set; /** Track projects where npm install failed: packageJsonPath -> error message */ failedProjects: Map; } /** * Creates a new empty accumulator for dependency recipes. */ export function createDependencyRecipeAccumulator(): DependencyRecipeAccumulator { return { projectsToUpdate: new Map(), updatedLockFiles: new Map(), updatedPackageJsons: new Map(), processedProjects: new Set(), failedProjects: new Map() }; } /** * Checks if a source path is a lock file and returns the updated content if available. * This is a helper for dependency recipes that need to update lock files. * * @param sourcePath The source path to check * @param acc The recipe accumulator containing updated lock file content * @returns The updated lock file content if this is a lock file that was updated, undefined otherwise */ export function getUpdatedLockFileContent( sourcePath: string, acc: DependencyRecipeAccumulator ): string | undefined { for (const lockFileName of getAllLockFileNames()) { if (sourcePath.endsWith(lockFileName)) { // Find the corresponding package.json path const packageJsonPath = sourcePath.replace(lockFileName, 'package.json'); const updateInfo = acc.projectsToUpdate.get(packageJsonPath); if (updateInfo && acc.updatedLockFiles.has(sourcePath)) { return acc.updatedLockFiles.get(sourcePath); } break; } } return undefined; } /** * Determines the appropriate parser for a lock file based on its filename and optionally content. * * For yarn.lock files, the format depends on the Yarn version: * - Yarn Classic (v1): Custom plain text format * - Yarn Berry (v2+): YAML format * * If content is provided for yarn.lock, it will be used to detect the format. * Otherwise, defaults to 'text' (Yarn Classic). * * @param lockFileName The lock file name (e.g., "pnpm-lock.yaml", "package-lock.json", "yarn.lock") * @param content Optional file content (used for yarn.lock format detection) * @returns 'yaml' for YAML lock files, 'json' for JSON lock files, 'text' for plain text */ export function getLockFileFormat(lockFileName: string, content?: string): 'yaml' | 'json' | 'text' { if ((YAML_LOCK_FILE_NAMES as readonly string[]).includes(lockFileName)) { return 'yaml'; } if (lockFileName === 'yarn.lock') { // Yarn Berry (v2+) uses YAML format, Classic uses custom text format if (content && isYarnBerryLockFile(content)) { return 'yaml'; } return 'text'; } if ((TEXT_LOCK_FILE_NAMES as readonly string[]).includes(lockFileName)) { return 'text'; } // package-lock.json, bun.lock return 'json'; } /** * Re-parses updated lock file content using the appropriate parser. * This is used by dependency recipes to create the updated lock file SourceFile. * * For yarn.lock files, the content is used to detect whether it's Yarn Berry (YAML) * or Yarn Classic (plain text) format. * * @param content The updated lock file content * @param sourcePath The source path of the lock file * @param lockFileName The lock file name (e.g., "pnpm-lock.yaml", "yarn.lock") * @returns The parsed SourceFile (Json.Document, Yaml.Documents, or PlainText) */ export async function parseLockFileContent( content: string, sourcePath: string, lockFileName: string ): Promise { // Pass content to getLockFileFormat for yarn.lock detection const format = getLockFileFormat(lockFileName, content); switch (format) { case 'yaml': { const parser = new YamlParser({}); return await parser.parseOne({text: content, sourcePath}) as Yaml.Documents; } case 'text': { const parser = new PlainTextParser({}); return await parser.parseOne({text: content, sourcePath}); } case 'json': default: { const parser = new JsonParser({}); return await parser.parseOne({text: content, sourcePath}) as Json.Document; } } } /** * Base interface for project update info used by dependency recipes. * Recipes extend this with additional fields specific to their needs. */ export interface BaseProjectUpdateInfo { /** Relative path to package.json (from source root) */ packageJsonPath: string; /** The package manager used by this project */ packageManager: PackageManager; } /** * Stores the result of a package manager install into the accumulator. * This handles the common pattern of storing updated lock files and tracking failures. * * @param result The result from runInstallInTempDir * @param acc The recipe accumulator * @param updateInfo The project update info (must have packageJsonPath and packageManager) * @param modifiedPackageJson The modified package.json content that was used for install */ export function storeInstallResult( result: TempInstallResult, acc: DependencyRecipeAccumulator, updateInfo: T, modifiedPackageJson: string ): void { if (result.success) { acc.updatedPackageJsons.set(updateInfo.packageJsonPath, modifiedPackageJson); if (result.lockFileContent) { const lockFileName = getLockFileName(updateInfo.packageManager); const lockFilePath = updateInfo.packageJsonPath.replace('package.json', lockFileName); acc.updatedLockFiles.set(lockFilePath, result.lockFileContent); } } else { acc.failedProjects.set(updateInfo.packageJsonPath, result.error || 'Unknown error'); } } /** * Runs the package manager install for a project if it hasn't been processed yet. * Updates the accumulator's processedProjects set after running. * * @param sourcePath The source path (package.json path) being processed * @param acc The recipe accumulator * @param runInstall Function that performs the actual install (recipe-specific) * @returns The failure message if install failed, undefined otherwise */ export async function runInstallIfNeeded( sourcePath: string, acc: DependencyRecipeAccumulator, runInstall: () => Promise ): Promise { if (!acc.processedProjects.has(sourcePath)) { await runInstall(); acc.processedProjects.add(sourcePath); } return acc.failedProjects.get(sourcePath); } /** * Updates the NodeResolutionResult marker on a JSON document after a package manager operation. * This recreates the marker based on the updated package.json and lock file content. * * @param doc The JSON document containing the marker * @param updateInfo Project update info with paths and package manager * @param acc The recipe accumulator containing updated content * @returns The document with the updated marker, or unchanged if no existing marker */ export async function updateNodeResolutionMarker( doc: Json.Document, updateInfo: T & { originalPackageJson: string }, acc: DependencyRecipeAccumulator ): Promise { const existingMarker = findNodeResolutionResult(doc); if (!existingMarker) { return doc; } // Parse the updated package.json and lock file to create new marker const updatedPackageJson = acc.updatedPackageJsons.get(updateInfo.packageJsonPath); const lockFileName = getLockFileName(updateInfo.packageManager); const updatedLockFile = acc.updatedLockFiles.get( updateInfo.packageJsonPath.replace('package.json', lockFileName) ); let packageJsonContent: PackageJsonContent; let lockContent: PackageLockContent | undefined; try { packageJsonContent = JSON.parse(updatedPackageJson || updateInfo.originalPackageJson); } catch { return doc; // Failed to parse, keep original marker } if (updatedLockFile) { try { // Parse lock file based on format if (updateInfo.packageManager === PackageManager.Pnpm) { // pnpm-lock.yaml is YAML format lockContent = YAML.parse(updatedLockFile); } else if (updateInfo.packageManager === PackageManager.YarnClassic || updateInfo.packageManager === PackageManager.YarnBerry) { // yarn.lock has a custom format - skip parsing here // The marker will still be updated with package.json info lockContent = undefined; } else { // npm (package-lock.json) and bun (bun.lock) use JSON lockContent = JSON.parse(updatedLockFile); } } catch { // Continue without lock file content } } // Create new marker, preserving existing npmrc configs from the parser // (recipes don't have filesystem access to re-read them) const newMarker = createNodeResolutionResultMarker( existingMarker.path, packageJsonContent, lockContent, existingMarker.workspacePackagePaths, existingMarker.packageManager, existingMarker.npmrcConfigs ); // If the new marker is structurally identical to the existing one (ignoring the // auto-generated id), return doc unchanged to avoid minting a fresh AST identity // for a no-op marker update. This prevents the empty-diff guard from firing on // recipes that don't modify package.json (e.g. lock-file-only fix strategies). const {id: _existingId, ...existingRest} = existingMarker; const {id: _newId, ...newRest} = newMarker; if (isDeepStrictEqual(existingRest, newRest)) { return doc; } // Replace the marker in the document return { ...doc, markers: replaceMarkerByKind(doc.markers, newMarker) }; } /** * Options for running install in a temporary directory. */ export interface TempInstallOptions { /** Timeout in milliseconds (default: 120000 = 2 minutes) */ timeout?: number; /** * If true, only update the lock file without installing node_modules. * If false, perform a full install which creates node_modules in the temp dir. * Default: true (lock-only is faster and sufficient for most cases) */ lockOnly?: boolean; /** * Original lock file content to use. If provided, this content will be written * to the temp directory instead of copying from projectDir. * This allows recipes to work with in-memory SourceFiles without filesystem access. */ originalLockFileContent?: string; /** * Config file contents to use. Keys are filenames (e.g., '.npmrc'), values are content. * If provided, these will be written to the temp directory instead of copying from projectDir. */ configFiles?: Record; } /** * Options for running install in a temporary directory with workspace support. */ export interface WorkspaceTempInstallOptions extends TempInstallOptions { /** * Workspace package.json files. Keys are relative paths from the project root * (e.g., "packages/foo/package.json"), values are the package.json content. * The root package.json should have a "workspaces" field pointing to these packages. */ workspacePackages?: Record; } /** * Internal implementation for running package manager install in a temporary directory. * Supports both simple projects and workspaces. */ async function runInstallInTempDirCore( pm: PackageManager, rootPackageJson: string, options: WorkspaceTempInstallOptions = {} ): Promise { const { timeout = 120000, lockOnly = true, originalLockFileContent, configFiles: configFileContents, workspacePackages } = options; const lockFileName = getLockFileName(pm); const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openrewrite-pm-')); try { // Write root package.json to temp directory await fsp.writeFile(path.join(tempDir, 'package.json'), rootPackageJson); // Write workspace package.json files (creating subdirectories as needed) if (workspacePackages) { for (const [relativePath, content] of Object.entries(workspacePackages)) { const fullPath = path.join(tempDir, relativePath); await fsp.mkdir(path.dirname(fullPath), {recursive: true}); await fsp.writeFile(fullPath, content); } } // Write lock file if provided if (originalLockFileContent !== undefined) { await fsp.writeFile(path.join(tempDir, lockFileName), originalLockFileContent); } // Write config files if provided if (configFileContents) { for (const [configFile, content] of Object.entries(configFileContents)) { await fsp.writeFile(path.join(tempDir, configFile), content); } } // Run package manager install const result = runInstall(pm, { cwd: tempDir, lockOnly, timeout }); if (!result.success) { // Combine error message with stderr for more useful diagnostics const errorParts: string[] = []; if (result.error) { errorParts.push(result.error); } if (result.stderr) { // Trim and limit stderr to avoid excessively long error messages const stderr = result.stderr.trim(); if (stderr) { errorParts.push(stderr.length > 2000 ? stderr.slice(0, 2000) + '...' : stderr); } } return { success: false, error: errorParts.length > 0 ? errorParts.join('\n\n') : 'Unknown error' }; } // Read back the updated lock file const updatedLockPath = path.join(tempDir, lockFileName); let lockFileContent: string | undefined; if (fs.existsSync(updatedLockPath)) { lockFileContent = await fsp.readFile(updatedLockPath, 'utf-8'); } return { success: true, lockFileContent }; } finally { // Cleanup temp directory try { await fsp.rm(tempDir, {recursive: true, force: true}); } catch { // Ignore cleanup errors } } } /** * Runs package manager install in a temporary directory. * * This function: * 1. Creates a temp directory * 2. Writes the provided package.json content * 3. Writes the lock file content (if provided) * 4. Writes config files (if provided) * 5. Runs the package manager install * 6. Returns the updated lock file content * 7. Cleans up the temp directory * * @param pm The package manager to use * @param modifiedPackageJson The modified package.json content to use * @param options Optional settings for timeout, lock-only mode, and file contents * @returns Result containing success status and lock file content or error */ export async function runInstallInTempDir( pm: PackageManager, modifiedPackageJson: string, options: TempInstallOptions = {} ): Promise { return runInstallInTempDirCore(pm, modifiedPackageJson, options); } /** * Runs package manager install in a temporary directory with workspace support. * * This function: * 1. Creates a temp directory * 2. Writes the root package.json content * 3. Writes workspace package.json files (creating subdirectories as needed) * 4. Writes the lock file content (if provided) * 5. Writes config files (if provided) * 6. Runs the package manager install at the root * 7. Returns the updated lock file content * 8. Cleans up the temp directory * * @param pm The package manager to use * @param rootPackageJson The root package.json content (should contain "workspaces" field) * @param options Optional settings including workspace packages, timeout, lock-only mode, and file contents * @returns Result containing success status and lock file content or error */ export async function runWorkspaceInstallInTempDir( pm: PackageManager, rootPackageJson: string, options: WorkspaceTempInstallOptions = {} ): Promise { return runInstallInTempDirCore(pm, rootPackageJson, options); } /** * Creates a lock file visitor that handles updating YAML lock files (pnpm-lock.yaml). * This is a reusable component for dependency recipes. * * @param acc The recipe accumulator containing updated lock file content * @returns A YamlVisitor that updates YAML lock files */ export function createYamlLockFileVisitor( acc: DependencyRecipeAccumulator ): YamlVisitor { return new class extends YamlVisitor { protected async visitDocuments(docs: Yaml.Documents, _ctx: ExecutionContext): Promise { const sourcePath = docs.sourcePath; const updatedLockContent = getUpdatedLockFileContent(sourcePath, acc); if (updatedLockContent) { const lockFileName = path.basename(sourcePath); const parsed = await parseLockFileContent(updatedLockContent, sourcePath, lockFileName) as Yaml.Documents; // Preserve original ID for RPC compatibility return { ...docs, documents: parsed.documents, suffix: parsed.suffix } as Yaml.Documents; } return docs; } }; } /** * Creates a composite visitor that delegates to the appropriate editor based on tree type. * This handles both JSON (package-lock.json, bun.lock) and YAML (pnpm-lock.yaml) lock files. * * @param jsonEditor The JSON visitor for handling JSON files * @param acc The recipe accumulator for YAML lock file handling * @returns A TreeVisitor that handles both JSON and YAML files */ export function createLockFileEditor( jsonEditor: JsonVisitor, acc: DependencyRecipeAccumulator ): TreeVisitor { const yamlEditor = createYamlLockFileVisitor(acc); return new class extends TreeVisitor { async visit(tree: any, ctx: ExecutionContext): Promise { if (isDocuments(tree)) { return yamlEditor.visit(tree, ctx); } else if (tree && tree.kind === Json.Kind.Document) { return jsonEditor.visit(tree, ctx); } return tree; } }; }