src/snippet-dependencies.ts (262 lines of code) (raw):

import * as cp from 'node:child_process'; import { promises as fsPromises } from 'node:fs'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { PackageJson } from '@jsii/spec'; import * as fastGlob from 'fast-glob'; import * as semver from 'semver'; import { findDependencyDirectory, findUp, isBuiltinModule } from './find-utils'; import * as logging from './logging'; import { TypeScriptSnippet, CompilationDependency } from './snippet'; import { mkDict, formatList, pathExists } from './util'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { intersect } = require('semver-intersect'); /** * Collect the dependencies of a bunch of snippets together in one declaration * * We assume here the dependencies will not conflict. */ export function collectDependencies(snippets: TypeScriptSnippet[]) { const ret: Record<string, CompilationDependency> = {}; for (const snippet of snippets) { for (const [name, source] of Object.entries(snippet.compilationDependencies ?? {})) { ret[name] = resolveConflict(name, source, ret[name]); } } return ret; } /** * Add transitive dependencies of concrete dependencies to the array * * This is necessary to prevent multiple copies of transitive dependencies on disk, which * jsii-based packages might not deal with very well. */ export async function expandWithTransitiveDependencies(deps: Record<string, CompilationDependency>) { const pathsSeen = new Set<string>(); const queue = Object.values(deps).filter(isConcrete); let next = queue.shift(); while (next) { await addDependenciesOf(next.resolvedDirectory); next = queue.shift(); } async function addDependenciesOf(dir: string) { if (pathsSeen.has(dir)) { return; } pathsSeen.add(dir); try { const pj: PackageJson = JSON.parse( await fsPromises.readFile(path.join(dir, 'package.json'), { encoding: 'utf-8' }), ); for (const [name, dep] of Object.entries(await resolveDependenciesFromPackageJson(pj, dir))) { if (!deps[name]) { deps[name] = dep; queue.push(dep); } } } catch (e: any) { if (e.code === 'ENOENT') { return; } throw e; } } } /** * Find the corresponding package directories for all dependencies in a package.json */ export async function resolveDependenciesFromPackageJson(packageJson: PackageJson | undefined, directory: string) { return mkDict( await Promise.all( Object.keys({ ...packageJson?.dependencies, ...packageJson?.peerDependencies }) .filter((name) => !isBuiltinModule(name)) .filter( (name) => !packageJson?.bundledDependencies?.includes(name) && !packageJson?.bundleDependencies?.includes(name), ) .map( async (name) => [ name, { type: 'concrete', resolvedDirectory: await fsPromises.realpath(await findDependencyDirectory(name, directory)), }, ] as const, ), ), ); } function resolveConflict( name: string, a: CompilationDependency, b: CompilationDependency | undefined, ): CompilationDependency { if (!b) { return a; } if (a.type === 'concrete' && b.type === 'concrete') { if (b.resolvedDirectory !== a.resolvedDirectory) { throw new Error(`Dependency conflict: ${name} can be either ${a.resolvedDirectory} or ${b.resolvedDirectory}`); } return a; } if (a.type === 'symbolic' && b.type === 'symbolic') { // Intersect the ranges return { type: 'symbolic', versionRange: myVersionIntersect(a.versionRange, b.versionRange), }; } if (a.type === 'concrete' && b.type === 'symbolic') { const concreteVersion: string = JSON.parse( fs.readFileSync(path.join(a.resolvedDirectory, 'package.json'), 'utf-8'), ).version; if (!semver.satisfies(concreteVersion, b.versionRange, { includePrerelease: true })) { throw new Error( `Dependency conflict: ${name} expected to match ${b.versionRange} but found ${concreteVersion} at ${a.resolvedDirectory}`, ); } return a; } if (a.type === 'symbolic' && b.type === 'concrete') { // Reverse roles so we fall into the previous case return resolveConflict(name, b, a); } throw new Error('Cases should have been exhaustive'); } /** * Check that the directory we were given has all the necessary dependencies in it * * It's a warning if this is not true, not an error. */ export async function validateAvailableDependencies(directory: string, deps: Record<string, CompilationDependency>) { logging.info(`Validating dependencies at ${directory}`); const failures = await Promise.all( Object.entries(deps).flatMap(async ([name, _dep]) => { try { await findDependencyDirectory(name, directory); return []; } catch { return [name]; } }), ); if (failures.length > 0) { logging.warn( `${directory}: packages necessary to compile examples missing from supplied directory: ${failures.join(', ')}`, ); } } /** * Intersect two semver ranges * * The package we are using for this doesn't support all syntaxes yet. * Do some work on top. */ function myVersionIntersect(a: string, b: string): string { if (a === '*') { return b; } if (b === '*') { return a; } try { return intersect(a, b); } catch (e: any) { throw new Error(`semver-intersect does not support either '${a}' or '${b}': ${e.message}`); } } /** * Prepare a temporary directory with symlinks to all the dependencies we need. * * - Symlinks the concrete dependencies * - Tries to first find the symbolic dependencies in a potential monorepo that might be present * (try both `lerna` and `yarn` monorepos). * - Installs the remaining symbolic dependencies using 'npm'. */ export async function prepareDependencyDirectory(deps: Record<string, CompilationDependency>): Promise<string> { const concreteDirs = Object.values(deps) .filter(isConcrete) .map((x) => x.resolvedDirectory); const monorepoPackages = await scanMonoRepos(concreteDirs); const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'rosetta')); logging.info(`Preparing dependency closure at ${tmpDir} (-vv for more details)`); // Resolved symbolic packages against monorepo const resolvedDeps = mkDict( Object.entries(deps).map(([name, dep]) => [ name, dep.type === 'concrete' ? dep : ((monorepoPackages[name] ? { type: 'concrete', resolvedDirectory: monorepoPackages[name] } : dep) as CompilationDependency), ]), ); const dependencies: Record<string, string> = {}; for (const [name, dep] of Object.entries(resolvedDeps)) { if (isConcrete(dep)) { logging.debug(`${name} -> ${dep.resolvedDirectory}`); dependencies[name] = `file:${dep.resolvedDirectory}`; } else { logging.debug(`${name} @ ${dep.versionRange}`); dependencies[name] = dep.versionRange; } } await fsPromises.writeFile( path.join(tmpDir, 'package.json'), JSON.stringify( { name: 'examples', version: '0.0.1', private: true, dependencies, }, undefined, 2, ), { encoding: 'utf-8', }, ); // Run NPM install on this package.json. cp.execSync( [ 'npm install', // We need to include --force for packages // that have a symbolic version in the symlinked dev tree (like "0.0.0"), but have // actual version range dependencies from externally installed packages (like "^2.0.0"). '--force', // this is critical from a security perspective to prevent // code execution as part of the install command using npm hooks. (e.g postInstall) '--ignore-scripts', // save time by not running audit '--no-audit', // ensures npm does not insert anything in $PATH '--no-bin-links', // don't write or update a package-lock.json file '--no-package-lock', // only print errors `--loglevel error`, ].join(' '), { cwd: tmpDir, encoding: 'utf-8', }, ); return tmpDir; } /** * Map package name to directory */ async function scanMonoRepos(startingDirs: readonly string[]): Promise<Record<string, string>> { const globs = new Set<string>(); for (const dir of startingDirs) { // eslint-disable-next-line no-await-in-loop setExtend(globs, await findMonoRepoGlobs(dir)); } if (globs.size === 0) { return {}; } logging.debug(`Monorepo package sources: ${Array.from(globs).join(', ')}`); const packageDirectories = await fastGlob(Array.from(globs).map(windowsToUnix), { onlyDirectories: true }); const results = mkDict( ( await Promise.all( packageDirectories.map(async (directory) => { const pjLocation = path.join(directory, 'package.json'); return (await pathExists(pjLocation)) ? [[JSON.parse(await fsPromises.readFile(pjLocation, 'utf-8')).name as string, directory] as const] : []; }), ) ).flat(), ); logging.debug(`Found ${Object.keys(results).length} packages in monorepo: ${formatList(Object.keys(results))}`); return results; } async function findMonoRepoGlobs(startingDir: string): Promise<Set<string>> { const ret = new Set<string>(); // Lerna monorepo const lernaJsonDir = await findUp(startingDir, async (dir) => pathExists(path.join(dir, 'lerna.json'))); if (lernaJsonDir) { const lernaJson = JSON.parse(await fsPromises.readFile(path.join(lernaJsonDir, 'lerna.json'), 'utf-8')); for (const glob of lernaJson?.packages ?? []) { ret.add(path.join(lernaJsonDir, glob)); } } // Yarn monorepo const yarnWsDir = await findUp( startingDir, async (dir) => (await pathExists(path.join(dir, 'package.json'))) && JSON.parse(await fsPromises.readFile(path.join(dir, 'package.json'), 'utf-8'))?.workspaces !== undefined, ); if (yarnWsDir) { const yarnWs = JSON.parse(await fsPromises.readFile(path.join(yarnWsDir, 'package.json'), 'utf-8')); for (const glob of yarnWs.workspaces?.packages ?? []) { ret.add(path.join(yarnWsDir, glob)); } } return ret; } function isConcrete(x: CompilationDependency): x is Extract<CompilationDependency, { type: 'concrete' }> { return x.type === 'concrete'; } function setExtend<A>(xs: Set<A>, ys: Set<A>) { for (const y of ys) { xs.add(y); } return xs; } /** * Necessary for fastGlob */ function windowsToUnix(x: string) { return x.replace(/\\/g, '/'); }