import { execSync } from 'child_process'; import { Module } from 'module'; import type { Tree } from 'nx/src/generators/tree'; import type { GeneratorCallback } from 'nx/src/config/misc-interfaces'; import { clean, coerce, gt } from 'semver'; import { installPackagesTask } from '../tasks/install-packages-task'; import { requireNx } from '../../nx'; import { dirSync } from 'tmp'; import { join } from 'path'; import type { PackageManager } from 'nx/src/utils/package-manager'; import { writeFileSync } from 'fs'; const { readJson, updateJson, getPackageManagerCommand, workspaceRoot, detectPackageManager, createTempNpmDirectory, getPackageManagerVersion, } = requireNx(); const UNIDENTIFIED_VERSION = 'UNIDENTIFIED_VERSION'; const NON_SEMVER_TAGS = { '*': 2, [UNIDENTIFIED_VERSION]: 2, next: 1, latest: 0, previous: -1, legacy: -2, }; function filterExistingDependencies( dependencies: Record, existingAltDependencies: Record ) { if (!existingAltDependencies) { return dependencies; } return Object.keys(dependencies ?? {}) .filter((d) => !existingAltDependencies[d]) .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {}); } function cleanSemver(version: string) { return clean(version) ?? coerce(version); } function isIncomingVersionGreater( incomingVersion: string, existingVersion: string ) { // if version is in the format of "latest", "next" or similar - keep it, otherwise try to parse it const incomingVersionCompareBy = incomingVersion in NON_SEMVER_TAGS ? incomingVersion : cleanSemver(incomingVersion)?.toString() ?? UNIDENTIFIED_VERSION; const existingVersionCompareBy = existingVersion in NON_SEMVER_TAGS ? existingVersion : cleanSemver(existingVersion)?.toString() ?? UNIDENTIFIED_VERSION; if ( incomingVersionCompareBy in NON_SEMVER_TAGS && existingVersionCompareBy in NON_SEMVER_TAGS ) { return ( NON_SEMVER_TAGS[incomingVersionCompareBy] > NON_SEMVER_TAGS[existingVersionCompareBy] ); } if ( incomingVersionCompareBy in NON_SEMVER_TAGS || existingVersionCompareBy in NON_SEMVER_TAGS ) { return true; } return gt(cleanSemver(incomingVersion), cleanSemver(existingVersion)); } function updateExistingAltDependenciesVersion( dependencies: Record, existingAltDependencies: Record ) { return Object.keys(existingAltDependencies || {}) .filter((d) => { if (!dependencies[d]) { return false; } const incomingVersion = dependencies[d]; const existingVersion = existingAltDependencies[d]; return isIncomingVersionGreater(incomingVersion, existingVersion); }) .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {}); } function updateExistingDependenciesVersion( dependencies: Record, existingDependencies: Record = {} ) { return Object.keys(dependencies) .filter((d) => { if (!existingDependencies[d]) { return true; } const incomingVersion = dependencies[d]; const existingVersion = existingDependencies[d]; return isIncomingVersionGreater(incomingVersion, existingVersion); }) .reduce((acc, d) => ({ ...acc, [d]: dependencies[d] }), {}); } /** * Add Dependencies and Dev Dependencies to package.json * * For example: * ```typescript * addDependenciesToPackageJson(tree, { react: 'latest' }, { jest: 'latest' }) * ``` * This will **add** `react` and `jest` to the dependencies and devDependencies sections of package.json respectively. * * @param tree Tree representing file system to modify * @param dependencies Dependencies to be added to the dependencies section of package.json * @param devDependencies Dependencies to be added to the devDependencies section of package.json * @param packageJsonPath Path to package.json * @param keepExistingVersions If true, prevents existing dependencies from being bumped to newer versions * @returns Callback to install dependencies only if necessary, no-op otherwise */ export function addDependenciesToPackageJson( tree: Tree, dependencies: Record, devDependencies: Record, packageJsonPath: string = 'package.json', keepExistingVersions?: boolean ): GeneratorCallback { const currentPackageJson = readJson(tree, packageJsonPath); /** Dependencies to install that are not met in dev dependencies */ let filteredDependencies = filterExistingDependencies( dependencies, currentPackageJson.devDependencies ); /** Dev dependencies to install that are not met in dependencies */ let filteredDevDependencies = filterExistingDependencies( devDependencies, currentPackageJson.dependencies ); // filtered dependencies should consist of: // - dependencies of the same type that are not present // by default, filtered dependencies also include these (unless keepExistingVersions is true): // - dependencies of the same type that have greater version // - specified dependencies of the other type that have greater version and are already installed as current type filteredDependencies = { ...updateExistingDependenciesVersion( filteredDependencies, currentPackageJson.dependencies ), ...updateExistingAltDependenciesVersion( devDependencies, currentPackageJson.dependencies ), }; filteredDevDependencies = { ...updateExistingDependenciesVersion( filteredDevDependencies, currentPackageJson.devDependencies ), ...updateExistingAltDependenciesVersion( dependencies, currentPackageJson.devDependencies ), }; if (keepExistingVersions) { filteredDependencies = removeExistingDependencies( filteredDependencies, currentPackageJson.dependencies ); filteredDevDependencies = removeExistingDependencies( filteredDevDependencies, currentPackageJson.devDependencies ); } else { filteredDependencies = removeLowerVersions( filteredDependencies, currentPackageJson.dependencies ); filteredDevDependencies = removeLowerVersions( filteredDevDependencies, currentPackageJson.devDependencies ); } if ( requiresAddingOfPackages( currentPackageJson, filteredDependencies, filteredDevDependencies ) ) { updateJson(tree, packageJsonPath, (json) => { json.dependencies = { ...(json.dependencies || {}), ...filteredDependencies, }; json.devDependencies = { ...(json.devDependencies || {}), ...filteredDevDependencies, }; json.dependencies = sortObjectByKeys(json.dependencies); json.devDependencies = sortObjectByKeys(json.devDependencies); return json; }); return (): void => { installPackagesTask(tree); }; } return () => {}; } /** * @returns The the incoming dependencies that are higher than the existing verions **/ function removeLowerVersions( incomingDeps: Record, existingDeps: Record ) { return Object.keys(incomingDeps).reduce((acc, d) => { if ( !existingDeps?.[d] || isIncomingVersionGreater(incomingDeps[d], existingDeps[d]) ) { acc[d] = incomingDeps[d]; } return acc; }, {}); } function removeExistingDependencies( incomingDeps: Record, existingDeps: Record ): Record { return Object.keys(incomingDeps).reduce((acc, d) => { if (!existingDeps?.[d]) { acc[d] = incomingDeps[d]; } return acc; }, {}); } /** * Remove Dependencies and Dev Dependencies from package.json * * For example: * ```typescript * removeDependenciesFromPackageJson(tree, ['react'], ['jest']) * ``` * This will **remove** `react` and `jest` from the dependencies and devDependencies sections of package.json respectively. * * @param dependencies Dependencies to be removed from the dependencies section of package.json * @param devDependencies Dependencies to be removed from the devDependencies section of package.json * @returns Callback to uninstall dependencies only if necessary. undefined is returned if changes are not necessary. */ export function removeDependenciesFromPackageJson( tree: Tree, dependencies: string[], devDependencies: string[], packageJsonPath: string = 'package.json' ): GeneratorCallback { const currentPackageJson = readJson(tree, packageJsonPath); if ( requiresRemovingOfPackages( currentPackageJson, dependencies, devDependencies ) ) { updateJson(tree, packageJsonPath, (json) => { for (const dep of dependencies) { delete json.dependencies[dep]; } for (const devDep of devDependencies) { delete json.devDependencies[devDep]; } json.dependencies = sortObjectByKeys(json.dependencies); json.devDependencies = sortObjectByKeys(json.devDependencies); return json; }); } return (): void => { installPackagesTask(tree); }; } function sortObjectByKeys(obj: T): T { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { return obj; } return Object.keys(obj) .sort() .reduce((result, key) => { return { ...result, [key]: obj[key], }; }, {}) as T; } /** * Verifies whether the given packageJson dependencies require an update * given the deps & devDeps passed in */ function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean { let needsDepsUpdate = false; let needsDevDepsUpdate = false; packageJsonFile.dependencies = packageJsonFile.dependencies || {}; packageJsonFile.devDependencies = packageJsonFile.devDependencies || {}; if (Object.keys(deps).length > 0) { needsDepsUpdate = Object.keys(deps).some((entry) => { const incomingVersion = deps[entry]; if (packageJsonFile.dependencies[entry]) { const existingVersion = packageJsonFile.dependencies[entry]; return isIncomingVersionGreater(incomingVersion, existingVersion); } if (packageJsonFile.devDependencies[entry]) { const existingVersion = packageJsonFile.devDependencies[entry]; return isIncomingVersionGreater(incomingVersion, existingVersion); } return true; }); } if (Object.keys(devDeps).length > 0) { needsDevDepsUpdate = Object.keys(devDeps).some((entry) => { const incomingVersion = devDeps[entry]; if (packageJsonFile.devDependencies[entry]) { const existingVersion = packageJsonFile.devDependencies[entry]; return isIncomingVersionGreater(incomingVersion, existingVersion); } if (packageJsonFile.dependencies[entry]) { const existingVersion = packageJsonFile.dependencies[entry]; return isIncomingVersionGreater(incomingVersion, existingVersion); } return true; }); } return needsDepsUpdate || needsDevDepsUpdate; } /** * Verifies whether the given packageJson dependencies require an update * given the deps & devDeps passed in */ function requiresRemovingOfPackages( packageJsonFile, deps: string[], devDeps: string[] ): boolean { let needsDepsUpdate = false; let needsDevDepsUpdate = false; packageJsonFile.dependencies = packageJsonFile.dependencies || {}; packageJsonFile.devDependencies = packageJsonFile.devDependencies || {}; if (deps.length > 0) { needsDepsUpdate = deps.some((entry) => packageJsonFile.dependencies[entry]); } if (devDeps.length > 0) { needsDevDepsUpdate = devDeps.some( (entry) => packageJsonFile.devDependencies[entry] ); } return needsDepsUpdate || needsDevDepsUpdate; } const packageMapCache = new Map(); /** * @typedef EnsurePackageOptions * @type {object} * @property {boolean} dev indicate if the package is a dev dependency * @property {throwOnMissing} boolean throws an error when the package is missing */ /** * @deprecated Use the other function signature without a Tree * * Use a package that has not been installed as a dependency. * * For example: * ```typescript * ensurePackage(tree, '@nx/jest', nxVersion) * ``` * This install the @nx/jest@ and return the module * When running with --dryRun, the function will throw when dependencies are missing. * Returns null for ESM dependencies. Import them with a dynamic import instead. * * @param tree the file system tree * @param pkg the package to check (e.g. @nx/jest) * @param requiredVersion the version or semver range to check (e.g. ~1.0.0, >=1.0.0 <2.0.0) * @param {EnsurePackageOptions} options? */ export function ensurePackage( tree: Tree, pkg: string, requiredVersion: string, options?: { dev?: boolean; throwOnMissing?: boolean } ): void; /** * Ensure that dependencies and devDependencies from package.json are installed at the required versions. * Returns null for ESM dependencies. Import them with a dynamic import instead. * * For example: * ```typescript * ensurePackage('@nx/jest', nxVersion) * ``` * * @param pkg the package to install and require * @param version the version to install if the package doesn't exist already */ export function ensurePackage( pkg: string, version: string ): T; export function ensurePackage( pkgOrTree: string | Tree, requiredVersionOrPackage: string, maybeRequiredVersion?: string, _?: never ): T { let pkg: string; let requiredVersion: string; if (typeof pkgOrTree === 'string') { pkg = pkgOrTree; requiredVersion = requiredVersionOrPackage; } else { // Old Signature pkg = requiredVersionOrPackage; requiredVersion = maybeRequiredVersion; } if (packageMapCache.has(pkg)) { return packageMapCache.get(pkg) as T; } try { return require(pkg); } catch (e) { if (e.code === 'ERR_REQUIRE_ESM') { // The package is installed, but is an ESM package. // The consumer of this function can import it as needed. return null; } else if (e.code !== 'MODULE_NOT_FOUND') { throw e; } } if (process.env.NX_DRY_RUN && process.env.NX_DRY_RUN !== 'false') { throw new Error( 'NOTE: This generator does not support --dry-run. If you are running this in Nx Console, it should execute fine once you hit the "Run" button.\n' ); } const { dir: tempDir } = createTempNpmDirectory?.() ?? { dir: dirSync().name, }; console.log(`Fetching ${pkg}...`); const packageManager = detectPackageManager(); const isVerbose = process.env.NX_VERBOSE_LOGGING === 'true'; generatePackageManagerFiles(tempDir, packageManager); const preInstallCommand = getPackageManagerCommand(packageManager).preInstall; if (preInstallCommand) { // ensure package.json and repo in tmp folder is set to a proper package manager state execSync(preInstallCommand, { cwd: tempDir, stdio: isVerbose ? 'inherit' : 'ignore', }); } let addCommand = getPackageManagerCommand(packageManager).addDev; if (packageManager === 'pnpm') { addCommand = 'pnpm add -D'; // we need to ensure that we are not using workspace command } execSync(`${addCommand} ${pkg}@${requiredVersion}`, { cwd: tempDir, stdio: isVerbose ? 'inherit' : 'ignore', }); addToNodePath(join(workspaceRoot, 'node_modules')); addToNodePath(join(tempDir, 'node_modules')); // Re-initialize the added paths into require (Module as any)._initPaths(); try { const result = require(require.resolve(pkg, { paths: [tempDir], })); packageMapCache.set(pkg, result); return result; } catch (e) { if (e.code === 'ERR_REQUIRE_ESM') { // The package is installed, but is an ESM package. // The consumer of this function can import it as needed. packageMapCache.set(pkg, null); return null; } throw e; } } /** * Generates necessary files needed for the package manager to work * and for the node_modules to be accessible. */ function generatePackageManagerFiles( root: string, packageManager: PackageManager = detectPackageManager() ) { const [pmMajor] = getPackageManagerVersion(packageManager).split('.'); switch (packageManager) { case 'yarn': if (+pmMajor >= 2) { writeFileSync( join(root, '.yarnrc.yml'), 'nodeLinker: node-modules\nenableScripts: false' ); } break; } } function addToNodePath(dir: string) { // NODE_PATH is a delimited list of paths. // The delimiter is different for windows. const delimiter = require('os').platform() === 'win32' ? ';' : ':'; const paths = process.env.NODE_PATH ? process.env.NODE_PATH.split(delimiter) : []; // The path is already in the node path if (paths.includes(dir)) { return; } // Add the tmp path paths.push(dir); // Update the env variable. process.env.NODE_PATH = paths.join(delimiter); } function getPackageVersion(pkg: string): string { return require(join(pkg, 'package.json')).version; } /** * @description The version of Nx used by the workspace. Returns null if no version is found. */ export const NX_VERSION = getPackageVersion('nx');