/** * Utility functions for working with npm packages and versions. * * @import { ExecOptions } from "node:child_process" */ import * as fs from "node:fs/promises"; import { dirname, join } from "node:path"; import { $ } from "./commands.mjs"; /** * Find the nearest directory containing both package.json and package-lock.json, * starting from the given directory and walking upward. * * @param {string} start The directory to start searching from. * @returns {Promise<{ packageJSONPath: string, packageLockPath: string }>} * @throws {Error} If no co-located package.json and package-lock.json are found. */ export async function findNPMPackage(start) { let currentDir = start; while (currentDir !== dirname(currentDir)) { const packageJSONPath = join(currentDir, "package.json"); const packageLockPath = join(currentDir, "package-lock.json"); try { await Promise.all([fs.access(packageJSONPath), fs.access(packageLockPath)]); return { packageJSONPath, packageLockPath, }; } catch { // Continue searching up the directory tree } currentDir = dirname(currentDir); } throw new Error(`No co-located package.json and package-lock.json found above ${start}`); } /** * @typedef {object} PackageJSON * @property {string} name * @property {string} version * @property {Record} [dependencies] * @property {Record} [devDependencies] * @property {Record} [peerDependencies] * @property {Record} [optionalDependencies] * @property {Record} [peerDependenciesMeta] * @property {Record} [engines] * @property {Record} [devEngines] * @property {string} [packageManager] */ /** * @param {string} jsonPath * @returns {Promise} */ export function loadJSON(jsonPath) { return fs .readFile(jsonPath, "utf-8") .then(JSON.parse) .catch((cause) => { throw new Error(`Failed to load JSON file at ${jsonPath}`, { cause }); }); } const PackageJSONComparisionFields = /** @type {const} */ ([ "name", "dependencies", "devDependencies", "optionalDependencies", "peerDependencies", "peerDependenciesMeta", ]); /** * @typedef {typeof PackageJSONComparisionFields[number]} PackageJSONComparisionField */ /** * Extracts only the dependency fields from a package.json object for comparison purposes. * * @param {PackageJSON} data * @returns {Pick} */ export function pluckDependencyFields(data) { /** * @type {Record} */ const result = {}; for (const field of PackageJSONComparisionFields) { if (data[field]) { result[field] = data[field]; } } return /** @type {Pick} */ (result); } //#region Versioning /** * Compares two semantic version strings (e.g., "14.17.0"). * * @param {string} a The first version string. * @param {string} b The second version string. * @returns {number} */ export function compareVersions(a, b) { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); for (let i = 0; i < 3; i++) { if (pa[i] > pb[i]) return 1; if (pa[i] < pb[i]) return -1; } return 0; } /** * Runs a Node.js command and returns its stdout output as a string. * * @param {TemplateStringsArray} strings * @param {...unknown} expressions * @returns {(options?: ExecOptions) => Promise} */ export const node = $.bind("node"); /** * @typedef {object} NPMCommandOptions * @property {boolean} [useCorepack] Whether to prefix the command with "corepack " to use Corepack's shims. * @returns {Promise} */ /** * Runs an npm command and returns its stdout output as a string. * * @param {TemplateStringsArray} strings * @param {...unknown} expressions * @returns {(options?: ExecOptions & NPMCommandOptions) => Promise} */ export function npm(strings, ...expressions) { const subcommand = String.raw(strings, ...expressions); return ({ useCorepack, ...options } = {}) => { const command = [useCorepack ? "corepack" : "", "npm", subcommand] .filter(Boolean) .join(" "); return $`${command}`(options); }; } /** * Parses a version range string, stripping any leading >= and normalizing to three parts. * @param {string} range * @returns {{ operator: ">=" | "=", version: string }} */ export function parseRange(range) { const hasGte = range.startsWith(">="); const raw = hasGte ? range.slice(2) : range; const parts = raw.split(".").map(Number); while (parts.length < 3) parts.push(0); return { operator: hasGte ? ">=" : "=", version: parts.join("."), }; } //#endregion