From da572834fd036d1db894e817dde9d09b5fde03af Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:41:45 +0100 Subject: [PATCH] Flesh out node scripts. --- scripts/node/lint-lockfile.mjs | 278 ++++++++++++++++++++++++++++++++ scripts/node/lint-runtime.mjs | 114 +++++++++++++ scripts/node/setup-corepack.mjs | 94 +++++++++++ scripts/node/utils/commands.mjs | 116 +++++++++++++ scripts/node/utils/corepack.mjs | 84 ++++++++++ scripts/node/utils/git.mjs | 25 +++ scripts/node/utils/node.mjs | 175 ++++++++++++++++++++ 7 files changed, 886 insertions(+) create mode 100755 scripts/node/lint-lockfile.mjs create mode 100755 scripts/node/lint-runtime.mjs create mode 100755 scripts/node/setup-corepack.mjs create mode 100644 scripts/node/utils/commands.mjs create mode 100644 scripts/node/utils/corepack.mjs create mode 100644 scripts/node/utils/git.mjs create mode 100644 scripts/node/utils/node.mjs diff --git a/scripts/node/lint-lockfile.mjs b/scripts/node/lint-lockfile.mjs new file mode 100755 index 0000000000..1b48f86e12 --- /dev/null +++ b/scripts/node/lint-lockfile.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * @file Lints the package-lock.json file to ensure it is in sync with package.json. + * + * Usage: + * lint-lockfile [options] [directory] + * + * Options: + * --warn Report issues as warnings instead of failing. The lockfile is + * still regenerated on disk, but the process exits 0. + * + * Exit codes: + * 0 Lockfile is in sync (or --warn was passed) + * 1 Unexpected error + * 2 Lockfile drift detected + */ + +/// + +import * as assert from "node:assert/strict"; +import { findPackageJSON } from "node:module"; +import { dirname } from "node:path"; +import { isDeepStrictEqual, parseArgs } from "node:util"; + +import { ConsoleLogger } from "../../packages/logger-js/lib/node.js"; +import { parseCWD, reportAndExit } from "./utils/commands.mjs"; +import { corepack } from "./utils/corepack.mjs"; +import { gitStatus } from "./utils/git.mjs"; +import { findNPMPackage, loadJSON, npm, pluckDependencyFields } from "./utils/node.mjs"; + +//#region Constants + +const logger = ConsoleLogger.prefix("lint:lockfile"); + +const { values: options, positionals } = parseArgs({ + options: { + "warn": { + type: "boolean", + default: false, + description: "Report issues as warnings instead of failing", + }, + "skip-git": { + type: "boolean", + default: !!process.env.CI, + description: + "Skip checking for uncommitted changes (use with --warn to ignore drift without reporting)", + }, + }, + allowPositionals: true, +}); + +const cwd = parseCWD(positionals); + +const ignoredProperties = new Set([ + // --- + "peer", + "engines", + "optional", +]); + +//#region Utilities + +/** + * @param {Record} actual + * @param {Record} expected + * @param {string[]} [prefix] + * @returns {Set[]} + */ +function extractDiffedProperties(actual, expected, prefix = []) { + const a = actual ?? {}; + const b = expected ?? {}; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + /** @type {Set[]} */ + const diffs = []; + + for (const key of keys) { + const path = [...prefix, key]; + const valA = a[key]; + const valB = b[key]; + + if ( + valA !== null && + valB !== null && + typeof valA === "object" && + typeof valB === "object" && + !Array.isArray(valA) && + !Array.isArray(valB) + ) { + // @ts-ignore + diffs.push(...extractDiffedProperties(valA, valB, path)); + } else if (!isDeepStrictEqual(valA, valB)) { + diffs.push(new Set(path)); + } + } + + return diffs; +} + +//#endregion + +/** + * Exit code when lockfile drift is detected (distinct from general errors) + */ +const EXIT_DRIFT = 2; + +/** + * @returns {Promise} The list of issues detected. + */ +async function run() { + /** @type {string[]} */ + const issues = []; + + /** + * Records an issue. In strict mode, throws immediately. + * In warn mode, collects the message for later reporting. + * + * @param {boolean} ok + * @param {string} message + */ + const check = (ok, message) => { + if (ok) return; + + if (options.warn) { + issues.push(message); + return; + } + + assert.fail(message); + }; + + /** + * Checks deep equality of two values. In strict mode, throws if they are not equal. + * In warn mode, records an issue instead. + * + * @param {unknown} actual + * @param {unknown} expected + * @param {string} message + */ + const checkDeep = (actual, expected, message) => { + if (options.warn) { + if (!isDeepStrictEqual(actual, expected)) { + issues.push(message); + } + + return; + } + + assert.deepStrictEqual(actual, expected, message); + }; + + logger.info(`Linting lockfile integrity in: ${cwd}`); + + // MARK: Locate files + + const resolvedPath = import.meta.resolve(cwd); + const packageJSONPath = findPackageJSON(resolvedPath); + + assert.ok( + packageJSONPath, + "Could not find package.json in the current directory or any parent directories", + ); + + const packageDir = dirname(packageJSONPath); + const { packageLockPath } = await findNPMPackage(packageDir); + const lockfileDir = dirname(packageLockPath); + const isWorkspace = lockfileDir !== packageDir; + + const corepackVersion = await corepack`--version`().catch(() => null); + const useCorepack = !!corepackVersion; + logger.info(`corepack: ${corepackVersion || "disabled"}`); + + const expected = { + lockfile: await loadJSON(packageLockPath), + package: await loadJSON(packageJSONPath).then(pluckDependencyFields), + }; + + logger.info(`package.json: ${packageJSONPath} (${expected.package.name})`); + logger.info(`package-lock.json: ${packageLockPath}${isWorkspace ? " (workspace root)" : ""}`); + + // MARK: Uncommitted changes + + if (options["skip-git"]) { + logger.warn("Skipping git status check"); + } else { + const packageStatus = await gitStatus(packageJSONPath); + const lockfileStatus = await gitStatus(packageLockPath); + + if (!packageStatus.available || !lockfileStatus.available) { + logger.warn("Git is not available; skipping uncommitted change detection."); + } else { + check(packageStatus.clean, `package.json has uncommitted changes: ${packageJSONPath}`); + + check( + lockfileStatus.clean, + `package-lock.json has uncommitted changes: ${packageLockPath}`, + ); + } + } + + // MARK: Regenerate + + const npmVersion = await npm`--version`({ useCorepack }); + + logger.info(`Detected npm version: ${npmVersion}`); + + await npm`install --package-lock-only`({ + cwd: lockfileDir, + useCorepack, + }); + + logger.info("npm install complete."); + + const actual = { + lockfile: await loadJSON(packageLockPath), + package: await loadJSON(packageJSONPath).then(pluckDependencyFields), + }; + + // MARK: Compare + + assert.deepStrictEqual( + actual.package, + expected.package, + `package.json was unexpectedly modified during lockfile check: ${packageJSONPath}`, + ); + + try { + checkDeep( + actual.lockfile, + expected.lockfile, + `package-lock.json is out of sync with package.json`, + ); + } catch (error) { + if (!(error instanceof assert.AssertionError)) { + throw error; + } + + // NPM versions <=11.10 has issues with deterministic lockfile generation, + // especially around optional peer dependencies. + const diffedProperties = extractDiffedProperties(actual.lockfile, expected.lockfile).filter( + (segments) => segments.isDisjointFrom(ignoredProperties), + ); + + if (diffedProperties.length) { + const formatted = diffedProperties + .map((segments) => Array.from(segments).join(".")) + .join("\n"); + + throw new Error(`Lockfile drift detected:\n${formatted}`, { cause: error }); + } + + logger.warn( + "Permissible dependency differences detected. Run `npm install` to update the lockfile.", + ); + } + + return issues; +} + +run() + .then((issues) => { + if (issues.length) { + logger.warn(`⚠️ ${issues.length} issue(s) detected:`); + + for (const issue of issues) { + logger.warn(` - ${issue}`); + } + + if (options.warn) { + logger.warn( + "The lockfile on disk has been regenerated. Review and commit the changes.", + ); + process.exit(EXIT_DRIFT); + } + } else { + logger.info("✅ Lockfile is in sync."); + } + }) + .catch((error) => reportAndExit(error, logger)); diff --git a/scripts/node/lint-runtime.mjs b/scripts/node/lint-runtime.mjs new file mode 100755 index 0000000000..718194dd04 --- /dev/null +++ b/scripts/node/lint-runtime.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * @file Lints the installed Node.js and npm versions against the requirements specified in package.json. + * + * Usage: + * lint-node [options] [directory] + * + * Exit codes: + * 0 Versions are in sync + * 1 Version mismatch detected + */ + +import * as assert from "node:assert/strict"; +import { parseArgs } from "node:util"; + +import { ConsoleLogger } from "../../packages/logger-js/lib/node.js"; +import { CommandError, parseCWD, reportAndExit } from "./utils/commands.mjs"; +import { corepack } from "./utils/corepack.mjs"; +import { resolveRepoRoot } from "./utils/git.mjs"; +import { compareVersions, findNPMPackage, loadJSON, node, npm, parseRange } from "./utils/node.mjs"; + +const logger = ConsoleLogger.prefix("lint-runtime"); + +/** + * @param {string} start + */ +async function readRequirements(start) { + const { packageJSONPath } = await findNPMPackage(start); + + logger.info(`Checking versions in ${packageJSONPath}`); + + const packageJSONData = await loadJSON(packageJSONPath); + + const nodeVersion = await node`--version`().then((output) => output.replace(/^v/, "")); + + const requiredNpmVersion = packageJSONData.engines?.npm; + const requiredNodeVersion = packageJSONData.engines?.node; + + return { nodeVersion, requiredNpmVersion, requiredNodeVersion }; +} + +async function main() { + const parsedArgs = parseArgs({ + allowPositionals: true, + }); + + const cwd = parseCWD(parsedArgs.positionals); + const repoRoot = await resolveRepoRoot(cwd).catch(() => null); + + logger.info(`cwd ${cwd}`); + logger.info(`repository ${repoRoot || "not found"}`); + + const corepackVersion = await corepack`--version`().catch(() => null); + const useCorepack = !!corepackVersion; + logger.info(`corepack ${corepackVersion || "disabled"}`); + + const npmVersion = await npm`--version`({ cwd, useCorepack }) + .then((version) => { + logger.info(`npm${corepackVersion ? " (via Corepack)" : ""} ${version}`); + + return version; + }) + .catch((error) => { + if (error instanceof CommandError && corepackVersion) { + logger.warn(`Failed to read npm version via Corepack ${error.message}`); + + logger.info(`Attempting to read npm version directly without Corepack...`); + // Corepack might be misconfigured or outdated. + // Attempting a second read without Corepack can help us distinguish + // between a general npm issue and a Corepack-specific one. + return npm`--version`({ cwd }).then((version) => { + logger.info(`npm (direct) ${version}`); + + return version; + }); + } + + throw error; + }); + + const { nodeVersion, requiredNpmVersion, requiredNodeVersion } = await readRequirements(cwd); + + logger.info(`node ${nodeVersion}`); + + if (requiredNpmVersion) { + logger.info(`package.json npm ${requiredNpmVersion}`); + + const { operator, version: required } = parseRange(requiredNpmVersion); + const result = compareVersions(npmVersion, required); + + assert.ok( + operator === ">=" ? result >= 0 : result === 0, + `npm version ${npmVersion} does not satisfy required version ${requiredNpmVersion}`, + ); + } + + if (requiredNodeVersion) { + logger.info(`package.json node ${requiredNodeVersion}`); + + const { operator, version: required } = parseRange(requiredNodeVersion); + const result = compareVersions(nodeVersion, required); + + assert.ok( + operator === ">=" ? result >= 0 : result === 0, + `Node.js version ${nodeVersion} does not satisfy required version ${requiredNodeVersion}`, + ); + } +} + +main() + .then(() => { + logger.info("✅ Node.js and npm versions are in sync."); + }) + .catch((error) => reportAndExit(error, logger)); diff --git a/scripts/node/setup-corepack.mjs b/scripts/node/setup-corepack.mjs new file mode 100755 index 0000000000..7a713ca428 --- /dev/null +++ b/scripts/node/setup-corepack.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +/** + * @file Downloads the latest corepack tarball from the npm registry. + */ + +import * as fs from "node:fs/promises"; +import { parseArgs } from "node:util"; + +import { ConsoleLogger } from "../../packages/logger-js/lib/node.js"; +import { $, parseCWD, reportAndExit } from "./utils/commands.mjs"; +import { corepack, pullLatestCorepack } from "./utils/corepack.mjs"; +import { resolveRepoRoot } from "./utils/git.mjs"; +import { findNPMPackage, loadJSON, npm } from "./utils/node.mjs"; + +const FALLBACK_NPM_VERSION = "11.11.0"; +const logger = ConsoleLogger.prefix("setup-corepack"); + +async function main() { + const parsedArgs = parseArgs({ + options: { + force: { + type: "boolean", + default: false, + description: "Force re-download of corepack even if a version is already installed", + }, + }, + allowPositionals: true, + }); + + const cwdArg = parseCWD(parsedArgs.positionals); + + const repoRoot = await resolveRepoRoot(cwdArg).catch(() => null); + const cwd = repoRoot || cwdArg; + + const npmVersion = await npm`--version`({ cwd }); + + logger.info(`npm ${npmVersion}`); + + const corepackVersion = await corepack`--version`({ cwd }).catch(() => null); + + logger.info(`corepack ${corepackVersion || "not found"}`); + + if (corepackVersion && !parsedArgs.values.force) { + logger.info("Corepack is already installed, skipping download (use --force to override)"); + return; + } + + await pullLatestCorepack(cwd); + + await npm`install --force -g corepack@latest`({ cwd }); + logger.info("Corepack installed successfully"); + + const { packageJSONPath } = await findNPMPackage(cwd); + + logger.info(`Checking versions in ${packageJSONPath}`); + + const packageJSONData = await loadJSON(packageJSONPath); + + const packageManager = packageJSONData.packageManager || `npm@${FALLBACK_NPM_VERSION}`; + + await $`corepack install -g ${packageManager}`({ cwd }); + + logger.info(`Setting up Corepack to use ${packageManager}...`); + + const writablePackageJSON = await fs.access(packageJSONPath, fs.constants.W_OK).then( + () => true, + () => false, + ); + + /** + * @type {string} + */ + let subcommand; + + if (!writablePackageJSON) { + if (!packageJSONData.packageManager) { + throw new Error( + `package.json is not writable and does not specify a packageManager field. Was the package.json file mounted via Docker?`, + ); + } + + subcommand = "install -g"; + } else { + logger.info("package.json is writable"); + subcommand = "use"; + } + + await $`corepack ${subcommand} ${packageManager}`({ cwd }); + + logger.info("Corepack installed npm successfully"); +} + +main().catch((error) => reportAndExit(error, logger)); diff --git a/scripts/node/utils/commands.mjs b/scripts/node/utils/commands.mjs new file mode 100644 index 0000000000..a02d139601 --- /dev/null +++ b/scripts/node/utils/commands.mjs @@ -0,0 +1,116 @@ +/** + * Utility functions for running shell commands and handling their results. + * + * @import { ExecOptions } from "node:child_process" + */ + +import { exec } from "node:child_process"; +import { resolve, sep } from "node:path"; +import { promisify } from "node:util"; + +import { ConsoleLogger } from "../../../packages/logger-js/lib/node.js"; + +const logger = ConsoleLogger.prefix("commands"); + +export class CommandError extends Error { + name = "CommandError"; + + /** + * @param {string} command + * @param {ErrorOptions & ExecOptions} options + */ + constructor(command, { cause, cwd, shell } = {}) { + const cwdInfo = cwd ? ` in directory ${cwd}` : ""; + const shellInfo = shell ? ` using shell ${shell}` : ""; + + super(`Command failed: ${command}${cwdInfo}${shellInfo}`, { cause }); + } +} + +/** + * @param {string[]} positionals + * @returns {string} The resolved current working directory for the script + */ +export function parseCWD(positionals) { + // `INIT_CWD` is present only if the script is run via npm. + const initCWD = process.env.INIT_CWD || process.cwd(); + + const cwd = (positionals.length ? resolve(initCWD, positionals[0]) : initCWD) + sep; + + return cwd; +} + +const execAsync = promisify(exec); + +/** + * @param {Awaited>} result + */ +export const trimResult = (result) => String(result.stdout).trim(); + +/** + * @typedef {(strings: TemplateStringsArray, ...expressions: unknown[]) => + * (options?: ExecOptions) => Promise + * } CommandTag + */ + +function createTag(prefix = "") { + /** @type {CommandTag} */ + return (strings, ...expressions) => { + const command = (prefix ? prefix + " " : "") + String.raw(strings, ...expressions); + + logger.debug(command); + + return (options) => + execAsync(command, options) + .then(trimResult) + .catch((cause) => { + throw new CommandError(command, { ...options, cause }); + }); + }; +} + +/** + * A tagged template function for running shell commands. + * @type {CommandTag & { bind(prefix: string): CommandTag }} + */ +export const $ = createTag(); + +/** + * @param {string} prefix + * @returns {CommandTag} + */ +$.bind = (prefix) => createTag(prefix); + +/** + * Promisified version of {@linkcode exec} for easier async/await usage. + * + * @param {string} command The command to run, with space-separated arguments. + * @param {ExecOptions} [options] Optional execution options. + * @throws {CommandError} If the command fails to execute. + */ +export function $2(command, options) { + return execAsync(command, options) + .then(trimResult) + .catch((cause) => { + throw new CommandError(command, { ...options, cause }); + }); +} + +/** + * Logs the given error and its cause (if any) and exits the process with a failure code. + * @param {unknown} error + * @param {typeof ConsoleLogger} logger + * @returns {never} + */ +export function reportAndExit(error, logger = ConsoleLogger) { + const message = error instanceof Error ? error.message : String(error); + const cause = error instanceof Error && error.cause instanceof Error ? error.cause : null; + + logger.error(`❌ ${message}`); + + if (cause) { + logger.error(`Caused by: ${cause.message}`); + } + + process.exit(1); +} diff --git a/scripts/node/utils/corepack.mjs b/scripts/node/utils/corepack.mjs new file mode 100644 index 0000000000..e3d7c41209 --- /dev/null +++ b/scripts/node/utils/corepack.mjs @@ -0,0 +1,84 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import { join, relative } from "node:path"; + +import { ConsoleLogger } from "../../../packages/logger-js/lib/node.js"; +import { $ } from "./commands.mjs"; + +const REGISTRY_URL = "https://registry.npmjs.org/corepack"; +const OUTPUT_DIR = join(".corepack", "releases"); +const OUTPUT_FILENAME = "latest.tgz"; + +export const corepack = $.bind("corepack"); + +/** + * Reads the installed Corepack version. + * + * @param {string} [cwd] The directory to run the command in. + * @returns {Promise} The installed Corepack version + */ +export function readCorepackVersion(cwd = process.cwd()) { + return $`corepack --version`({ cwd }); +} + +const logger = ConsoleLogger.prefix("setup-corepack"); + +/** + * @param {string} baseDirectory + */ +export async function pullLatestCorepack(baseDirectory = process.cwd()) { + logger.info("Fetching corepack metadata from registry..."); + + const outputDir = join(baseDirectory, OUTPUT_DIR); + const outputPath = join(outputDir, OUTPUT_FILENAME); + + const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1000 * 60) }); + + if (!res.ok) { + throw new Error(`Failed to fetch registry metadata: ${res.status} ${res.statusText}`); + } + + const metadata = await res.json(); + + const latestVersion = metadata["dist-tags"].latest; + const versionData = metadata.versions[latestVersion]; + const tarballUrl = versionData.dist.tarball; + const expectedIntegrity = versionData.dist.integrity; + + logger.info(`Latest corepack version: ${latestVersion}`); + logger.info(`Tarball URL: ${tarballUrl}`); + logger.info(`Expected integrity: ${expectedIntegrity}`); + + logger.info({ url: tarballUrl }, "Downloading tarball..."); + + const tarballRes = await fetch(tarballUrl, { + signal: AbortSignal.timeout(1000 * 60), + }); + + if (!tarballRes.ok) { + throw new Error( + `Failed to download tarball: ${tarballRes.status} ${tarballRes.statusText}`, + ); + } + + const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer()); + + logger.info("Verifying integrity..."); + + const [algorithm, expectedHash] = expectedIntegrity.split("-"); + const actualHash = crypto.createHash(algorithm).update(tarballBuffer).digest("base64"); + + if (actualHash !== expectedHash) { + throw new Error( + `Integrity mismatch!\n Expected: ${expectedHash}\n Actual: ${actualHash}`, + ); + } + + logger.info("Integrity verified."); + + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(outputPath, tarballBuffer); + + logger.info(`Saved to ${relative(baseDirectory, outputPath)}`); + logger.info(`corepack@${latestVersion} (${expectedIntegrity})`); +} diff --git a/scripts/node/utils/git.mjs b/scripts/node/utils/git.mjs new file mode 100644 index 0000000000..689882ab6d --- /dev/null +++ b/scripts/node/utils/git.mjs @@ -0,0 +1,25 @@ +import { $ } from "./commands.mjs"; + +/** + * Checks whether the given file has uncommitted changes in git. + * + * @param {string} filePath + * @param {string} [cwd] + * @returns {Promise<{ clean: boolean, available: boolean }>} + */ +export async function gitStatus(filePath, cwd = process.cwd()) { + return $`git status --porcelain ${filePath}`({ cwd }) + .then((output) => ({ clean: !output, available: true })) + .catch(() => ({ clean: false, available: false })); +} + +/** + * Finds the root directory of the git repository containing the given directory. + * + * @param {string} cwd + * @returns {Promise} The path to the git repository root. + * @throws {Error} If the command fails (e.g., not a git repository). + */ +export function resolveRepoRoot(cwd = process.cwd()) { + return $`git rev-parse --show-toplevel`({ cwd }); +} diff --git a/scripts/node/utils/node.mjs b/scripts/node/utils/node.mjs new file mode 100644 index 0000000000..c75f2b571b --- /dev/null +++ b/scripts/node/utils/node.mjs @@ -0,0 +1,175 @@ +/** + * 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