#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import process from "node:process"; import { GITHUB_USER_AGENT, ROOT_DIR, commandExists, confirmPrompt, fail, logInfo, logSuccess, logWarn, readNodeMajorVersion, run, runCaptureOutput, tryRun, usage, } from "./lib.mjs"; const rootDir = ROOT_DIR; const usageMessage = usage(process.argv[1], "--verify | --update"); function parseMode(argv) { const mode = argv[2] ?? ""; if (mode !== "--verify" && mode !== "--update") { fail(usageMessage); } return mode; } function buildReferenceSpecs(nodeMajor) { return [ { path: `${rootDir}/package.json`, replacements: [ [/"node":\s*">=\d+(?:\.\d+\.\d+)?"/g, `"node": ">=${nodeMajor}"`], ], }, { path: `${rootDir}/dev_scripts/lib.mjs`, replacements: [ [ /export const CURRENT_NODE_VERSION = "\d+";/g, `export const CURRENT_NODE_VERSION = "${nodeMajor}";`, ], ], }, { path: `${rootDir}/.github/actions/setup-env/action.yml`, replacements: [[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`]], }, { path: `${rootDir}/.github/workflows/ci.yml`, replacements: [ [/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`], ], }, { path: `${rootDir}/.github/workflows/pr.yml`, replacements: [[/node-version:\s*\d+(?:\.x)?/g, `node-version: ${nodeMajor}`]], }, { path: `${rootDir}/.github/workflows/release.yml`, replacements: [ [/node-version:\s*\[\d+(?:\.x)?\]/g, `node-version: [${nodeMajor}]`], ], }, { path: `${rootDir}/README.md`, replacements: [[/- Node\.js \d+\+/g, `- Node.js ${nodeMajor}+`]], }, ]; } function applyReplacements(content, replacements) { let updated = content; for (const [pattern, replacement] of replacements) { updated = updated.replace(pattern, replacement); } return updated; } function updateNodeVersionReferences(nodeMajor) { const files = buildReferenceSpecs(nodeMajor); let changedCount = 0; for (const entry of files) { const original = readFileSync(entry.path, "utf8"); const updated = applyReplacements(original, entry.replacements); if (updated !== original) { writeFileSync(entry.path, updated, "utf8"); changedCount += 1; logInfo(`Updated Node version references in ${entry.path}.`); } } if (changedCount === 0) { logInfo("No Node version reference updates were needed."); } else { logSuccess(`Updated Node version references in ${changedCount} file(s).`); } } function verifyNodeVersionReferences(nodeMajor) { const files = buildReferenceSpecs(nodeMajor); const drifted = []; for (const entry of files) { const original = readFileSync(entry.path, "utf8"); const updated = applyReplacements(original, entry.replacements); if (updated !== original) { drifted.push(entry.path); } } if (drifted.length > 0) { fail(`Node version references are out of sync in: ${drifted.join(", ")}`); } logSuccess("All Node version references are synchronized with .node_ver."); } async function getAvailableNodeMajors() { let packageJson; try { const response = await fetch("https://registry.npmjs.org/node", { headers: { Accept: "application/json", "User-Agent": GITHUB_USER_AGENT, }, }); if (!response.ok) { fail( `Unable to read Node.js versions from npm registry: HTTP ${response.status}`, ); } packageJson = await response.json(); } catch (error) { fail( `Unable to read Node.js versions from npm registry: ${error instanceof Error ? error.message : String(error)}`, ); } const versions = Object.keys(packageJson?.versions ?? {}); if (!Array.isArray(versions) || versions.length === 0) { fail("Unexpected npm registry response for Node.js versions."); } return [...new Set(versions.map((version) => String(version).split(".")[0]))] .filter((major) => /^\d+$/.test(major)) .sort((left, right) => Number(left) - Number(right)); } function runScriptWithOptionalSudo(scriptText) { if (!commandExists("bash")) { fail("bash is required to run the NodeSource setup script."); } const usesSudo = commandExists("sudo"); const command = usesSudo ? "sudo" : "bash"; const args = usesSudo ? ["-E", "bash", "-s", "--"] : ["-s", "--"]; const result = spawnSync(command, args, { cwd: rootDir, encoding: "utf8", input: scriptText, stdio: ["pipe", "inherit", "inherit"], env: process.env, }); if (result.error) { throw new Error( `Failed to execute NodeSource setup script: ${result.error.message}`, ); } if (result.status !== 0) { throw new Error( `NodeSource setup script failed with exit code ${result.status ?? "unknown"}.`, ); } } async function setupNodeSourceRepo(nodeMajor) { const setupUrl = `https://deb.nodesource.com/setup_${nodeMajor}.x`; logInfo(`Configuring NodeSource for Node.js ${nodeMajor}.x...`); let scriptText; try { const response = await fetch(setupUrl, { headers: { Accept: "text/plain", "User-Agent": GITHUB_USER_AGENT, }, }); if (!response.ok) { fail( `Unable to download NodeSource setup script: HTTP ${response.status}`, ); } scriptText = await response.text(); } catch (error) { fail( `Unable to download NodeSource setup script: ${error instanceof Error ? error.message : String(error)}`, ); } if (!scriptText.trim()) { fail("Downloaded NodeSource setup script is empty."); } runScriptWithOptionalSudo(scriptText); } function installNodejs(nodeMajor) { if (!commandExists("apt-get")) { fail("APT is required to install Node.js packages."); } const aptRunner = commandExists("sudo") ? (args) => run("sudo", ["apt-get", ...args], { cwd: rootDir }) : (args) => run("apt-get", args, { cwd: rootDir }); logInfo("Updating apt package index..."); aptRunner(["update"]); logInfo("Installing nodejs package..."); aptRunner(["install", "-y", "nodejs"]); const fullNodeVersion = runCaptureOutput("node", ["-v"], { cwd: rootDir }); if (!fullNodeVersion.startsWith(`v${nodeMajor}.`)) { fail( `Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`, ); } logSuccess( `Node.js ${fullNodeVersion} installed/updated for major ${nodeMajor}.`, ); } function verifyInstalledNodeMajor(nodeMajor) { const nodeResult = tryRun("node", ["-v"], { cwd: rootDir, stdio: "pipe" }); if (!nodeResult.ok) { fail("Node.js is not installed or not available on PATH."); } const fullNodeVersion = (nodeResult.stdout ?? "").trim(); if (!fullNodeVersion.startsWith(`v${nodeMajor}.`)) { fail( `Installed Node.js version (${fullNodeVersion}) does not match expected major v${nodeMajor}.`, ); } logSuccess(`Installed Node.js version matches major ${nodeMajor}.`); } async function main() { const mode = parseMode(process.argv); const nodeMajor = readNodeMajorVersion(rootDir); if (mode === "--verify") { verifyNodeVersionReferences(nodeMajor); verifyInstalledNodeMajor(nodeMajor); return; } if (!commandExists("apt") && !commandExists("apt-get")) { fail("APT is required to install Node.js packages."); } const availableMajors = await getAvailableNodeMajors(); if (!availableMajors.includes(nodeMajor)) { fail( `Node.js major ${nodeMajor} is not listed as available. Available majors: ${availableMajors.join(", ")}`, ); } logWarn( `You are about to install/update Node.js for major ${nodeMajor}.x. This may break your development environment and NPM users.`, ); await confirmPrompt( `Type "i confirm change to ${nodeMajor} to proceed:", new RegExp(`^i confirm change to ${nodeMajor}$`), /^n$/, ); updateNodeVersionReferences(nodeMajor); await setupNodeSourceRepo(nodeMajor); installNodejs(nodeMajor); verifyNodeVersionReferences(nodeMajor); } main().catch((error) => { fail(error instanceof Error ? error.message : String(error)); });