import { EventEmitter } from 'events'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import { helpers } from './helpers.ts'; import type { InstallTarget, PackageJson } from './index.ts'; import { utils } from './utils.ts'; import type { Options as ExecaOptions } from 'execa'; import { prober, type PackageManager } from './prober.ts'; interface PackageByDirectory { [directory: string]: PackageJson; } export interface Env { [name: string]: string; } export interface Options { npmEnv?: Env; packageManager?: PackageManager; concurrent?: number; } export interface ListByPackage { [key: string]: string[]; } const TEN_MEGA_BYTE = 1024 * 1024 * 10; interface EventMap { install_targets_identified: [InstallTarget[]]; install_start: [ListByPackage]; installed: [pkg: string, stdout: string, stderr: string]; packing_start: [allSources: string[]]; packed: [location: string]; packing_end: []; install_end: []; } export class LocalInstaller extends EventEmitter { private sourcesByTarget: ListByPackage; private options: Options; private uniqueDir: string; constructor(sourcesByTarget: ListByPackage, options?: Options) { super(); this.sourcesByTarget = resolve(sourcesByTarget); this.options = Object.assign({}, options); this.uniqueDir = utils.getRandomTmpDir('node-local-install-'); } public on( event: TEventName, listener: (...args: EventMap[TEventName]) => void, ): this { return super.on(event, listener); } public emit( event: TEventName, ...args: EventMap[TEventName] ): boolean { return super.emit(event, ...args); } public async install(): Promise { await this.createTmpDirectory(this.uniqueDir); const packages = await this.resolvePackages(); const installTargets = this.identifyInstallTargets(packages); await this.packAll(); await this.installAll(installTargets); await this.removeTmpDirectory(); return installTargets; } public async createTmpDirectory(tmpDir: string): Promise { return fs.mkdir(tmpDir); } private async installAll(installTargets: InstallTarget[]): Promise { this.emit('install_start', this.sourcesByTarget); // Install targets with max concurrent operations const batchSize = this.options.concurrent ?? os.cpus().length; for (let i = 0; i < installTargets.length; i += batchSize) { const batch = installTargets.slice(i, i + batchSize); await Promise.all(batch.map((target) => this.installOne(target))); } this.emit('install_end'); } private async installOne(target: InstallTarget): Promise { const toInstall = target.sources.map((source) => resolvePackFile(this.uniqueDir, source.packageJson), ); const pkgManager = this.options.packageManager ?? (await prober.probePackageManager()); const options: ExecaOptions = { cwd: target.directory, maxBuffer: TEN_MEGA_BYTE, env: { ...this.options.npmEnv, npm_config_save: 'false', npm_config_lockfile: 'false', }, }; const installArgs = pkgManager === 'pnpm' ? ['add', ...toInstall] : ['i', '--no-save', '--no-package-lock', ...toInstall]; const { stdout, stderr } = await utils.exec( pkgManager, installArgs, options, ); this.emit( 'installed', target.packageJson.name, stdout!.toString(), stderr!.toString(), ); } private async resolvePackages(): Promise { const uniqueDirectories = new Set( Object.keys(this.sourcesByTarget).concat( Object.keys(this.sourcesByTarget).flatMap( (target) => this.sourcesByTarget[target], ), ), ); const allPackages = Promise.all( Array.from(uniqueDirectories).map((directory) => helpers.readPackageJson(directory).then((packageJson) => ({ directory, packageJson, })), ), ); const packages = await allPackages; const packageByDirectory: PackageByDirectory = {}; packages.forEach( (pkg) => (packageByDirectory[pkg.directory] = pkg.packageJson), ); return packageByDirectory; } private identifyInstallTargets( packages: PackageByDirectory, ): InstallTarget[] { const installTargets = Object.keys(this.sourcesByTarget).map((target) => ({ directory: target, packageJson: packages[target], sources: this.sourcesByTarget[target].map((source) => ({ directory: source, packageJson: packages[source], })), })); this.emit('install_targets_identified', installTargets); return installTargets; } private async packAll(): Promise { const allSources = Array.from( new Set( Object.keys(this.sourcesByTarget).flatMap( (target) => this.sourcesByTarget[target], ), ), ); this.emit('packing_start', allSources); await Promise.all(allSources.map((source) => this.packOne(source))); this.emit('packing_end'); } private async packOne(directory: string): Promise { await utils.exec('npm', ['pack', directory], { cwd: this.uniqueDir, maxBuffer: TEN_MEGA_BYTE, }); this.emit('packed', directory); } private removeTmpDirectory(): Promise { return fs.rm(this.uniqueDir, { recursive: true, force: true }); } } function resolvePackFile(dir: string, pkg: PackageJson) { // Don't forget about scoped packages const scopeIndex = pkg.name.indexOf('@'); const slashIndex = pkg.name.indexOf('/'); if (scopeIndex === 0 && slashIndex > 0) { // @s/b -> s-b-x.x.x.tgz return path.resolve( dir, `${pkg.name.substr(1, slashIndex - 1)}-${pkg.name.substr( slashIndex + 1, )}-${pkg.version}.tgz`, ); } else { // b -> b-x.x.x.tgz return path.resolve(dir, `${pkg.name}-${pkg.version}.tgz`); } } export function resolve(packagesByTarget: ListByPackage): ListByPackage { const resolvedPackages: ListByPackage = {}; Object.keys(packagesByTarget).forEach((localTarget) => { resolvedPackages[path.resolve(localTarget)] = Array.from( new Set(packagesByTarget[localTarget].map((pkg) => path.resolve(pkg))), ); }); return resolvedPackages; }