/** * Helper functions for updating dependencies in a TypeScript project. * * @module */ import { assertObject, isObject } from "complete-common"; import path from "node:path"; import { PackageManager } from "../enums/PackageManager.js"; import { $ } from "./execa.js"; import { getFilePath, isFile } from "./file.js"; import { getJSONC } from "./jsonc.js"; import { getPackageManagerForProject, getPackageManagerInstallCommand, } from "./packageManager.js"; import { readFile } from "./readWrite.js"; const DEPENDENCY_TYPES_TO_CHECK = ["dependencies", "devDependencies"] as const; const COOLDOWN_DURATION = "7d"; /** * Helper function to run `npm-check-updates` to update the dependencies in the "package.json" file. * If there are any updates, the package manager used in the project will be automatically invoked. * * If specific versions need to be kept back, they should be placed in a "package-metadata.json" * next to the respective "package.json" file like this: * * ```json * { * "dependencies": { * "react": { * "lock-version": true, * "reason": "Docusaurus does not support the latest version of React." * } * } * } * ``` * * @param filePathOrDirPath Either the path to a "package.json" file or the path to a directory * which contains a "package.json" file. If undefined is passed, the * current working directory will be used. * @param installAfterUpdate Optional. Whether to install the new dependencies afterward, if any. * Default is true. * @param quiet Optional. Whether to suppress console output. Default is false. * @param packagesWithoutCooldown Optional. Package names that should be updated without applying * the dependency cooldown. * @returns Whether the "package.json" file was updated. */ export async function updatePackageJSONDependencies( filePathOrDirPath?: string, installAfterUpdate = true, quiet = false, packagesWithoutCooldown: readonly string[] = [], ): Promise { const packageJSONPath = await getFilePath("package.json", filePathOrDirPath); const packageRoot = path.dirname(packageJSONPath); const packagesToIgnore = await getPackagesToIgnore(packageRoot); const packageJSONChanged = await (quiet ? runNPMCheckUpdatesQuiet( packageJSONPath, packagesToIgnore, packagesWithoutCooldown, ) : runNPMCheckUpdates( packageJSONPath, packagesToIgnore, packagesWithoutCooldown, packageRoot, )); if (packageJSONChanged && installAfterUpdate) { const $$ = $({ cwd: packageRoot, stdout: quiet ? "pipe" : "inherit", stderr: quiet ? "pipe" : "inherit", }); const packageManager = (await getPackageManagerForProject(packageRoot)) ?? PackageManager.npm; const command = getPackageManagerInstallCommand(packageManager); const commandParts = command.split(" "); await $$`${commandParts}`; } return packageJSONChanged; } /** * Determine if we should skip the dependencies that are specified in the "package-metadata.json" * file. */ async function getPackagesToIgnore( packageRoot: string, ): Promise { const metadataPath = path.join(packageRoot, "package-metadata.json"); const metadataExists = await isFile(metadataPath); if (!metadataExists) { return []; } console.log( 'A "package-metadata.json" was found; looking for dependencies to ignore.', ); const metadata = await getJSONC(metadataPath); assertObject(metadata, `The "${metadataPath}" file was not an object.`); const packagesToIgnore: string[] = []; for (const dependencyType of DEPENDENCY_TYPES_TO_CHECK) { const dependenciesObject = metadata[dependencyType]; if (!isObject(dependenciesObject)) { continue; } for (const [dependencyName, dependencyObject] of Object.entries( dependenciesObject, )) { if (!isObject(dependencyObject)) { continue; } const lockVersion = dependencyObject["lock-version"]; if (lockVersion !== true) { continue; } const lockReason = dependencyObject["lock-reason"]; if (typeof lockReason === "string") { console.log( `Skipping update of ${dependencyType} of "${dependencyName}" because: ${lockReason}`, ); } packagesToIgnore.push(dependencyName); } } return packagesToIgnore; } /** * It is impossible to invoke `npm-check-updates` programmatically and get the useful CLI output: * https://github.com/raineorshine/npm-check-updates/issues/1499 * * Thus, we have to invoke it using a shell. * * @returns Whether the "package.json" file was changed by `npm-check-updates`. */ async function runNPMCheckUpdates( packageJSONPath: string, packagesToIgnore: readonly string[], packagesWithoutCooldown: readonly string[], packageRoot: string, ): Promise { const $$ = $({ cwd: packageRoot }); const oldPackageJSONString = await readFile(packageJSONPath); const packageJSON: unknown = JSON.parse(oldPackageJSONString); assertObject(packageJSON, `Failed to parse the file: ${packageJSONPath}`); const { workspaces } = packageJSON; const packageJSONHasWorkspaces = isObject(workspaces); // - We invoke the "ncu" bin alias instead of the equivalently-functional "npm-check-updates" bin. // On Windows, Bun generates manifest-less ".exe" bin shims; since "npm-check-updates.exe" // contains the substring "update", Windows' Installer Detection heuristic auto-elevates it, // causing a UAC prompt and an "EACCES" spawn failure for non-elevated processes. The "ncu" bin // name contains none of the trigger keywords, so it spawns normally. // - "--upgrade" is necessary because `npm-check-updates` will be a no-op by default (i.e., it // only displays what is upgradeable). let command = `ncu --upgrade --cooldown ${COOLDOWN_DURATION}`; const packagesToRejectWithCooldown = [ ...packagesToIgnore, ...packagesWithoutCooldown, ]; if (packagesToRejectWithCooldown.length > 0) { command += ` --reject ${packagesToRejectWithCooldown.join(",")}`; } if (packageJSONHasWorkspaces) { command += " --workspaces"; } const commandParts = command.split(" "); await $$`${commandParts}`; if (packagesWithoutCooldown.length > 0) { let noCooldownCommand = `ncu --upgrade --cooldown 0 --filter ${packagesWithoutCooldown.join(",")}`; if (packagesToIgnore.length > 0) { noCooldownCommand += ` --reject ${packagesToIgnore.join(",")}`; } if (packageJSONHasWorkspaces) { noCooldownCommand += " --workspaces"; } const noCooldownCommandParts = noCooldownCommand.split(" "); await $$`${noCooldownCommandParts}`; } const newPackageJSONString = await readFile(packageJSONPath); return oldPackageJSONString !== newPackageJSONString; } /** * Unlike the `runNPMCheckUpdates` function, we can safely run it through TypeScript when no CLI * output is required. * * @returns Whether the "package.json" file was changed by `npm-check-updates`. */ async function runNPMCheckUpdatesQuiet( packageJSONPath: string, packagesToIgnore: readonly string[], packagesWithoutCooldown: readonly string[], ): Promise { const packageJSONString = await readFile(packageJSONPath); const packageJSON: unknown = JSON.parse(packageJSONString); assertObject(packageJSON, `Failed to parse the file: ${packageJSONPath}`); const { workspaces } = packageJSON; const packageJSONHasWorkspaces = isObject(workspaces); // We need to perform a dynamic import for "npm-check-updates" since the module has side effects: // https://github.com/raineorshine/npm-check-updates/issues/1524 const npmCheckUpdates = await import("npm-check-updates"); const upgradedPackages = await npmCheckUpdates.run({ // Mitigate supply chain attacks while allowing explicitly trusted packages to update // immediately. cooldown: (packageName) => packagesWithoutCooldown.includes(packageName) ? 0 : COOLDOWN_DURATION, packageFile: packageJSONPath, reject: packagesToIgnore, upgrade: true, workspaces: packageJSONHasWorkspaces, }); if (!isObject(upgradedPackages)) { return false; } return Object.keys(upgradedPackages).length > 0; }