project/lib/graph/providers/NodePackageDependencies.js

import path from "node:path";
import {readPackageUp} from "read-pkg-up";
import {readPackage} from "read-pkg";
import {promisify} from "node:util";
import fs from "graceful-fs";
const realpath = promisify(fs.realpath);
import resolve from "resolve";
const resolveModulePath = promisify(resolve);
import {getLogger} from "@ui5/logger";
const log = getLogger("graph:providers:NodePackageDependencies");

// Packages to consider:
// * https://github.com/npm/read-package-json-fast
// * https://github.com/npm/name-from-folder ?

/**
 * @public
 * @class
 * @alias @ui5/project/graph/providers/NodePackageDependencies
 */
class NodePackageDependencies {
	/**
	 * Generates a project graph from npm modules
	 *
	 * @public
	 * @param {object} options
	 * @param {string} options.cwd Directory to start searching for the root module
	 * @param {object} [options.rootConfiguration]
	 *		Configuration object to use for the root module instead of reading from a configuration file
	 * @param {string} [options.rootConfigPath]
	 *		Configuration file to use for the root module instead the default ui5.yaml
	 */
	constructor({cwd, rootConfiguration, rootConfigPath}) {
		this._cwd = cwd;
		this._rootConfiguration = rootConfiguration;
		this._rootConfigPath = rootConfigPath;
	}

	async getRootNode() {
		const rootPkg = await readPackageUp({
			cwd: this._cwd,
			normalize: false
		});

		if (!rootPkg || !rootPkg.packageJson) {
			throw new Error(
				`Failed to locate package.json for directory ${path.resolve(this._cwd)}`);
		}

		const modulePath = path.dirname(rootPkg.path);
		if (!rootPkg.packageJson.name) {
			throw new Error(`Missing or empty 'name' attribute in package.json at ${modulePath}`);
		}
		if (!rootPkg.packageJson.version) {
			throw new Error(`Missing or empty 'version' attribute in package.json at ${modulePath}`);
		}

		return {
			id: rootPkg.packageJson.name,
			version: rootPkg.packageJson.version,
			path: modulePath,
			configuration: this._rootConfiguration,
			configPath: this._rootConfigPath,
			_dependencies: await this._getDependencies(modulePath, rootPkg.packageJson, true)
		};
	}

	async getDependencies(node, workspace) {
		log.verbose(`Resolving dependencies of ${node.id}...`);
		if (!node._dependencies) {
			return [];
		}
		return Promise.all(node._dependencies.map(async ({name, optional}) => {
			const modulePath = await this._resolveModulePath(node.path, name, workspace);
			return this._getNode(modulePath, optional, name);
		}));
	}

	async _resolveModulePath(baseDir, moduleName, workspace) {
		log.verbose(`Resolving module path for '${moduleName}'...`);

		if (workspace) {
			// Check whether node can be resolved via the provided Workspace instance
			// If yes, replace the node from NodeProvider with the one from Workspace
			const workspaceNode = await workspace.getModuleByNodeId(moduleName);
			if (workspaceNode) {
				log.info(`Resolved module ${moduleName} via ${workspace.getName()} workspace ` +
					`to version ${workspaceNode.getVersion()}`);
				log.verbose(`  Resolved module ${moduleName} to path ${workspaceNode.getPath()}`);
				return workspaceNode.getPath();
			}
		}

		try {
			let packageJsonPath = await resolveModulePath(moduleName + "/package.json", {
				basedir: baseDir,
				preserveSymlinks: false
			});
			packageJsonPath = await realpath(packageJsonPath);

			const modulePath = path.dirname(packageJsonPath);
			log.verbose(`Resolved module ${moduleName} to path ${modulePath}`);
			return modulePath;
		} catch (err) {
			throw new Error(
				`Unable to locate module ${moduleName} via resolve logic: ${err.message}`);
		}
	}

	/**
	 * Resolves a Node module by reading its package.json
	 *
	 * @param {string} modulePath Path to the module.
	 * @param {boolean} optional Whether this dependency is optional.
	 * @param {string} [nameAlias] The name of the module. It's usually the same as the name definfed
	 * 	in package.json and could easily be skipped. Useful when defining dependency as an alias:
	 * 	{@link https://github.com/npm/rfcs/blob/main/implemented/0001-package-aliases.md}
	 * @returns {Promise<object>}
	 */
	async _getNode(modulePath, optional, nameAlias) {
		log.verbose(`Reading package.json in directory ${modulePath}...`);
		const packageJson = await readPackage({
			cwd: modulePath,
			normalize: false
		});

		return {
			id: nameAlias || packageJson.name,
			version: packageJson.version,
			path: modulePath,
			optional,
			_dependencies: await this._getDependencies(modulePath, packageJson)
		};
	}

	async _getDependencies(modulePath, packageJson, rootModule = false) {
		const dependencies = [];
		if (packageJson.dependencies) {
			Object.keys(packageJson.dependencies).forEach((depName) => {
				dependencies.push({
					name: depName,
					optional: false
				});
			});
		}
		if (rootModule && packageJson.devDependencies) {
			Object.keys(packageJson.devDependencies).forEach((depName) => {
				dependencies.push({
					name: depName,
					optional: false
				});
			});
		}
		if (!rootModule && packageJson.devDependencies) {
			await Promise.all(Object.keys(packageJson.devDependencies).map(async (depName) => {
				try {
					await this._resolveModulePath(modulePath, depName);
					dependencies.push({
						name: depName,
						optional: true
					});
				} catch (err) {
					// Ignore error since it's a development dependency of a non-root module
				}
			}));
		}
		if (packageJson.optionalDependencies) {
			await Promise.all(Object.keys(packageJson.optionalDependencies).map(async (depName) => {
				try {
					await this._resolveModulePath(modulePath, depName);
					dependencies.push({
						name: depName,
						optional: false
					});
				} catch (err) {
					// Ignore error since it's an optional dependency
				}
			}));
		}
		return dependencies;
	}
}

export default NodePackageDependencies;