rugby
Version:
A Node.js CLI to collect npm package tarballs and assets (including those fetched by pre/postinstall scripts) for offline Artifactory use.
264 lines (239 loc) • 13 kB
JavaScript
"use strict";
const path = require("path");
const fs = require("fs-extra");
const os = require("os");
const crypto = require("crypto");
const child_process = require("child_process");
const chalk = require("chalk");
const pacote = require("pacote");
const { discoverDependencyGraph } = require("./lib/discover-graph.js");
const { collectNetworkDuringScripts } = require("./lib/script-network-capture.js");
const { buildTreeFromLock, printTree, buildFlatListFromGraph } = require("./lib/tree.js");
async function run(options) {
const { projectDir, outDir, listOnly, verbose, npmPath, npmArgs } = options;
console.log(chalk.cyan("Analyzing dependencies..."));
const pkgJson = await fs.readJson(path.join(projectDir, "package.json"));
const lockJson = await fs.readJson(path.join(projectDir, "package-lock.json"));
const graph = await discoverDependencyGraph({ projectDir, pkgJson, lockJson, verbose });
console.log(chalk.cyan("Dependency graph discovered."));
// Capture additional assets fetched by pre/postinstall scripts by running install in a sandbox
// For list-only, use a temporary capture directory and do not require --out
const captureDir = listOnly
? path.join(os.tmpdir(), `rugby-capture-${Date.now()}`)
: path.join(outDir, "capture");
await fs.mkdirp(captureDir);
const captureResult = await collectNetworkDuringScripts({
projectDir,
outDir: captureDir,
verbose,
npmPath,
npmArgs,
});
if (listOnly) {
await printList(graph, captureResult, { projectDir, lockJson });
try { await fs.remove(captureDir); } catch {}
return;
}
await saveAll(graph, captureResult, { outDir, verbose });
console.log(chalk.green("Done. Packages and assets saved to:"), outDir);
// Post-run quick guide
try {
const guide = await buildNextStepsGuide({ outDir, projectDir });
const guidePath = path.join(outDir, "NEXT_STEPS.txt");
await fs.writeFile(guidePath, guide, "utf8");
console.log("");
console.log(chalk.bold("What to do next:"));
const previewLines = guide.split("\n").slice(0, 20);
previewLines.forEach((line) => console.log(line));
if (guide.split("\n").length > previewLines.length) {
console.log(chalk.gray(`\nFull guide written to ${guidePath}`));
}
} catch (e) {
if (verbose) console.error("Failed to write next steps guide:", e);
}
}
async function printList(graph, captureResult, { projectDir, lockJson }) {
console.log(chalk.bold("Dependency tree:"));
const tree = buildTreeFromLock(lockJson);
const markSet = new Set((captureResult.scriptPackages || []).map((p) => p.name));
const lines = printTree(tree, { markScriptSet: markSet });
if (lines.length) {
lines.forEach((l) => console.log(l));
} else {
// Fallback: flat list from graph
for (const item of buildFlatListFromGraph(graph)) {
const tag = markSet.has(item.name) ? " [from scripts]" : "";
console.log(`- ${item.name}${item.version ? `@${item.version}` : ""}${tag}`);
}
}
console.log("");
console.log(chalk.bold("Packages discovered from install scripts:"));
if (captureResult.scriptPackages && captureResult.scriptPackages.length) {
captureResult.scriptPackages
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((p) => console.log(`- ${p.name}${p.version ? `@${p.version}` : ""} [from scripts]`));
} else {
console.log(chalk.gray("None"));
}
console.log("");
console.log(chalk.bold("Assets referenced by install scripts (URLs/files):"));
if (captureResult && captureResult.assets && captureResult.assets.length) {
captureResult.assets.forEach((a) => {
console.log(`- ${a.url || a.path || a.filename}`);
});
} else {
console.log(chalk.gray("No additional assets discovered via install scripts."));
}
}
async function saveAll(graph, captureResult, { outDir, verbose }) {
const downloadsDir = path.join(outDir, "packages");
await fs.mkdirp(downloadsDir);
// Download all package tarballs using pacote, respecting integrity from lock
for (const [key, info] of graph.order) {
const spec = `${info.name}@${info.version}`;
const safeName = info.name.replace(/\//g, "_").replace(/@/g, "");
const filename = `${safeName}@${info.version}.tgz`;
const dest = path.join(downloadsDir, filename);
if (await fs.pathExists(dest)) continue;
const opts = {};
if (info.resolved) opts.resolved = info.resolved;
if (info.integrity) opts.integrity = info.integrity;
if (verbose) console.log("Downloading", spec, "->", dest);
const data = await pacote.tarball(spec, opts);
await fs.writeFile(dest, data);
}
// Save captured assets
const assetsDir = path.join(outDir, "assets");
await fs.mkdirp(assetsDir);
for (const asset of captureResult.assets || []) {
const outName = asset.filename || crypto.createHash("sha256").update(asset.url || asset.path).digest("hex");
const dest = path.join(assetsDir, outName);
if (asset.path) {
if (await fs.pathExists(asset.path)) {
await fs.copy(asset.path, dest, { overwrite: true, errorOnExist: false });
}
} else if (asset.url && asset.tempPath && (await fs.pathExists(asset.tempPath))) {
await fs.copy(asset.tempPath, dest, { overwrite: true, errorOnExist: false });
}
}
}
async function buildNextStepsGuide({ outDir, projectDir }) {
const packagesDir = path.join(outDir, "packages");
const assetsDir = path.join(outDir, "assets");
const pkgFiles = (await safeList(packagesDir)).filter((f) => f.endsWith(".tgz"));
const assetFiles = await safeList(assetsDir);
const cacheDir = path.join(outDir, "npm-cache");
const lines = [];
lines.push("1) Move these folders to the offline network:");
lines.push(` - packages/: ${packagesDir}`);
lines.push(` - assets/: ${assetsDir}`);
lines.push("");
lines.push("2) Option A (Linux/macOS): Install offline using a local npm cache (no registry)");
lines.push(` - Prepare a cache directory (example): ${cacheDir}`);
lines.push(" - Prime the cache with the tarballs:");
lines.push(" export CACHE=</path/to/cache>");
lines.push(" export PKGS=</path/to/packages>");
lines.push(" mkdir -p \"$CACHE\"");
lines.push(" for f in \"$PKGS\"/*.tgz; do npm cache add --cache \"$CACHE\" \"$f\"; done");
lines.push(" - In your project, install strictly from cache (offline):");
lines.push(" cd </path/to/your/project>");
lines.push(" rm -rf node_modules");
lines.push(" npm ci --cache \"$CACHE\" --prefer-offline --offline");
lines.push("");
lines.push(" Windows (PowerShell):");
lines.push(" $env:CACHE = \"<C:\\path\\to\\cache>\"");
lines.push(" $env:PKGS = \"<C:\\path\\to\\packages>\"");
lines.push(" New-Item -ItemType Directory -Force -Path $env:CACHE | Out-Null");
lines.push(" Get-ChildItem -Path $env:PKGS -Filter *.tgz | ForEach-Object { npm cache add --cache $env:CACHE $_.FullName }");
lines.push(" cd <C:\\path\\to\\your\\project>");
lines.push(" rmdir /s /q node_modules 2>$null");
lines.push(" npm ci --cache \"$env:CACHE\" --prefer-offline --offline");
lines.push("");
lines.push(" Windows (CMD):");
lines.push(" set CACHE=C:\\path\\to\\cache");
lines.push(" set PKGS=C:\\path\\to\\packages");
lines.push(" if not exist \"%CACHE%\" mkdir \"%CACHE%\"");
lines.push(" for %%f in (\"%PKGS%\\*.tgz\") do npm cache add --cache \"%CACHE%\" \"%%f\"");
lines.push(" cd C:\\path\\to\\your\\project");
lines.push(" rmdir /s /q node_modules");
lines.push(" npm ci --cache \"%CACHE%\" --prefer-offline --offline");
lines.push("");
lines.push("3) Option B: Use JFrog Artifactory (recommended for teams)");
lines.push(" What to upload:");
lines.push(" - npm repo: upload/publish all tarballs from packages/*.tgz");
lines.push(" - generic repo (or static hosting): upload assets/* for install-script binary downloads");
lines.push("");
lines.push(" Publish tarballs to Artifactory npm repo (Linux/macOS):");
lines.push(" export REG=<https://artifactory.example.com/artifactory/api/npm/your-npm-repo/>");
lines.push(" npm set registry \"$REG\"");
lines.push(" for f in \"${packagesDir}\"/*.tgz; do npm publish \"$f\" --registry \"$REG\" || true; done");
lines.push("");
lines.push(" Publish tarballs (Windows PowerShell):");
lines.push(" $env:REG = \"https://artifactory.example.com/artifactory/api/npm/your-npm-repo/\"");
lines.push(" npm set registry \"$env:REG\"");
lines.push(" Get-ChildItem -Path \"" + packagesDir.replace(/\\/g, "/") + "\" -Filter *.tgz | ForEach-Object { npm publish $_.FullName --registry \"$env:REG\" }");
lines.push("");
lines.push(" Upload assets to a Generic repo (Linux/macOS curl example):");
lines.push(" export GEN=<https://artifactory.example.com/artifactory/generic-assets/>");
lines.push(" for f in \"${assetsDir}\"/*; do curl -u <user:api_key> -T \"$f\" \"$GEN$(basename \"$f\")\"; done");
lines.push("");
lines.push(" Upload assets (Windows PowerShell example):");
lines.push(" $env:GEN = \"https://artifactory.example.com/artifactory/generic-assets/\"");
lines.push(" Get-ChildItem -Path \"" + assetsDir.replace(/\\/g, "/") + "\" | ForEach-Object { Invoke-WebRequest -Uri (\"$env:GEN\" + $_.Name) -InFile $_.FullName -Method Put -Headers @{ Authorization = \"Basic <base64-user:api_key>\" } }");
lines.push("");
lines.push(" Alternative: host assets locally (no Artifactory)");
lines.push(" Linux/macOS (bash):");
lines.push(" ASSETS=</absolute/path/to/assets>");
lines.push(" (cd \"$ASSETS\" && python3 -m http.server 9000) & SERVE_PID=$!");
lines.push(" export npm_config_bcrypt_binary_host_mirror=http://127.0.0.1:9000/");
lines.push(" export SHARP_DIST_BASE_URL=http://127.0.0.1:9000/");
lines.push(" export SASS_BINARY_SITE=http://127.0.0.1:9000/");
lines.push(" # install with npm ci, then kill $SERVE_PID when done");
lines.push("");
lines.push(" Windows (PowerShell):");
lines.push(" $env:ASSETS = \"C:\\absolute\\path\\to\\assets\"");
lines.push(" $proc = Start-Process -FilePath python -ArgumentList \"-m http.server 9000\" -WorkingDirectory $env:ASSETS -PassThru");
lines.push(" $env:npm_config_bcrypt_binary_host_mirror = \"http://127.0.0.1:9000/\"");
lines.push(" $env:SHARP_DIST_BASE_URL = \"http://127.0.0.1:9000/\"");
lines.push(" $env:SASS_BINARY_SITE = \"http://127.0.0.1:9000/\"");
lines.push(" # run npm ci; after install: Stop-Process -Id $proc.Id");
lines.push("");
lines.push(" Configure your project to use Artifactory npm and assets:");
lines.push(" npm config set registry <https://artifactory.example.com/artifactory/api/npm/your-npm-repo/>");
lines.push(" # Set binary mirrors to your Generic repo (adjust per package):");
lines.push(" Linux/macOS (bash):");
lines.push(" export npm_config_bcrypt_binary_host_mirror=<https://artifactory.example.com/artifactory/generic-assets/>");
lines.push(" export SHARP_DIST_BASE_URL=<https://artifactory.example.com/artifactory/generic-assets/>");
lines.push(" export SASS_BINARY_SITE=<https://artifactory.example.com/artifactory/generic-assets/>");
lines.push(" Windows (PowerShell):");
lines.push(" $env:npm_config_bcrypt_binary_host_mirror = \"https://artifactory.example.com/artifactory/generic-assets/\"");
lines.push(" $env:SHARP_DIST_BASE_URL = \"https://artifactory.example.com/artifactory/generic-assets/\"");
lines.push(" $env:SASS_BINARY_SITE = \"https://artifactory.example.com/artifactory/generic-assets/\"");
lines.push("");
lines.push(" Install on the offline network (after npm and asset repos are ready):");
lines.push(" cd </path/to/your/project>");
lines.push(" rm -rf node_modules");
lines.push(" npm ci");
lines.push("");
lines.push("4) Satisfy postinstall/preinstall binary downloads (node-pre-gyp, esbuild, etc.):");
lines.push(" - If scripts attempt to download binaries, point them to your offline assets path or Artifactory.");
lines.push(" - Common env vars (examples, set to a base URL where assets/ are available):");
lines.push(" Linux/macOS: export ... (see above Option B)");
lines.push(" Windows PowerShell: $env:... = '...' (see above Option B)");
lines.push(" Optionally: export ESBUILD_BINARY_PATH=<path-to-prebuilt-esbuild-if-needed>");
lines.push(" - If an install fails, check its docs for the correct env var and point it to your assets location.");
lines.push("");
lines.push("Counts:");
lines.push(` - Tarballs: ${pkgFiles.length}`);
lines.push(` - Assets: ${assetFiles.length}`);
return lines.join("\n");
}
async function safeList(dir) {
try {
const items = await fs.readdir(dir);
return items || [];
} catch {
return [];
}
}
module.exports = { run };