Skip to content

Commit 35ea69c

Browse files
ochafikclaude
andcommitted
feat: add preflight check for package installation compatibility
Adds a fast preflight check that verifies package-lock.json can be installed in the current environment. This helps contributors catch issues before submitting PRs when their npm registry configuration differs from the public registry. Motivation: #176 addressed compatibility issues with @oven/bun-* packages by widening version ranges. This script provides tooling to detect and fix such issues proactively. Usage: npm run preflight # Check if lockfile is installable (~2 sec) npm run preflight:fix # Regenerate lockfile via Docker The script uses `npm install --dry-run` for fast detection and provides context-aware recommendations based on the environment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 3e95d64 commit 35ea69c

File tree

2 files changed

+300
-1
lines changed

2 files changed

+300
-1
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
"docs": "typedoc",
5656
"docs:watch": "typedoc --watch",
5757
"prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check",
58-
"prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write"
58+
"prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write",
59+
"preflight": "node scripts/preflight.mjs",
60+
"preflight:fix": "node scripts/preflight.mjs --fix"
5961
},
6062
"author": "Olivier Chafik",
6163
"devDependencies": {

scripts/preflight.mjs

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
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

Comments
 (0)