Spaces:
Running
Running
| import { existsSync, readFileSync } from 'node:fs'; | |
| import { join, resolve } from 'node:path'; | |
| import { pathToFileURL } from 'node:url'; | |
| import { NPM_INSTALL_POLICY_CHECK_COMMAND } from './npm-install-policy.mjs'; | |
| export const LOCAL_DEPENDENCY_INSTALL_COMMAND = | |
| `npm run install-scripts:check && ${NPM_INSTALL_POLICY_CHECK_COMMAND} && npm ci --strict-allow-scripts && npm run dependencies:check`; | |
| export function inspectDependencyInstallation(cwd = process.cwd()) { | |
| const root = resolve(cwd); | |
| const nodeModulesPath = join(root, 'node_modules'); | |
| const rootLockfilePath = join(root, 'package-lock.json'); | |
| const hiddenLockfilePath = join(nodeModulesPath, '.package-lock.json'); | |
| if (!existsSync(nodeModulesPath)) return buildFailure('node_modules_missing'); | |
| const rootLockfile = readLockfile(rootLockfilePath, true); | |
| if (!rootLockfile.ok) return buildFailure(rootLockfile.reason); | |
| const hiddenLockfile = readLockfile(hiddenLockfilePath, false); | |
| if (!hiddenLockfile.ok) return buildFailure(hiddenLockfile.reason); | |
| const directDependencies = collectDirectDependencies(rootLockfile.value.packages['']); | |
| const rootLockMismatches = collectRootLockMismatches(directDependencies, rootLockfile.value.packages); | |
| if (rootLockMismatches.length) return buildFailure('root_lockfile_direct_package_missing', { directDependencies, rootLockMismatches }); | |
| const hiddenLockMismatches = collectHiddenLockMismatches( | |
| directDependencies, | |
| rootLockfile.value.packages, | |
| hiddenLockfile.value.packages | |
| ); | |
| if (hiddenLockMismatches.length) { | |
| return buildFailure('hidden_lockfile_package_mismatch', { directDependencies, hiddenLockMismatches }); | |
| } | |
| const installedPackages = inspectDirectPackageManifests(nodeModulesPath, directDependencies, rootLockfile.value.packages); | |
| if (installedPackages.missingPackages.length) { | |
| return buildFailure('direct_package_missing', { directDependencies, ...installedPackages }); | |
| } | |
| if (installedPackages.invalidPackages.length) { | |
| return buildFailure('direct_package_manifest_invalid', { directDependencies, ...installedPackages }); | |
| } | |
| if (installedPackages.nameMismatches.length) { | |
| return buildFailure('direct_package_name_mismatch', { directDependencies, ...installedPackages }); | |
| } | |
| if (installedPackages.versionMismatches.length) { | |
| return buildFailure('direct_package_version_mismatch', { directDependencies, ...installedPackages }); | |
| } | |
| return { ok: true, directDependencies }; | |
| } | |
| function readLockfile(path, isRootLockfile) { | |
| if (!existsSync(path)) return { ok: false, reason: isRootLockfile ? 'root_lockfile_missing' : 'hidden_lockfile_missing' }; | |
| try { | |
| const value = JSON.parse(readFileSync(path, 'utf8')); | |
| if (!isLockfile(value, isRootLockfile)) { | |
| return { ok: false, reason: isRootLockfile ? 'root_lockfile_invalid' : 'hidden_lockfile_invalid' }; | |
| } | |
| return { ok: true, value }; | |
| } catch { | |
| return { ok: false, reason: isRootLockfile ? 'root_lockfile_invalid' : 'hidden_lockfile_invalid' }; | |
| } | |
| } | |
| function isLockfile(value, requiresRootPackage) { | |
| if (!value || typeof value !== 'object' || Array.isArray(value)) return false; | |
| if (!Number.isInteger(value.lockfileVersion) || !value.packages || typeof value.packages !== 'object') return false; | |
| return !requiresRootPackage || Boolean(value.packages[''] && typeof value.packages[''] === 'object'); | |
| } | |
| function collectDirectDependencies(rootPackage) { | |
| const names = new Set(); | |
| for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) { | |
| const dependencies = rootPackage?.[field]; | |
| if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue; | |
| for (const name of Object.keys(dependencies)) names.add(name); | |
| } | |
| return [...names].sort(); | |
| } | |
| function collectRootLockMismatches(directDependencies, packages) { | |
| return directDependencies.filter((name) => !readPackageVersion(packages, `node_modules/${name}`)); | |
| } | |
| function collectHiddenLockMismatches(directDependencies, rootPackages, hiddenPackages) { | |
| return directDependencies.flatMap((name) => { | |
| const expected = readPackageVersion(rootPackages, `node_modules/${name}`); | |
| const actual = readPackageVersion(hiddenPackages, `node_modules/${name}`); | |
| return actual === expected ? [] : [{ name, expected, actual }]; | |
| }); | |
| } | |
| function inspectDirectPackageManifests(nodeModulesPath, directDependencies, rootPackages) { | |
| const missingPackages = []; | |
| const invalidPackages = []; | |
| const nameMismatches = []; | |
| const versionMismatches = []; | |
| for (const name of directDependencies) { | |
| const manifestPath = join(nodeModulesPath, name, 'package.json'); | |
| if (!existsSync(manifestPath)) { | |
| missingPackages.push(name); | |
| continue; | |
| } | |
| const manifest = readPackageManifest(manifestPath); | |
| if (!manifest) { | |
| invalidPackages.push(name); | |
| continue; | |
| } | |
| if (manifest.name !== name) nameMismatches.push({ expected: name, actual: manifest.name }); | |
| const expected = readPackageVersion(rootPackages, `node_modules/${name}`); | |
| if (manifest.version !== expected) versionMismatches.push({ name, expected, actual: manifest.version }); | |
| } | |
| return { missingPackages, invalidPackages, nameMismatches, versionMismatches }; | |
| } | |
| function readPackageManifest(path) { | |
| try { | |
| const manifest = JSON.parse(readFileSync(path, 'utf8')); | |
| if (!manifest || typeof manifest !== 'object' || typeof manifest.name !== 'string' || typeof manifest.version !== 'string') { | |
| return undefined; | |
| } | |
| return manifest; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| function readPackageVersion(packages, packagePath, visited = new Set()) { | |
| if (visited.has(packagePath)) return undefined; | |
| visited.add(packagePath); | |
| const manifest = packages[packagePath]; | |
| if (typeof manifest?.version === 'string' && manifest.version.length > 0) return manifest.version; | |
| if (!manifest?.link || typeof manifest.resolved !== 'string') return undefined; | |
| return readPackageVersion(packages, manifest.resolved, visited); | |
| } | |
| function buildFailure(reason, details = {}) { | |
| return { | |
| ok: false, | |
| reason, | |
| directDependencies: details.directDependencies || [], | |
| missingPackages: details.missingPackages || [], | |
| invalidPackages: details.invalidPackages || [], | |
| nameMismatches: details.nameMismatches || [], | |
| versionMismatches: details.versionMismatches || [], | |
| rootLockMismatches: details.rootLockMismatches || [], | |
| hiddenLockMismatches: details.hiddenLockMismatches || [] | |
| }; | |
| } | |
| function main() { | |
| const result = inspectDependencyInstallation(); | |
| if (result.ok) return; | |
| console.error(JSON.stringify(result, null, 2)); | |
| process.exitCode = 1; | |
| } | |
| if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) main(); | |