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