UNPKG

@shopify/cli-kit

Version:

A set of utilities, interfaces, and models that are common across all the platform features

524 lines • 21.1 kB
import { AbortError, BugError } from './error.js'; import { AbortController } from './abort.js'; import { captureOutput, exec } from './system.js'; import { fileExists, readFile, writeFile, findPathUp, glob } from './fs.js'; import { dirname, joinPath } from './path.js'; import { runWithTimer } from './metadata.js'; import { inferPackageManagerForGlobalCLI } from './is-global.js'; import { outputToken, outputContent, outputDebug } from '../../public/node/output.js'; import { cacheRetrieve, cacheRetrieveOrRepopulate } from '../../private/node/conf-store.js'; import latestVersion from 'latest-version'; import { SemVer, satisfies as semverSatisfies } from 'semver'; /** The name of the Yarn lock file */ export const yarnLockfile = 'yarn.lock'; /** The name of the npm lock file */ export const npmLockfile = 'package-lock.json'; /** The name of the pnpm lock file */ export const pnpmLockfile = 'pnpm-lock.yaml'; /** The name of the bun lock file */ export const bunLockfile = 'bun.lockb'; /** The name of the pnpm workspace file */ export const pnpmWorkspaceFile = 'pnpm-workspace.yaml'; /** An array containing the lockfiles from all the package managers */ export const lockfiles = [yarnLockfile, pnpmLockfile, npmLockfile, bunLockfile]; export const lockfilesByManager = { yarn: yarnLockfile, npm: npmLockfile, pnpm: pnpmLockfile, bun: bunLockfile, unknown: undefined, }; /** * A union that represents the package managers available. */ export const packageManager = ['yarn', 'npm', 'pnpm', 'bun', 'unknown']; /** * Returns an abort error that's thrown when the package manager can't be determined. * @returns An abort error. */ export class UnknownPackageManagerError extends AbortError { constructor() { super('Unknown package manager'); } } /** * Returns an abort error that's thrown when a directory that's expected to have * a package.json doesn't have it. * @param directory - The path to the directory that should contain a package.json * @returns An abort error. */ export class PackageJsonNotFoundError extends AbortError { constructor(directory) { super(outputContent `The directory ${outputToken.path(directory)} doesn't have a package.json.`); } } /** * Returns a bug error that's thrown when the lookup of the package.json traversing the directory * hierarchy up can't find a package.json * @param directory - The directory from which the traverse has been done * @returns An abort error. */ export class FindUpAndReadPackageJsonNotFoundError extends BugError { constructor(directory) { super(outputContent `Couldn't find a a package.json traversing directories from ${outputToken.path(directory)}`); } } /** * Returns the dependency manager used to run the create workflow. * @param env - The environment variables of the process in which the CLI runs. * @returns The dependency manager */ export function packageManagerFromUserAgent(env = process.env) { if (env.npm_config_user_agent?.includes('yarn')) { return 'yarn'; } else if (env.npm_config_user_agent?.includes('pnpm')) { return 'pnpm'; } else if (env.npm_config_user_agent?.includes('bun')) { return 'bun'; } else if (env.npm_config_user_agent?.includes('npm')) { return 'npm'; } return 'unknown'; } /** * Returns the dependency manager used in a directory. * @param fromDirectory - The starting directory * @returns The dependency manager */ export async function getPackageManager(fromDirectory) { let directory; let packageJson; try { directory = await captureOutput('npm', ['prefix'], { cwd: fromDirectory }); outputDebug(outputContent `Obtaining the dependency manager in directory ${outputToken.path(directory)}...`); packageJson = joinPath(directory, 'package.json'); // eslint-disable-next-line no-catch-all/no-catch-all } catch { // if problems locating directoy/package file, we use user agent instead } if (!directory || !packageJson || !(await fileExists(packageJson))) { return packageManagerFromUserAgent(); } const yarnLockPath = joinPath(directory, yarnLockfile); const pnpmLockPath = joinPath(directory, pnpmLockfile); const bunLockPath = joinPath(directory, bunLockfile); if (await fileExists(yarnLockPath)) { return 'yarn'; } else if (await fileExists(pnpmLockPath)) { return 'pnpm'; } else if (await fileExists(bunLockPath)) { return 'bun'; } else { return 'npm'; } } /** * This function traverses down a directory tree to find directories containing a package.json * and installs the dependencies if needed. To know if it's needed, it uses the "check" command * provided by dependency managers. * @param options - Options to install dependencies recursively. */ export async function installNPMDependenciesRecursively(options) { const packageJsons = await glob(joinPath(options.directory, '**/package.json'), { ignore: [joinPath(options.directory, 'node_modules/**/package.json')], cwd: options.directory, onlyFiles: true, deep: options.deep, }); const abortController = new AbortController(); try { await Promise.all(packageJsons.map(async (packageJsonPath) => { const directory = dirname(packageJsonPath); await installNodeModules({ directory, packageManager: options.packageManager, stdout: undefined, stderr: undefined, signal: abortController.signal, args: [], }); })); } catch (error) { abortController.abort(); throw error; } } export async function installNodeModules(options) { const execOptions = { cwd: options.directory, stdin: undefined, stdout: options.stdout, stderr: options.stderr, signal: options.signal, }; let args = ['install']; if (options.args) { args = args.concat(options.args); } await runWithTimer('cmd_all_timing_network_ms')(async () => { await exec(options.packageManager, args, execOptions); }); } /** * Returns the name of the package configured in its package.json * @param packageJsonPath - Path to the package.json file * @returns A promise that resolves with the name. */ export async function getPackageName(packageJsonPath) { const packageJsonContent = await readAndParsePackageJson(packageJsonPath); return packageJsonContent.name; } /** * Returns the version of the package configured in its package.json * @param packageJsonPath - Path to the package.json file * @returns A promise that resolves with the version. */ export async function getPackageVersion(packageJsonPath) { const packageJsonContent = await readAndParsePackageJson(packageJsonPath); return packageJsonContent.version; } /** * Returns the list of production and dev dependencies of a package.json * @param packageJsonPath - Path to the package.json file * @returns A promise that resolves with the list of dependencies. */ export async function getDependencies(packageJsonPath) { const packageJsonContent = await readAndParsePackageJson(packageJsonPath); const dependencies = packageJsonContent.dependencies ?? {}; const devDependencies = packageJsonContent.devDependencies ?? {}; return { ...dependencies, ...devDependencies }; } /** * Returns true if the app uses workspaces, false otherwise. * @param packageJsonPath - Path to the package.json file * @param pnpmWorkspacePath - Path to the pnpm-workspace.yaml file * @returns A promise that resolves with true if the app uses workspaces, false otherwise. */ export async function usesWorkspaces(appDirectory) { const packageJsonPath = joinPath(appDirectory, 'package.json'); const packageJsonContent = await readAndParsePackageJson(packageJsonPath); const pnpmWorkspacePath = joinPath(appDirectory, pnpmWorkspaceFile); return Boolean(packageJsonContent.workspaces) || fileExists(pnpmWorkspacePath); } /** * Given an NPM dependency, it checks if there's a more recent version, and if there is, it returns its value. * @param dependency - The dependency name (e.g. react) * @param currentVersion - The current version. * @param cacheExpiryInHours - If the last check was done more than this amount of hours ago, it will * refresh the cache. Defaults to always refreshing. * @returns A promise that resolves with a more recent version or undefined if there's no more recent version. */ export async function checkForNewVersion(dependency, currentVersion, { cacheExpiryInHours = 0 } = {}) { const getLatestVersion = async () => { outputDebug(outputContent `Checking if there's a version of ${dependency} newer than ${currentVersion}`); return getLatestNPMPackageVersion(dependency); }; const cacheKey = `npm-package-${dependency}`; let lastVersion; try { lastVersion = await cacheRetrieveOrRepopulate(cacheKey, getLatestVersion, cacheExpiryInHours * 3600 * 1000); // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { return undefined; } if (lastVersion && new SemVer(currentVersion).compare(lastVersion) < 0) { return lastVersion; } else { return undefined; } } /** * Given an NPM dependency, it checks if there's a cached more recent version, and if there is, it returns its value. * @param dependency - The dependency name (e.g. react) * @param currentVersion - The current version. * @returns A more recent version or undefined if there's no more recent version. */ export function checkForCachedNewVersion(dependency, currentVersion) { const cacheKey = `npm-package-${dependency}`; const lastVersion = cacheRetrieve(cacheKey)?.value; if (lastVersion && new SemVer(currentVersion).compare(lastVersion) < 0) { return lastVersion; } else { return undefined; } } /** * Utility function used to check whether a package version satisfies some requirements * @param version - The version to check * @param requirements - The requirements to check against, e.g. "\>=1.0.0" - see https://www.npmjs.com/package/semver#ranges * @returns A boolean indicating whether the version satisfies the requirements */ export function versionSatisfies(version, requirements) { return semverSatisfies(version, requirements); } /** * Reads and parses a package.json * @param packageJsonPath - Path to the package.json * @returns An promise that resolves with an in-memory representation * of the package.json or rejects with an error if the file is not found or the content is * not decodable. */ export async function readAndParsePackageJson(packageJsonPath) { if (!(await fileExists(packageJsonPath))) { throw new PackageJsonNotFoundError(dirname(packageJsonPath)); } return JSON.parse(await readFile(packageJsonPath)); } /** * Adds dependencies to a Node project (i.e. a project that has a package.json) * @param dependencies - List of dependencies to be added. * @param options - Options for adding dependencies. */ export async function addNPMDependenciesIfNeeded(dependencies, options) { outputDebug(outputContent `Adding the following dependencies if needed: ${outputToken.json(dependencies)} With options: ${outputToken.json(options)} `); const packageJsonPath = joinPath(options.directory, 'package.json'); if (!(await fileExists(packageJsonPath))) { throw new PackageJsonNotFoundError(options.directory); } const existingDependencies = Object.keys(await getDependencies(packageJsonPath)); const dependenciesToAdd = dependencies.filter((dep) => { return !existingDependencies.includes(dep.name); }); if (dependenciesToAdd.length === 0) { return; } await addNPMDependencies(dependenciesToAdd, options); } export async function addNPMDependencies(dependencies, options) { const dependenciesWithVersion = dependencies.map((dep) => { return dep.version ? `${dep.name}@${dep.version}` : dep.name; }); options.stdout?.write(`Installing ${[dependenciesWithVersion].join(' ')} with ${options.packageManager}`); switch (options.packageManager) { case 'npm': // npm isn't too smart when resolving the dependency tree. For example, admin ui extensions include react as // a peer dependency, but npm can't figure out the relationship and fails. Installing dependencies one by one // makes the task easier and npm can then proceed. for (const dep of dependenciesWithVersion) { // eslint-disable-next-line no-await-in-loop await installDependencies(options, argumentsToAddDependenciesWithNPM(dep, options.type)); } break; case 'yarn': await installDependencies(options, argumentsToAddDependenciesWithYarn(dependenciesWithVersion, options.type, Boolean(options.addToRootDirectory))); break; case 'pnpm': await installDependencies(options, argumentsToAddDependenciesWithPNPM(dependenciesWithVersion, options.type, Boolean(options.addToRootDirectory))); break; case 'bun': await installDependencies(options, argumentsToAddDependenciesWithBun(dependenciesWithVersion, options.type)); await installDependencies(options, ['install']); break; case 'unknown': throw new UnknownPackageManagerError(); } } async function installDependencies(options, args) { return runWithTimer('cmd_all_timing_network_ms')(async () => { return exec(options.packageManager, args, { cwd: options.directory, stdout: options.stdout, stderr: options.stderr, signal: options.signal, }); }); } export async function addNPMDependenciesWithoutVersionIfNeeded(dependencies, options) { await addNPMDependenciesIfNeeded(dependencies.map((dependency) => { return { name: dependency, version: undefined }; }), options); } /** * Returns the arguments to add dependencies using NPM. * @param dependencies - The list of dependencies to add * @param type - The dependency type. * @returns An array with the arguments. */ function argumentsToAddDependenciesWithNPM(dependency, type) { let command = ['install']; command = command.concat(dependency); switch (type) { case 'dev': command.push('--save-dev'); break; case 'peer': command.push('--save-peer'); break; case 'prod': command.push('--save-prod'); break; } // NPM adds ^ to the installed version by default. We want to install exact versions unless specified otherwise. if (dependency.match(/@\d/g)) { command.push('--save-exact'); } return command; } /** * Returns the arguments to add dependencies using Yarn. * @param dependencies - The list of dependencies to add * @param type - The dependency type. * @param addAtRoot - Force to install the dependencies in the workspace root (optional, default = false) * @returns An array with the arguments. */ function argumentsToAddDependenciesWithYarn(dependencies, type, addAtRoot = false) { let command = ['add']; if (addAtRoot) { command.push('-W'); } command = command.concat(dependencies); switch (type) { case 'dev': command.push('--dev'); break; case 'peer': command.push('--peer'); break; case 'prod': command.push('--prod'); break; } return command; } /** * Returns the arguments to add dependencies using PNPM. * @param dependencies - The list of dependencies to add * @param type - The dependency type. * @param addAtRoot - Force to install the dependencies in the workspace root (optional, default = false) * @returns An array with the arguments. */ function argumentsToAddDependenciesWithPNPM(dependencies, type, addAtRoot = false) { let command = ['add']; if (addAtRoot) { command.push('-w'); } command = command.concat(dependencies); switch (type) { case 'dev': command.push('--save-dev'); break; case 'peer': command.push('--save-peer'); break; case 'prod': command.push('--save-prod'); break; } return command; } /** * Returns the arguments to add dependencies using Bun. * @param dependencies - The list of dependencies to add * @param type - The dependency type. * @returns An array with the arguments. */ function argumentsToAddDependenciesWithBun(dependencies, type) { let command = ['add']; command = command.concat(dependencies); switch (type) { case 'dev': command.push('--development'); break; case 'peer': command.push('--optional'); break; case 'prod': break; } return command; } /** * Given a directory it traverses the directory up looking for a package.json and if found, it reads it * decodes the JSON, and returns its content as a Javascript object. * @param options - The directory from which traverse up. * @returns If found, the promise resolves with the path to the * package.json and its content. If not found, it throws a FindUpAndReadPackageJsonNotFoundError error. */ export async function findUpAndReadPackageJson(fromDirectory) { const packageJsonPath = await findPathUp('package.json', { cwd: fromDirectory, type: 'file' }); if (packageJsonPath) { const packageJson = JSON.parse(await readFile(packageJsonPath)); return { path: packageJsonPath, content: packageJson }; } else { throw new FindUpAndReadPackageJsonNotFoundError(fromDirectory); } } export async function addResolutionOrOverride(directory, dependencies) { const packageManager = await getPackageManager(directory); const packageJsonPath = joinPath(directory, 'package.json'); const packageJsonContent = await readAndParsePackageJson(packageJsonPath); if (packageManager === 'yarn') { packageJsonContent.resolutions = packageJsonContent.resolutions ? { ...packageJsonContent.resolutions, ...dependencies } : dependencies; } if (packageManager === 'npm' || packageManager === 'pnpm' || packageManager === 'bun') { packageJsonContent.overrides = packageJsonContent.overrides ? { ...packageJsonContent.overrides, ...dependencies } : dependencies; } await writeFile(packageJsonPath, JSON.stringify(packageJsonContent, null, 2)); } /** * Returns the latest available version of an NPM package. * @param name - The name of the NPM package. * @returns A promise to get the latest available version of a package. */ async function getLatestNPMPackageVersion(name) { outputDebug(outputContent `Getting the latest version of NPM package: ${outputToken.raw(name)}`); return runWithTimer('cmd_all_timing_network_ms')(() => { return latestVersion(name); }); } /** * Writes the package.json file to the given directory. * * @param directory - Directory where the package.json file will be written. * @param packageJSON - Package.json file to write. */ export async function writePackageJSON(directory, packageJSON) { outputDebug(outputContent `JSON-encoding and writing content to package.json at ${outputToken.path(directory)}...`); const packagePath = joinPath(directory, 'package.json'); await writeFile(packagePath, JSON.stringify(packageJSON, null, 2)); } /** * Infers the package manager to be used based on the provided options and environment. * * This function determines the package manager in the following order of precedence: * 1. Uses the package manager specified in the options, if valid. * 2. Infers the package manager from the user agent string. * 3. Infers the package manager used for the global CLI installation. * 4. Defaults to 'npm' if no other method succeeds. * * @param optionsPackageManager - The package manager specified in the options (if any). * @returns The inferred package manager as a PackageManager type. */ export function inferPackageManager(optionsPackageManager, env = process.env) { if (optionsPackageManager && packageManager.includes(optionsPackageManager)) { return optionsPackageManager; } const usedPackageManager = packageManagerFromUserAgent(env); if (usedPackageManager !== 'unknown') return usedPackageManager; const globalPackageManager = inferPackageManagerForGlobalCLI(); if (globalPackageManager !== 'unknown') return globalPackageManager; return 'npm'; } //# sourceMappingURL=node-package-manager.js.map