@shopify/cli-kit
Version:
A set of utilities, interfaces, and models that are common across all the platform features
524 lines • 21.1 kB
JavaScript
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