nx/packages/devkit/src/utils/package-json.ts

591 lines
17 KiB
TypeScript

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<string, string>,
existingAltDependencies: Record<string, string>
) {
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<string, string>,
existingAltDependencies: Record<string, string>
) {
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<string, string>,
existingDependencies: Record<string, string> = {}
) {
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<string, string>,
devDependencies: Record<string, string>,
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<string, string>,
existingDeps: Record<string, string>
) {
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<string, string>,
existingDeps: Record<string, string>
): Record<string, string> {
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<T>(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<string, any>();
/**
* @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@<nxVersion> 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<T extends any = any>(
pkg: string,
version: string
): T;
export function ensurePackage<T extends any = any>(
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');