Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@
"docs": "typedoc",
"docs:watch": "typedoc --watch",
"prettier": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --check",
"prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write"
"prettier:fix": "prettier -u \"**/*.{js,jsx,ts,tsx,mjs,json,md,yml,yaml}\" --write",
"preflight": "node scripts/preflight.mjs",
"preflight:fix": "node scripts/preflight.mjs --fix"
},
"author": "Olivier Chafik",
"devDependencies": {
Expand Down
297 changes: 297 additions & 0 deletions scripts/preflight.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
#!/usr/bin/env node
/**
* Preflight check for package installation compatibility.
*
* Verifies that package-lock.json can be installed in the current environment.
* Useful for catching issues before submitting PRs or when switching between
* different npm registry configurations.
*
* Usage:
* node scripts/preflight.mjs # Check if install would succeed
* node scripts/preflight.mjs --fix # Regenerate lockfile via Docker (public registry)
* node scripts/preflight.mjs --local # Delete lockfile and reinstall locally
*
* Exit codes:
* 0 - All checks passed
* 1 - Issues found (see output for details)
*/

import { existsSync, readFileSync, unlinkSync, rmSync } from "fs";
import { execSync, spawn } from "child_process";
import { dirname, join } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..");

// Parse CLI flags
const args = process.argv.slice(2);
const FIX_DOCKER = args.includes("--fix");
const FIX_LOCAL = args.includes("--local");
const VERBOSE = args.includes("--verbose") || args.includes("-v");
const HELP = args.includes("--help") || args.includes("-h");

if (HELP) {
console.log(`
Preflight check for package installation compatibility.

Usage:
node scripts/preflight.mjs [options]

Options:
--fix Regenerate package-lock.json using Docker (public npm registry)
--local Delete package-lock.json and reinstall using local registry
--verbose Show detailed progress
--help Show this help message

Examples:
# Check if current lockfile can be installed
node scripts/preflight.mjs

# Fix by regenerating from public registry (requires Docker)
node scripts/preflight.mjs --fix

# Fix by regenerating from your configured registry
node scripts/preflight.mjs --local
`);
process.exit(0);
}

// Detect environment
const isCI = Boolean(process.env.CI);
const registryUrl = getRegistryUrl();
const isInternalRegistry = !registryUrl.includes("registry.npmjs.org");

function getRegistryUrl() {
try {
return execSync("npm config get registry", { encoding: "utf-8" }).trim();
} catch {
return "https://registry.npmjs.org/";
}
}

function log(msg) {
console.log(msg);
}

function verbose(msg) {
if (VERBOSE) console.log(` ${msg}`);
}

// ============================================================================
// Fix modes
// ============================================================================

if (FIX_DOCKER) {
log("🐳 Regenerating package-lock.json using Docker (public npm registry)...\n");

if (!commandExists("docker")) {
console.error("❌ Docker is not installed or not in PATH.");
console.error(" Install Docker or use --local to regenerate with your current registry.");
process.exit(1);
}

try {
// Read current prepare script to restore it later
const pkgJson = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8"));
const prepareScript = pkgJson.scripts?.prepare || "";

execSync(
`docker run --rm -v "${projectRoot}:/app" -w /app node:20 bash -c '
# Temporarily disable prepare script
node -e "
const fs = require(\\\"fs\\\");
const pkg = JSON.parse(fs.readFileSync(\\\"package.json\\\"));
pkg.scripts = pkg.scripts || {};
pkg.scripts.prepare = \\\"echo skipped\\\";
fs.writeFileSync(\\\"package.json\\\", JSON.stringify(pkg, null, 2));
"
rm -f package-lock.json
npm install --ignore-scripts 2>&1
# Restore prepare script
node -e "
const fs = require(\\\"fs\\\");
const pkg = JSON.parse(fs.readFileSync(\\\"package.json\\\"));
pkg.scripts = pkg.scripts || {};
pkg.scripts.prepare = ${JSON.stringify(prepareScript)};
fs.writeFileSync(\\\"package.json\\\", JSON.stringify(pkg, null, 2));
"
'`,
{ stdio: "inherit", cwd: projectRoot }
);

log("\n✅ Regenerated package-lock.json from public npm registry.");
log(" Please review changes and commit if correct.");
process.exit(0);
} catch (err) {
console.error("\n❌ Failed to regenerate lockfile:", err.message);
process.exit(1);
}
}

if (FIX_LOCAL) {
log("🔄 Regenerating package-lock.json using local registry...\n");

const lockfilePath = join(projectRoot, "package-lock.json");
const nodeModulesPath = join(projectRoot, "node_modules");

try {
if (existsSync(lockfilePath)) {
unlinkSync(lockfilePath);
verbose("Deleted package-lock.json");
}
if (existsSync(nodeModulesPath)) {
rmSync(nodeModulesPath, { recursive: true, force: true });
verbose("Deleted node_modules");
}

log("Running npm install...\n");
execSync("npm install", { stdio: "inherit", cwd: projectRoot });

log("\n✅ Regenerated package-lock.json from your configured registry.");
log(" Note: This lockfile may differ from the one in the repository.");
process.exit(0);
} catch (err) {
console.error("\n❌ Failed to regenerate lockfile:", err.message);
process.exit(1);
}
}

// ============================================================================
// Check mode (default)
// ============================================================================

log("🔍 Preflight check: verifying package-lock.json compatibility\n");

if (isInternalRegistry) {
verbose(`Registry: ${registryUrl} (internal)`);
} else {
verbose(`Registry: ${registryUrl} (public)`);
}

// Fast path: try npm install --dry-run
log("Running dry-run install...");

const dryRunResult = await runDryInstall();

if (dryRunResult.success) {
log("\n✅ Preflight check passed. All packages are available.");
process.exit(0);
}

// Parse missing packages from error output
const missingPackages = parseMissingPackages(dryRunResult.stderr);

if (missingPackages.length === 0) {
// Unknown error - show raw output
console.error("\n❌ Install failed with unexpected error:\n");
console.error(dryRunResult.stderr);
process.exit(1);
}

// Report missing packages
log(`\n❌ ${missingPackages.length} package(s) not available:\n`);
for (const pkg of missingPackages) {
log(` - ${pkg}`);
}

// Provide context-aware recommendations
log("\n" + "─".repeat(60));

if (isCI) {
log("\n⚠️ CI Environment Detected");
log(" The package-lock.json contains packages not available in the registry.");
log(" This PR should regenerate the lockfile using:");
log(" node scripts/preflight.mjs --fix");
process.exit(1);
}

if (isInternalRegistry) {
log("\n💡 You're using an internal npm registry.");
log(" The lockfile was generated with newer package versions.");
log("\n Options:");
log(" 1. Regenerate lockfile from your registry (versions may differ):");
log(" node scripts/preflight.mjs --local");
log("\n 2. Request the missing packages be synced to your internal registry.");
} else {
log("\n💡 To fix, regenerate the lockfile from the public registry:");
log(" node scripts/preflight.mjs --fix");
}

process.exit(1);

// ============================================================================
// Helper functions
// ============================================================================

function commandExists(cmd) {
try {
execSync(`which ${cmd}`, { stdio: "pipe" });
return true;
} catch {
return false;
}
}

function runDryInstall() {
return new Promise((resolve) => {
const child = spawn("npm", ["install", "--dry-run", "--ignore-scripts"], {
cwd: projectRoot,
stdio: ["pipe", "pipe", "pipe"],
});

let stdout = "";
let stderr = "";

child.stdout.on("data", (data) => {
stdout += data.toString();
});

child.stderr.on("data", (data) => {
stderr += data.toString();
});

child.on("close", (code) => {
resolve({
success: code === 0,
stdout,
stderr,
});
});

child.on("error", (err) => {
resolve({
success: false,
stdout,
stderr: err.message,
});
});
});
}

function parseMissingPackages(stderr) {
const missing = [];

// Match patterns like:
// npm error 404 Not Found - GET https://registry/package-name
// npm error notarget No matching version found for package@version
const notFoundRegex = /npm error 404.*?[-/]([^/\s]+(?:\/[^/\s]+)?)\s*$/gm;
const noTargetRegex = /npm error notarget.*?for\s+(\S+)/gm;

let match;
while ((match = notFoundRegex.exec(stderr)) !== null) {
const pkg = match[1].replace(/%2f/gi, "/");
if (!missing.includes(pkg)) {
missing.push(pkg);
}
}

while ((match = noTargetRegex.exec(stderr)) !== null) {
const pkg = match[1];
if (!missing.includes(pkg)) {
missing.push(pkg);
}
}

return missing;
}
Loading