rugby
Version:
A Node.js CLI to collect npm package tarballs and assets (including those fetched by pre/postinstall scripts) for offline Artifactory use.
278 lines (246 loc) • 9.32 kB
JavaScript
"use strict";
const path = require("path");
const fs = require("fs-extra");
const os = require("os");
const child_process = require("child_process");
const which = require("which");
const crypto = require("crypto");
// Strategy:
// - Run `npm ci --ignore-scripts` to materialize node_modules deterministically without running scripts.
// - Then run install lifecycle scripts in a sandbox by invoking `npm rebuild --foreground-scripts` so that
// only lifecycle scripts execute. We wrap spawn with env vars that point HTTP(S)_PROXY to our local proxy
// recorder (implemented via Node fetch proxy) which logs all external requests and also downloads those assets
// into outDir for later upload. We also record file reads of URLs passed to curl/wget by patching PATH to
// shim curl/wget.
async function collectNetworkDuringScripts({ projectDir, outDir, verbose, npmPath, npmArgs = "" }) {
await fs.mkdirp(outDir);
// On Windows, prefer npm.cmd over npm.ps1 to avoid PowerShell execution policy issues
const npm = npmPath || (process.platform === "win32"
? (fs.existsSync("C:\\Program Files\\nodejs\\npm.cmd")
? "C:\\Program Files\\nodejs\\npm.cmd"
: which.sync("npm"))
: which.sync("npm"));
const realNode = which.sync("node");
const env = { ...process.env };
const logPath = path.join(outDir, "network.log");
const assetsDir = path.join(outDir, "assets-temp");
await fs.mkdirp(assetsDir);
// Generate shims for curl and wget that log and download files via Node
const shimDir = path.join(outDir, "shims");
await fs.mkdirp(shimDir);
// On Windows, create batch file shims; on Unix, create bash shims
if (process.platform === "win32") {
// Windows batch file shims
const curlShim = windowsShim("curl", assetsDir, logPath);
const wgetShim = windowsShim("wget", assetsDir, logPath);
const nodeShim = windowsNodeShim(realNode);
await fs.writeFile(path.join(shimDir, "curl.bat"), curlShim);
await fs.writeFile(path.join(shimDir, "wget.bat"), wgetShim);
await fs.writeFile(path.join(shimDir, "node.bat"), nodeShim);
} else {
// Unix bash shims
const curlShim = `#!/usr/bin/env bash\n${bashShim("curl", assetsDir)}\n`;
const wgetShim = `#!/usr/bin/env bash\n${bashShim("wget", assetsDir)}\n`;
const nodeShim = `#!/usr/bin/env bash\n${nodeShimScript(realNode)}\n`;
await fs.writeFile(path.join(shimDir, "curl"), curlShim, { mode: 0o755 });
await fs.writeFile(path.join(shimDir, "wget"), wgetShim, { mode: 0o755 });
await fs.writeFile(path.join(shimDir, "node"), nodeShim, { mode: 0o755 });
}
// Prepend our shim directory to PATH so pre/postinstall scripts use it
const pathSeparator = process.platform === "win32" ? ";" : ":";
env.PATH = `${shimDir}${pathSeparator}${env.PATH || ""}`;
env.RUGBY_ASSETS_DIR = assetsDir;
env.RUGBY_LOG = logPath;
// Skip NODE_OPTIONS hook on Windows if path contains spaces - it causes issues with argument parsing
// TODO: Find a better solution for Windows paths with spaces
const httpHookPath = path.join(__dirname, "http-hook.js");
if (process.platform !== "win32" || !httpHookPath.includes(' ')) {
env.NODE_OPTIONS = `${env.NODE_OPTIONS ? env.NODE_OPTIONS + " " : ""}--require ${httpHookPath}`;
} else {
console.warn("Warning: Skipping http hook on Windows due to path containing spaces. Some network capture features may not work.");
}
// 1) Ensure node_modules are present without scripts
try {
spawnSyncLogged(npm, ["ci", "--ignore-scripts", ...splitArgs(npmArgs)], { cwd: projectDir, env, verbose });
} catch (e) {
// fallback to install if ci fails (e.g., no lockfile v2)
spawnSyncLogged(npm, ["install", "--ignore-scripts", ...splitArgs(npmArgs)], { cwd: projectDir, env, verbose });
}
// Baseline node_modules snapshot
const baseline = await scanNodeModules(path.join(projectDir, "node_modules"));
// 2) Run lifecycle scripts only
spawnSyncLogged(npm, ["rebuild", "--foreground-scripts", "--ignore-scripts=false", ...splitArgs(npmArgs)], {
cwd: projectDir,
env,
verbose,
});
// Post-run node_modules snapshot and diff
const after = await scanNodeModules(path.join(projectDir, "node_modules"));
const scriptPackages = [];
for (const [pkgName, version] of after.entries()) {
if (!baseline.has(pkgName)) {
scriptPackages.push({ name: pkgName, version });
}
}
// Collect assets list by scanning assetsDir
const assets = [];
const files = await fs.readdir(assetsDir);
for (const f of files) {
assets.push({ filename: f, tempPath: path.join(assetsDir, f) });
}
// Parse log for URL list as well
if (await fs.pathExists(logPath)) {
const content = await fs.readFile(logPath, "utf8");
const urlRegex = /(https?:\/[\w\-._~:/?#[\]@!$&'()*+,;=%]+)/g;
let m;
while ((m = urlRegex.exec(content))) {
assets.push({ url: m[1] });
}
}
return { assets, logPath, scriptPackages };
}
function splitArgs(str) {
if (!str) return [];
// naive split respecting quotes
const re = /\s+([^\s\"]+|\"[^\"]*\")/g;
const parts = [];
let m;
const s = ` ${str}`;
while ((m = re.exec(s))) {
const p = m[1];
parts.push(p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p);
}
return parts;
}
function spawnSyncLogged(cmd, args, { cwd, env, verbose }) {
if (verbose) console.log("$", cmd, ...args);
// On Windows, we need to handle .cmd files specially
let spawnCmd = cmd;
let spawnArgs = args;
let spawnOptions = {
cwd,
env,
stdio: "inherit",
};
if (process.platform === "win32") {
// Use cmd.exe to run .cmd files on Windows to avoid PowerShell execution policy issues
if (cmd.endsWith('.cmd') || cmd.endsWith('.CMD')) {
spawnCmd = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
spawnArgs = ['/c', cmd, ...args];
} else {
spawnOptions.shell = true;
}
}
const res = child_process.spawnSync(spawnCmd, spawnArgs, spawnOptions);
if (res.error) throw res.error;
if (res.status !== 0) throw new Error(`${cmd} ${args.join(" ")} failed with code ${res.status}`);
return res;
}
function bashShim(tool, assetsDir) {
return `
set -euo pipefail
LOG="${assetsDir}/network.log"
ARGS=("$@")
URL=""
for a in "${'$'}{ARGS[@]}"; do
if [[ "${'$'}a" =~ ^https?:// ]]; then URL="${'$'}a"; break; fi
done
if [ -n "${'$'}URL" ]; then
echo "[shim:${tool}] ${'$'}URL" >> "${'$'}LOG" 2>/dev/null || true
# Opportunistic fetch for capture; ignore failures
node -e 'const fs=require("fs"),path=require("path"),http=require("http"),https=require("https");
const url=process.argv[1];
const fn=Buffer.from(url).toString("base64").replace(/[^a-zA-Z0-9_-]/g,"")+".bin";
const dest=path.join(process.env.RUGBY_ASSETS_DIR||"${assetsDir}",fn);
const proto=url.startsWith("https")?https:http;
proto.get(url,(res)=>{if(res.statusCode>=300&&res.statusCode<400&&res.headers.location){return require("http");}
const out=fs.createWriteStream(dest);res.pipe(out);out.on("finish",()=>out.close());}).on("error",()=>{});' "${'$'}URL" >/dev/null 2>&1 || true
fi
exec -a ${tool} $(command -v -- ${tool}) "${'$'}@"
`;
}
function nodeShimScript(realNodePath) {
return `
set -euo pipefail
HOOK="${path.join(__dirname, 'http-hook.js').replace(/"/g, '\\"')}"
exec "${realNodePath}" -r "${'$'}HOOK" "${'$'}@"
`;
}
function windowsShim(tool, assetsDir, logPath) {
// Windows batch file shim for curl/wget
return ` off
set LOG=${logPath}
set ASSETS_DIR=${assetsDir}
set URL=
set OUTPUT=
:parseargs
if "%~1"=="" goto :run
if "%~1"=="-o" (
set OUTPUT=%~2
shift
shift
goto :parseargs
)
if "%~1"=="-O" (
set OUTPUT=%%~nx1
shift
goto :parseargs
)
set URL=%~1
shift
goto :parseargs
:run
if not "%URL%"=="" (
echo [%DATE% %TIME%] ${tool} %URL% >> "%LOG%"
if not "%OUTPUT%"=="" (
echo Downloading %URL% to %OUTPUT%
powershell -Command "Invoke-WebRequest -Uri '%URL%' -OutFile '%ASSETS_DIR%\\%OUTPUT%'"
)
)
exit /b 0
`;
}
function windowsNodeShim(realNodePath) {
// Windows batch file shim for node
const hookPath = path.join(__dirname, 'http-hook.js').replace(/\\/g, '\\\\');
return `@echo off
"${realNodePath}" -r "${hookPath}" %*
`;
}
module.exports = { collectNetworkDuringScripts };
async function scanNodeModules(root) {
const results = new Map();
async function walk(dir) {
let entries = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
if (ent.name.startsWith(".")) continue;
const full = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (ent.name.startsWith("@")) {
await walk(full);
continue;
}
const pkgJsonPath = path.join(full, "package.json");
if (await fs.pathExists(pkgJsonPath)) {
try {
const pkg = await fs.readJson(pkgJsonPath);
if (pkg && pkg.name && pkg.version) {
results.set(pkg.name, pkg.version);
}
} catch {}
}
const subNodeModules = path.join(full, "node_modules");
if (await fs.pathExists(subNodeModules)) {
await walk(subNodeModules);
}
}
}
}
await walk(root);
return results;
}