UNPKG

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
"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 };