tools/@aws-cdk/yarn-cling/lib/index.ts (192 lines of code) (raw):

import { promises as fs, exists } from 'fs'; import * as path from 'path'; import * as lockfile from '@yarnpkg/lockfile'; import * as semver from 'semver'; import { hoistDependencies } from './hoisting'; import { PackageJson, PackageLock, PackageLockEntry, PackageLockPackage, YarnLock } from './types'; export interface ShrinkwrapOptions { /** * The package.json file to start scanning for dependencies */ packageJsonFile: string; /** * The output lockfile to generate * * @default - Don't generate the file, just return the calculated output */ outputFile?: string; /** * Whether to hoist dependencies * * @default true */ hoist?: boolean; } export async function generateShrinkwrap(options: ShrinkwrapOptions): Promise<PackageLock> { // No args (yet) const packageJsonFile = options.packageJsonFile; const packageJsonDir = path.dirname(packageJsonFile); const yarnLockLoc = await findYarnLock(packageJsonDir); const yarnLock: YarnLock = lockfile.parse(await fs.readFile(yarnLockLoc, { encoding: 'utf8' })); const pkgJson = await loadPackageJson(packageJsonFile); const lock = await generateLockFile(pkgJson, yarnLock, packageJsonDir); if (options.hoist ?? true) { hoistDependencies({ version: '*', dependencies: lock.dependencies }); } validateTree(lock); if (options.outputFile) { // Write the shrinkwrap file await fs.writeFile(options.outputFile, JSON.stringify(lock, undefined, 2), { encoding: 'utf8' }); } return lock; } async function generateLockFile(pkgJson: PackageJson, yarnLock: YarnLock, rootDir: string): Promise<PackageLock> { const lockFile = { name: pkgJson.name, version: pkgJson.version, lockfileVersion: 1, requires: true, dependencies: await dependenciesFor(pkgJson.dependencies || {}, yarnLock, rootDir, [pkgJson.name]), }; checkRequiredVersions(lockFile); return lockFile; } const CYCLES_REPORTED = new Set<string>(); // eslint-disable-next-line max-len async function dependenciesFor(deps: Record<string, string>, yarnLock: YarnLock, rootDir: string, dependencyPath: string[]): Promise<Record<string, PackageLockPackage>> { const ret: Record<string, PackageLockPackage> = {}; // Get rid of any monorepo symlinks rootDir = await fs.realpath(rootDir); for (const [depName, versionRange] of Object.entries(deps)) { if (dependencyPath.includes(depName)) { const index = dependencyPath.indexOf(depName); const beforeCycle = dependencyPath.slice(0, index); const inCycle = [...dependencyPath.slice(index), depName]; const cycleString = inCycle.join(' => '); if (!CYCLES_REPORTED.has(cycleString)) { // eslint-disable-next-line no-console console.warn(`Dependency cycle: ${beforeCycle.join(' => ')} => [ ${cycleString} ]. Dropping dependency '${inCycle.slice(-2).join(' => ')}'.`); CYCLES_REPORTED.add(cycleString); } continue; } const depDir = await findPackageDir(depName, rootDir); const depPkgJsonFile = path.join(depDir, 'package.json'); const depPkgJson = await loadPackageJson(depPkgJsonFile); const yarnKey = `${depName}@${versionRange}`; // Sanity check if (depPkgJson.name !== depName) { throw new Error(`Looking for '${depName}' from ${rootDir}, but found '${depPkgJson.name}' in ${depDir}`); } const yarnResolved = yarnLock.object[yarnKey]; if (yarnResolved) { // Resolved by Yarn ret[depName] = { version: yarnResolved.version, integrity: yarnResolved.integrity, resolved: yarnResolved.resolved, requires: depPkgJson.dependencies, dependencies: await dependenciesFor(depPkgJson.dependencies || {}, yarnLock, depDir, [...dependencyPath, depName]), }; } else { // Comes from monorepo, just use whatever's in package.json ret[depName] = { version: depPkgJson.version, requires: depPkgJson.dependencies, dependencies: await dependenciesFor(depPkgJson.dependencies || {}, yarnLock, depDir, [...dependencyPath, depName]), }; } // Simplify by removing useless entries if (Object.keys(ret[depName].requires ?? {}).length === 0) { delete ret[depName].requires; } if (Object.keys(ret[depName].dependencies ?? {}).length === 0) { delete ret[depName].dependencies; } } return ret; } async function findYarnLock(start: string) { return findUp('yarn.lock', start); } async function findUp(fileName: string, start: string) { start = path.resolve(start); let dir = start; const yarnLockHere = () => path.join(dir, fileName); while (!await fileExists(yarnLockHere())) { const parent = path.dirname(dir); if (parent === dir) { throw new Error(`No ${fileName} found upwards from ${start}`); } dir = parent; } return yarnLockHere(); } async function loadPackageJson(fileName: string): Promise<PackageJson> { return JSON.parse(await fs.readFile(fileName, { encoding: 'utf8' })); } async function fileExists(fullPath: string): Promise<boolean> { try { await fs.stat(fullPath); return true; } catch (e: any) { if (e.code === 'ENOENT' || e.code === 'ENOTDIR') { return false; } throw e; } } export function formatPackageLock(entry: PackageLockEntry) { const lines = new Array<string>(); recurse([], entry); return lines.join('\n'); function recurse(names: string[], thisEntry: PackageLockEntry) { if (names.length > 0) { // eslint-disable-next-line no-console lines.push(`${names.join(' -> ')} @ ${thisEntry.version}`); } for (const [depName, depEntry] of Object.entries(thisEntry.dependencies || {})) { recurse([...names, depName], depEntry); } } } /** * Find package directory * * Do this by walking upwards in the directory tree until we find * `<dir>/node_modules/<package>/package.json`. * * ------- * * Things that we tried but don't work: * * 1. require.resolve(`${depName}/package.json`, { paths: [rootDir] }); * * Breaks with ES Modules if `package.json` has not been exported, which is * being enforced starting Node >= 12. * * 2. findPackageJsonUpwardFrom(require.resolve(depName, { paths: [rootDir] })) * * Breaks if a built-in NodeJS package name conflicts with an NPM package name * (in Node15 `string_decoder` is introduced...) */ async function findPackageDir(depName: string, rootDir: string) { let prevDir; let dir = rootDir; while (dir !== prevDir) { const candidateDir = path.join(dir, 'node_modules', depName); if (await new Promise(ok => exists(path.join(candidateDir, 'package.json'), ok))) { return candidateDir; } prevDir = dir; dir = path.dirname(dir); // dirname('/') -> '/', dirname('c:\\') -> 'c:\\' } throw new Error(`Did not find '${depName}' upwards of '${rootDir}'`); } /** * We may sometimes try to adjust a package version to a version that's incompatible with the declared requirement. * * For example, this recently happened for 'netmask', where the package we * depend on has `{ requires: { netmask: '^1.0.6', } }`, but we need to force-substitute in version `2.0.1`. * * If NPM processes the shrinkwrap and encounters the following situation: * * ``` * { * netmask: { version: '2.0.1' }, * resolver: { * requires: { * netmask: '^1.0.6' * } * } * } * ``` * * NPM is going to disregard the swhinkrwap and still give `resolver` its own private * copy of netmask `^1.0.6`. * * We tried overriding the `requires` version, and that works for `npm install` (yay) * but if anyone runs `npm ls` afterwards, `npm ls` is going to check the actual source * `package.jsons` against the actual `node_modules` file tree, and complain that the * versions don't match. * * We run `npm ls` in our tests to make sure our dependency tree is sane, and our customers * might too, so this is not a great solution. * * To cut any discussion short in the future, we're going to detect this situation and * tell our future selves that is cannot and will not work, and we should find another * solution. */ export function checkRequiredVersions(root: PackageLock | PackageLockPackage) { recurse(root, []); function recurse(entry: PackageLock | PackageLockPackage, parentChain: PackageLockEntry[]) { // On the root, 'requires' is the value 'true', for God knows what reason. Don't care about those. if (typeof entry.requires === 'object') { // For every 'requires' dependency, find the version it actually got resolved to and compare. for (const [name, range] of Object.entries(entry.requires)) { const resolvedPackage = findResolved(name, [entry, ...parentChain]); if (!resolvedPackage) { continue; } if (!semver.satisfies(resolvedPackage.version, range)) { // Ruh-roh. throw new Error(`Looks like we're trying to force '${name}' to version '${resolvedPackage.version}', but the dependency ` + `is specified as '${range}'. This can never properly work via shrinkwrapping. Try vendoring a patched ` + 'version of the intermediary dependencies instead.'); } } } for (const dep of Object.values(entry.dependencies ?? {})) { recurse(dep, [entry, ...parentChain]); } } /** * Find a package name in a package lock tree. */ function findResolved(name: string, chain: PackageLockEntry[]) { for (const level of chain) { if (level.dependencies?.[name]) { return level.dependencies?.[name]; } } return undefined; } } /** * Check that all packages still resolve their dependencies to the right versions * * We have manipulated the tree a bunch. Do a sanity check to ensure that all declared * dependencies are satisfied. */ function validateTree(lock: PackageLock) { let failed = false; recurse(lock, [lock]); if (failed) { throw new Error('Could not satisfy one or more dependencies'); } function recurse(pkg: PackageLockEntry, rootPath: PackageLockEntry[]) { for (const pack of Object.values(pkg.dependencies ?? {})) { const p = [pack, ...rootPath]; checkRequiresOf(pack, p); recurse(pack, p); } } // rootPath: most specific one first function checkRequiresOf(pack: PackageLockPackage, rootPath: PackageLockEntry[]) { for (const [name, declaredRange] of Object.entries(pack.requires ?? {})) { const foundVersion = rootPath.map((p) => p.dependencies?.[name]?.version).find(isDefined); if (!foundVersion) { // eslint-disable-next-line no-console console.error(`Dependency on ${name} not satisfied: not found`); failed = true; } else if (!semver.satisfies(foundVersion, declaredRange)) { // eslint-disable-next-line no-console console.error(`Dependency on ${name} not satisfied: declared range '${declaredRange}', found '${foundVersion}'`); failed = true; } } } } function isDefined<A>(x: A): x is NonNullable<A> { return x !== undefined; }