#!/usr/bin/env node /** * Supply chain hardening: expands transitive dependencies of a package into * explicit, pinned direct dependencies in package.json. * * This ensures that `pnpm install nx@latest` produces a fully deterministic * install with zero resolver freedom — every package version is predetermined. * * Usage: * npx ts-node scripts/expand-deps.ts --project nx * npx ts-node scripts/expand-deps.ts --project nx --dry-run */ import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import * as yaml from 'yaml'; const workspaceRoot = join(__dirname, '..'); interface LockfileImporterDep { specifier: string; version: string; } interface LockfileSnapshot { dependencies?: Record; optionalDependencies?: Record; } interface Lockfile { importers: Record< string, { dependencies?: Record; optionalDependencies?: Record; } >; snapshots: Record; } function parseArgs(): { project: string; dryRun: boolean; verbose: boolean } { const args = process.argv.slice(2); let project = ''; let dryRun = false; let verbose = false; for (const arg of args) { if (arg === '--dry-run') { dryRun = true; } else if (arg === '--verbose') { verbose = true; } else if (!arg.startsWith('-')) { project = arg; } } if (!project) { console.error('Usage: expand-deps [--dry-run] [--verbose]'); process.exit(1); } return { project, dryRun, verbose }; } /** * Strip peer dep qualifiers from a version string. * e.g. "1.15.11(debug@4.4.1)" -> "1.15.11" */ function stripPeerQualifier(version: string): string { const parenIndex = version.indexOf('('); return parenIndex === -1 ? version : version.substring(0, parenIndex); } /** * Build the snapshot lookup key for a package. * In the snapshots section, entries are keyed as "name@version" or * "name@version(peer-qualifiers)". We need to find the right entry. */ function findSnapshotEntry( snapshots: Record, name: string, version: string ): LockfileSnapshot | null { // Try exact match first (name@version) const exactKey = `${name}@${version}`; if (snapshots[exactKey]) { return snapshots[exactKey]; } // Look for entries with peer qualifiers (name@version(...)) for (const key of Object.keys(snapshots)) { if (key === exactKey || key.startsWith(`${exactKey}(`)) { return snapshots[key]; } } return null; } interface ConflictInfo { name: string; version1: string; path1: string[]; version2: string; path2: string[]; } function walkDeps( snapshots: Record, name: string, version: string, resolved: Map, conflicts: ConflictInfo[], currentPath: string[] ): void { const path = [...currentPath, `${name}@${version}`]; // Check for conflicts const existing = resolved.get(name); if (existing) { if (existing.version === version) { return; // Already visited with same version } conflicts.push({ name, version1: existing.version, path1: existing.path, version2: version, path2: path, }); return; } resolved.set(name, { version, path }); const snapshot = findSnapshotEntry(snapshots, name, version); if (!snapshot) { // Leaf dependency with no sub-deps (e.g. "cli-spinners@2.6.1: {}") return; } const deps = snapshot.dependencies || {}; for (const [depName, depVersionRaw] of Object.entries(deps)) { const depVersion = stripPeerQualifier(depVersionRaw); walkDeps(snapshots, depName, depVersion, resolved, conflicts, path); } } function main() { const { project, dryRun, verbose } = parseArgs(); const packageJsonPath = join( workspaceRoot, 'packages', project, 'package.json' ); const lockfilePath = join(workspaceRoot, 'pnpm-lock.yaml'); // Read package.json const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); const originalDeps: Record = packageJson.dependencies || {}; const directDepNames = new Set(Object.keys(originalDeps)); // Parse lockfile console.log('Parsing pnpm-lock.yaml...'); const lockfileContent = readFileSync(lockfilePath, 'utf-8'); const lockfile: Lockfile = yaml.parse(lockfileContent); // Find the importer entry for this package const importerKey = `packages/${project}`; const importer = lockfile.importers[importerKey]; if (!importer) { console.error( `ERROR: No importer entry found for "${importerKey}" in pnpm-lock.yaml` ); process.exit(1); } const importerDeps = importer.dependencies || {}; // Resolve direct deps to their pinned versions from the lockfile const directDepsResolved = new Map< string, { specifier: string; resolvedVersion: string } >(); const skippedDeps: string[] = []; for (const depName of directDepNames) { const importerDep = importerDeps[depName]; if (!importerDep) { // This dep is in package.json but not in the lockfile importer section. // This happens when package.json is dirty from a previous expand-deps run // that wasn't cleaned up, or when someone forgot to run `pnpm install`. // In CI we fail hard; locally we skip and warn. if (process.env.CI) { console.error( `ERROR: Direct dependency "${depName}" not found in lockfile importers section. Run \`pnpm install\` first.` ); process.exit(1); } skippedDeps.push(depName); continue; } directDepsResolved.set(depName, { specifier: originalDeps[depName], resolvedVersion: stripPeerQualifier(importerDep.version), }); } if (skippedDeps.length > 0) { console.warn( `\nWARNING: ${skippedDeps.length} dependencies were not expanded since they were missing in the lockfile. Run with --verbose to see the list. Make sure you run \`pnpm install\` first.` ); if (verbose) { for (const dep of skippedDeps) { console.warn(` - ${dep}`); } } } // Walk the full transitive dependency tree const resolved = new Map(); const conflicts: ConflictInfo[] = []; for (const [depName, { resolvedVersion }] of directDepsResolved) { walkDeps( lockfile.snapshots, depName, resolvedVersion, resolved, conflicts, [] ); } // Check for conflicts if (conflicts.length > 0) { console.error('\nERROR: Version conflicts detected:\n'); for (const conflict of conflicts) { console.error(` "${conflict.name}":`); console.error(` Version ${conflict.version1}`); console.error(` Path: ${conflict.path1.join(' -> ')}`); console.error(` Version ${conflict.version2}`); console.error(` Path: ${conflict.path2.join(' -> ')}`); console.error(''); } console.error('Resolve manually before publishing.'); process.exit(1); } // Build the expanded dependencies object const expandedDeps: Record = {}; const sortedNames = [...resolved.keys()].sort(); for (const name of sortedNames) { expandedDeps[name] = resolved.get(name)!.version; } // Report console.log(`\nexpand-deps: ${project}\n`); // Direct deps changes console.log('Direct deps (resolved from lockfile):'); for (const [depName, { specifier, resolvedVersion }] of directDepsResolved) { if (specifier === resolvedVersion) { console.log(` ${depName}: ${resolvedVersion} (unchanged)`); } else { console.log( ` ${depName}: ${resolvedVersion} (was "${specifier}" — ${specifier.startsWith('catalog:') ? 'resolved' : 'pinned'})` ); } } // New transitive deps const transitiveDeps = sortedNames.filter( (name) => !directDepNames.has(name) ); console.log(`\nNew transitive deps to add (${transitiveDeps.length}):`); for (const name of transitiveDeps) { console.log(` ${name}: ${resolved.get(name)!.version}`); } console.log( `\nTotal: ${directDepNames.size} direct -> ${resolved.size} total deps (${transitiveDeps.length} transitive added)` ); if (dryRun) { console.log('\n--dry-run: no changes written.'); return; } // Write back packageJson.dependencies = expandedDeps; writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); console.log(`\nWrote expanded dependencies to ${packageJsonPath}`); } main();