|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Preflight check for package installation compatibility. |
| 4 | + * |
| 5 | + * Verifies that package-lock.json can be installed in the current environment. |
| 6 | + * Useful for catching issues before submitting PRs or when switching between |
| 7 | + * different npm registry configurations. |
| 8 | + * |
| 9 | + * Usage: |
| 10 | + * node scripts/preflight.mjs # Check if install would succeed |
| 11 | + * node scripts/preflight.mjs --fix # Regenerate lockfile via Docker (public registry) |
| 12 | + * node scripts/preflight.mjs --local # Delete lockfile and reinstall locally |
| 13 | + * |
| 14 | + * Exit codes: |
| 15 | + * 0 - All checks passed |
| 16 | + * 1 - Issues found (see output for details) |
| 17 | + */ |
| 18 | + |
| 19 | +import { existsSync, readFileSync, unlinkSync, rmSync } from "fs"; |
| 20 | +import { execSync, spawn } from "child_process"; |
| 21 | +import { dirname, join } from "path"; |
| 22 | +import { fileURLToPath } from "url"; |
| 23 | + |
| 24 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 25 | +const projectRoot = join(__dirname, ".."); |
| 26 | + |
| 27 | +// Parse CLI flags |
| 28 | +const args = process.argv.slice(2); |
| 29 | +const FIX_DOCKER = args.includes("--fix"); |
| 30 | +const FIX_LOCAL = args.includes("--local"); |
| 31 | +const VERBOSE = args.includes("--verbose") || args.includes("-v"); |
| 32 | +const HELP = args.includes("--help") || args.includes("-h"); |
| 33 | + |
| 34 | +if (HELP) { |
| 35 | + console.log(` |
| 36 | +Preflight check for package installation compatibility. |
| 37 | +
|
| 38 | +Usage: |
| 39 | + node scripts/preflight.mjs [options] |
| 40 | +
|
| 41 | +Options: |
| 42 | + --fix Regenerate package-lock.json using Docker (public npm registry) |
| 43 | + --local Delete package-lock.json and reinstall using local registry |
| 44 | + --verbose Show detailed progress |
| 45 | + --help Show this help message |
| 46 | +
|
| 47 | +Examples: |
| 48 | + # Check if current lockfile can be installed |
| 49 | + node scripts/preflight.mjs |
| 50 | +
|
| 51 | + # Fix by regenerating from public registry (requires Docker) |
| 52 | + node scripts/preflight.mjs --fix |
| 53 | +
|
| 54 | + # Fix by regenerating from your configured registry |
| 55 | + node scripts/preflight.mjs --local |
| 56 | +`); |
| 57 | + process.exit(0); |
| 58 | +} |
| 59 | + |
| 60 | +// Detect environment |
| 61 | +const isCI = Boolean(process.env.CI); |
| 62 | +const registryUrl = getRegistryUrl(); |
| 63 | +const isInternalRegistry = !registryUrl.includes("registry.npmjs.org"); |
| 64 | + |
| 65 | +function getRegistryUrl() { |
| 66 | + try { |
| 67 | + return execSync("npm config get registry", { encoding: "utf-8" }).trim(); |
| 68 | + } catch { |
| 69 | + return "https://registry.npmjs.org/"; |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +function log(msg) { |
| 74 | + console.log(msg); |
| 75 | +} |
| 76 | + |
| 77 | +function verbose(msg) { |
| 78 | + if (VERBOSE) console.log(` ${msg}`); |
| 79 | +} |
| 80 | + |
| 81 | +// ============================================================================ |
| 82 | +// Fix modes |
| 83 | +// ============================================================================ |
| 84 | + |
| 85 | +if (FIX_DOCKER) { |
| 86 | + log("🐳 Regenerating package-lock.json using Docker (public npm registry)...\n"); |
| 87 | + |
| 88 | + if (!commandExists("docker")) { |
| 89 | + console.error("❌ Docker is not installed or not in PATH."); |
| 90 | + console.error(" Install Docker or use --local to regenerate with your current registry."); |
| 91 | + process.exit(1); |
| 92 | + } |
| 93 | + |
| 94 | + try { |
| 95 | + // Read current prepare script to restore it later |
| 96 | + const pkgJson = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8")); |
| 97 | + const prepareScript = pkgJson.scripts?.prepare || ""; |
| 98 | + |
| 99 | + execSync( |
| 100 | + `docker run --rm -v "${projectRoot}:/app" -w /app node:20 bash -c ' |
| 101 | + # Temporarily disable prepare script |
| 102 | + node -e " |
| 103 | + const fs = require(\\\"fs\\\"); |
| 104 | + const pkg = JSON.parse(fs.readFileSync(\\\"package.json\\\")); |
| 105 | + pkg.scripts = pkg.scripts || {}; |
| 106 | + pkg.scripts.prepare = \\\"echo skipped\\\"; |
| 107 | + fs.writeFileSync(\\\"package.json\\\", JSON.stringify(pkg, null, 2)); |
| 108 | + " |
| 109 | + rm -f package-lock.json |
| 110 | + npm install --ignore-scripts 2>&1 |
| 111 | + # Restore prepare script |
| 112 | + node -e " |
| 113 | + const fs = require(\\\"fs\\\"); |
| 114 | + const pkg = JSON.parse(fs.readFileSync(\\\"package.json\\\")); |
| 115 | + pkg.scripts = pkg.scripts || {}; |
| 116 | + pkg.scripts.prepare = ${JSON.stringify(prepareScript)}; |
| 117 | + fs.writeFileSync(\\\"package.json\\\", JSON.stringify(pkg, null, 2)); |
| 118 | + " |
| 119 | + '`, |
| 120 | + { stdio: "inherit", cwd: projectRoot } |
| 121 | + ); |
| 122 | + |
| 123 | + log("\n✅ Regenerated package-lock.json from public npm registry."); |
| 124 | + log(" Please review changes and commit if correct."); |
| 125 | + process.exit(0); |
| 126 | + } catch (err) { |
| 127 | + console.error("\n❌ Failed to regenerate lockfile:", err.message); |
| 128 | + process.exit(1); |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +if (FIX_LOCAL) { |
| 133 | + log("🔄 Regenerating package-lock.json using local registry...\n"); |
| 134 | + |
| 135 | + const lockfilePath = join(projectRoot, "package-lock.json"); |
| 136 | + const nodeModulesPath = join(projectRoot, "node_modules"); |
| 137 | + |
| 138 | + try { |
| 139 | + if (existsSync(lockfilePath)) { |
| 140 | + unlinkSync(lockfilePath); |
| 141 | + verbose("Deleted package-lock.json"); |
| 142 | + } |
| 143 | + if (existsSync(nodeModulesPath)) { |
| 144 | + rmSync(nodeModulesPath, { recursive: true, force: true }); |
| 145 | + verbose("Deleted node_modules"); |
| 146 | + } |
| 147 | + |
| 148 | + log("Running npm install...\n"); |
| 149 | + execSync("npm install", { stdio: "inherit", cwd: projectRoot }); |
| 150 | + |
| 151 | + log("\n✅ Regenerated package-lock.json from your configured registry."); |
| 152 | + log(" Note: This lockfile may differ from the one in the repository."); |
| 153 | + process.exit(0); |
| 154 | + } catch (err) { |
| 155 | + console.error("\n❌ Failed to regenerate lockfile:", err.message); |
| 156 | + process.exit(1); |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +// ============================================================================ |
| 161 | +// Check mode (default) |
| 162 | +// ============================================================================ |
| 163 | + |
| 164 | +log("🔍 Preflight check: verifying package-lock.json compatibility\n"); |
| 165 | + |
| 166 | +if (isInternalRegistry) { |
| 167 | + verbose(`Registry: ${registryUrl} (internal)`); |
| 168 | +} else { |
| 169 | + verbose(`Registry: ${registryUrl} (public)`); |
| 170 | +} |
| 171 | + |
| 172 | +// Fast path: try npm install --dry-run |
| 173 | +log("Running dry-run install..."); |
| 174 | + |
| 175 | +const dryRunResult = await runDryInstall(); |
| 176 | + |
| 177 | +if (dryRunResult.success) { |
| 178 | + log("\n✅ Preflight check passed. All packages are available."); |
| 179 | + process.exit(0); |
| 180 | +} |
| 181 | + |
| 182 | +// Parse missing packages from error output |
| 183 | +const missingPackages = parseMissingPackages(dryRunResult.stderr); |
| 184 | + |
| 185 | +if (missingPackages.length === 0) { |
| 186 | + // Unknown error - show raw output |
| 187 | + console.error("\n❌ Install failed with unexpected error:\n"); |
| 188 | + console.error(dryRunResult.stderr); |
| 189 | + process.exit(1); |
| 190 | +} |
| 191 | + |
| 192 | +// Report missing packages |
| 193 | +log(`\n❌ ${missingPackages.length} package(s) not available:\n`); |
| 194 | +for (const pkg of missingPackages) { |
| 195 | + log(` - ${pkg}`); |
| 196 | +} |
| 197 | + |
| 198 | +// Provide context-aware recommendations |
| 199 | +log("\n" + "─".repeat(60)); |
| 200 | + |
| 201 | +if (isCI) { |
| 202 | + log("\n⚠️ CI Environment Detected"); |
| 203 | + log(" The package-lock.json contains packages not available in the registry."); |
| 204 | + log(" This PR should regenerate the lockfile using:"); |
| 205 | + log(" node scripts/preflight.mjs --fix"); |
| 206 | + process.exit(1); |
| 207 | +} |
| 208 | + |
| 209 | +if (isInternalRegistry) { |
| 210 | + log("\n💡 You're using an internal npm registry."); |
| 211 | + log(" The lockfile was generated with newer package versions."); |
| 212 | + log("\n Options:"); |
| 213 | + log(" 1. Regenerate lockfile from your registry (versions may differ):"); |
| 214 | + log(" node scripts/preflight.mjs --local"); |
| 215 | + log("\n 2. Request the missing packages be synced to your internal registry."); |
| 216 | +} else { |
| 217 | + log("\n💡 To fix, regenerate the lockfile from the public registry:"); |
| 218 | + log(" node scripts/preflight.mjs --fix"); |
| 219 | +} |
| 220 | + |
| 221 | +process.exit(1); |
| 222 | + |
| 223 | +// ============================================================================ |
| 224 | +// Helper functions |
| 225 | +// ============================================================================ |
| 226 | + |
| 227 | +function commandExists(cmd) { |
| 228 | + try { |
| 229 | + execSync(`which ${cmd}`, { stdio: "pipe" }); |
| 230 | + return true; |
| 231 | + } catch { |
| 232 | + return false; |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +function runDryInstall() { |
| 237 | + return new Promise((resolve) => { |
| 238 | + const child = spawn("npm", ["install", "--dry-run", "--ignore-scripts"], { |
| 239 | + cwd: projectRoot, |
| 240 | + stdio: ["pipe", "pipe", "pipe"], |
| 241 | + }); |
| 242 | + |
| 243 | + let stdout = ""; |
| 244 | + let stderr = ""; |
| 245 | + |
| 246 | + child.stdout.on("data", (data) => { |
| 247 | + stdout += data.toString(); |
| 248 | + }); |
| 249 | + |
| 250 | + child.stderr.on("data", (data) => { |
| 251 | + stderr += data.toString(); |
| 252 | + }); |
| 253 | + |
| 254 | + child.on("close", (code) => { |
| 255 | + resolve({ |
| 256 | + success: code === 0, |
| 257 | + stdout, |
| 258 | + stderr, |
| 259 | + }); |
| 260 | + }); |
| 261 | + |
| 262 | + child.on("error", (err) => { |
| 263 | + resolve({ |
| 264 | + success: false, |
| 265 | + stdout, |
| 266 | + stderr: err.message, |
| 267 | + }); |
| 268 | + }); |
| 269 | + }); |
| 270 | +} |
| 271 | + |
| 272 | +function parseMissingPackages(stderr) { |
| 273 | + const missing = []; |
| 274 | + |
| 275 | + // Match patterns like: |
| 276 | + // npm error 404 Not Found - GET https://registry/package-name |
| 277 | + // npm error notarget No matching version found for package@version |
| 278 | + const notFoundRegex = /npm error 404.*?[-/]([^/\s]+(?:\/[^/\s]+)?)\s*$/gm; |
| 279 | + const noTargetRegex = /npm error notarget.*?for\s+(\S+)/gm; |
| 280 | + |
| 281 | + let match; |
| 282 | + while ((match = notFoundRegex.exec(stderr)) !== null) { |
| 283 | + const pkg = match[1].replace(/%2f/gi, "/"); |
| 284 | + if (!missing.includes(pkg)) { |
| 285 | + missing.push(pkg); |
| 286 | + } |
| 287 | + } |
| 288 | + |
| 289 | + while ((match = noTargetRegex.exec(stderr)) !== null) { |
| 290 | + const pkg = match[1]; |
| 291 | + if (!missing.includes(pkg)) { |
| 292 | + missing.push(pkg); |
| 293 | + } |
| 294 | + } |
| 295 | + |
| 296 | + return missing; |
| 297 | +} |
0 commit comments