UNPKG

npmc

Version:

a package manager for JavaScript

388 lines (363 loc) 12.8 kB
'use strict' const BB = require('bluebird') const buildLogicalTree = require('npm-logical-tree') const detectIndent = require('detect-indent') const detectNewline = require('detect-newline') const fs = require('graceful-fs') const getPrefix = require('find-npm-prefix') const lockVerify = require('lock-verify') const mkdirp = BB.promisify(require('mkdirp')) const npa = require('npm-package-arg') const pacote = require('pacote') const path = require('path') const rimraf = BB.promisify(require('rimraf')) const ssri = require('ssri') const zlib = require('zlib') const readdirAsync = BB.promisify(fs.readdir) const readFileAsync = BB.promisify(fs.readFile) const statAsync = BB.promisify(fs.stat) const writeFileAsync = BB.promisify(fs.writeFile) class MyPrecious { constructor (opts) { this.opts = opts this.config = opts.config // Stats this.startTime = Date.now() this.runTime = 0 this.timings = {} this.pkgCount = 0 this.removed = 0 // Misc this.log = this.opts.log || require('./lib/silentlog.js') this.pkg = null this.tree = null this.archives = new Set() } timedStage (name) { const start = Date.now() return BB.resolve(this[name].apply(this, [].slice.call(arguments, 1))) .tap(() => { this.timings[name] = Date.now() - start this.log.info(name, `Done in ${this.timings[name] / 1000}s`) }) } run () { return this.archive() } archive () { return this.timedStage('prepare') .then(() => this.timedStage('findExisting')) .then(() => this.timedStage('saveTarballs', this.tree)) .then(() => this.timedStage('updateLockfile', this.tree)) .then(() => this.timedStage('cleanupArchives')) .then(() => this.timedStage('teardown')) .then(() => { this.runTime = Date.now() - this.startTime }) .catch(err => { this.timedStage('teardown'); throw err }) .then(() => this) } unarchive () { return this.timedStage('prepare') .then(() => this.timedStage('restoreDependencies', this.tree)) .then(() => this.timedStage('updateLockfile', this.tree)) .then(() => this.timedStage('removeTarballs')) .then(() => this.timedStage('removeModules')) .then(() => { this.runTime = Date.now() - this.startTime this.log.info( 'run-time', `total run time: ${this.runTime / 1000}s` ) }) .catch(err => { this.timedStage('teardown'); throw err }) .then(() => this) } prepare () { this.log.info('prepare', 'initializing installer') return ( this.config.get('prefix') && this.config.get('global') ? BB.resolve(this.config.get('prefix')) // There's some Special™ logic around the `--prefix` config when it // comes from a config file or env vs when it comes from the CLI : process.argv.some(arg => arg.match(/^\s*--prefix\s*/i)) ? this.config.get('prefix') : getPrefix(process.cwd()) ) .then(prefix => { this.prefix = prefix this.archiveDir = path.join(prefix, 'archived-packages') this.log.verbose('prepare', 'package prefix: ' + prefix) return BB.join( readJson(prefix, 'package.json'), readJson(prefix, 'package-lock.json', true), readJson(prefix, 'npm-shrinkwrap.json', true), (pkg, lock, shrink) => { pkg._shrinkwrap = shrink || lock this.pkg = pkg this.lockName = shrink ? 'npm-shrinkwrap.json' : 'package-lock.json' } ) }) .then(() => statAsync( path.join(this.prefix, 'node_modules') ).catch(err => { if (err.code !== 'ENOENT') { throw err } })) .then(stat => this.checkLock()) .then(() => { this.tree = buildLogicalTree(this.pkg, this.pkg._shrinkwrap) this.log.silly('tree', this.tree) }) } teardown () { return BB.resolve() } checkLock () { this.log.info('checkLock', 'verifying package-lock data') const pkg = this.pkg const prefix = this.prefix if (!pkg._shrinkwrap || !pkg._shrinkwrap.lockfileVersion) { return BB.reject( new Error(`we can only install packages with an existing package-lock.json or npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or later to generate it, then try again.`) ) } return lockVerify(prefix).then(result => { if (result.status) { result.warnings.forEach(w => this.log.warn('lockfile', w)) } else { throw new Error( 'we can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.\n\n' + result.warnings.map(w => 'Warning: ' + w).join('\n') + '\n' + result.errors.join('\n') + '\n' ) } }) } findExisting () { this.log.info('findExisting', 'checking for existing archived packages') return readdirAsync(this.archiveDir) .catch(err => { if (err.code === 'ENOENT') { return [] } throw err }) .then(existing => { return BB.all( existing.filter(f => f.match(/^@/)) .map(f => { return readdirAsync(path.join(this.archiveDir, f)) .then(subfiles => subfiles.map(subf => `${f}/${subf}`)) }) ) .then(scoped => scoped.reduce((acc, scoped) => { return acc.concat(scoped) }, existing.filter(f => !f.match(/^@/)))) }) .then(allExisting => { this.existingArchives = new Set(allExisting) }) } archiveTarball (spec, dep) { const pkgPath = this.getTarballPath(dep) this.log.silly('archiveTarball', `${spec} -> ${pkgPath}`) const relpath = path.relative(this.archiveDir, pkgPath) const alreadyExists = this.existingArchives.has( path.relative(this.archiveDir, pkgPath) ) const algorithms = dep.integrity && Object.keys(ssri.parse(dep.integrity)) this.archives.add(relpath) return mkdirp(path.dirname(pkgPath)) .then(() => { if (alreadyExists) { this.log.silly('archiveTarball', `archive for ${spec} already exists`) return ssri.fromStream(fs.createReadStream(pkgPath), {algorithms}) } return new BB((resolve, reject) => { const tardata = pacote.tarball.stream(spec, this.config.toPacote({ log: this.log, resolved: dep.resolved && !dep.resolved.startsWith('file:') && dep.resolved, integrity: dep.integrity })) const gunzip = zlib.createGunzip() const sriStream = ssri.integrityStream({algorithms}) const out = fs.createWriteStream(pkgPath) let integrity sriStream.on('integrity', i => { integrity = i }) tardata.on('error', reject) gunzip.on('error', reject) sriStream.on('error', reject) out.on('error', reject) out.on('close', () => resolve(integrity)) tardata .pipe(gunzip) .pipe(sriStream) .pipe(out) }) .tap(() => { this.pkgCount++ }) }) .then(tarIntegrity => { const resolvedPath = path.relative(this.prefix, pkgPath) .replace(/\\/g, '/') let integrity if (!dep.integrity) { integrity = tarIntegrity.toString() } else if (dep.integrity.indexOf(tarIntegrity.toString()) !== -1) { // TODO - this is a stopgap until `ssri#concat` (or a new // `ssri#merge`) become availble. integrity = dep.integrity } else { // concat needs to be in this order 'cause apparently it's what npm // expects to do. integrity = tarIntegrity.concat(dep.integrity).toString() } return { resolved: `file:${resolvedPath}`, integrity } }) } restoreDependencies (tree) { this.log.info('restoreDependencies', 'removing archive details from dependencies locked') return tree.forEachAsync((dep, next) => { if (dep.isRoot || dep.bundled) { return next() } const spec = npa.resolve(dep.name, dep.version, this.prefix) if (spec.type === 'directory' || spec.type === 'git') { dep.resolved = null dep.integrity = null return next() } return pacote.manifest(spec, this.config.toPacote({ log: this.log, integrity: ( spec.type === 'remote' || spec.registry || spec.type === 'local' ) ? dep.integrity : null })) .then(mani => { dep.resolved = mani._resolved || null dep.integrity = mani._integrity || null }) .then(next) }) } getTarballPath (dep) { let suffix const spec = npa.resolve(dep.name, dep.version, this.prefix) if (spec.registry) { suffix = dep.version } else if (spec.type === 'remote') { suffix = 'remote' } else if (spec.type === 'file') { suffix = 'file' } else if (spec.hosted) { suffix = `${spec.hosted.type}-${spec.hosted.user}-${spec.hosted.project}-${spec.gitCommittish}` } else if (spec.type === 'git') { suffix = `git-${spec.gitCommittish}` } else if (spec.type === 'directory') { suffix = 'directory' } if (dep.integrity && ( spec.registry || spec.type === 'file' || spec.type === 'remote' )) { const split = dep.integrity.split(/\s+/) const shortHash = ssri.parse(split[split.length - 1], {single: true}) .hexDigest() .substr(0, 9) suffix += `-${shortHash}` } const filename = `${dep.name}-${suffix}.tar` return path.join(this.archiveDir, filename) } saveTarballs (tree) { this.log.info('saveTarballs', 'archiving packages to', this.archiveDir) return tree.forEachAsync((dep, next) => { if (!this.checkDepEnv(dep)) { return } const spec = npa.resolve(dep.name, dep.version, this.prefix) if (dep.isRoot || spec.type === 'directory' || dep.bundled) { return next() } else { return this.archiveTarball(spec, dep) .then(updated => Object.assign(dep, updated)) .then(() => next()) } }, {concurrency: 50, Promise: BB}) } removeTarballs () { this.log.info('removeTarballs', 'removing tarball archive') return rimraf(this.archiveDir) } removeModules () { this.log.info('removeModules', 'removing archive-installed node_modules/') return rimraf(path.join(this.prefix, 'node_modules')) } checkDepEnv (dep) { const includeDev = ( // Covers --dev and --development (from npm config itself) this.config.get('dev') || ( !/^prod(uction)?$/.test(this.config.get('only')) && !this.config.get('production') ) || /^dev(elopment)?$/.test(this.config.get('only')) || /^dev(elopment)?$/.test(this.config.get('also')) ) const includeProd = !/^dev(elopment)?$/.test(this.config.get('only')) return (dep.dev && includeDev) || (!dep.dev && includeProd) } updateLockfile (tree) { this.log.info('updateLockfile', 'updating details in lockfile') tree.forEach((dep, next) => { if (dep.isRoot) { return next() } const physDep = dep.address.split(':').reduce((obj, name, i) => { return obj.dependencies[name] }, this.pkg._shrinkwrap) if (dep.resolved) { physDep.resolved = dep.resolved } else { delete physDep.resolved } if (dep.integrity) { physDep.integrity = dep.integrity } else { delete physDep.integrity } next() }) const lockPath = path.join(this.prefix, this.lockName) return readFileAsync(lockPath, 'utf8') .then(file => { const indent = detectIndent(file).indent || 2 const ending = detectNewline.graceful(file) return writeFileAsync( lockPath, JSON.stringify(this.pkg._shrinkwrap, null, indent) .replace(/\n/g, ending) ) }) } cleanupArchives () { const removeMe = [] for (let f of this.existingArchives.values()) { if (!this.archives.has(f)) { removeMe.push(f) } } if (removeMe.length) { this.log.info('cleanupArchives', 'removing', removeMe.length, 'dangling archives') this.removed = removeMe.length } return BB.map(removeMe, f => rimraf(path.join(this.archiveDir, f))) } } module.exports = MyPrecious module.exports.PreciousConfig = require('./lib/config/npm-config.js').PreciousConfig function stripBOM (str) { return str.replace(/^\uFEFF/, '') } module.exports._readJson = readJson function readJson (jsonPath, name, ignoreMissing) { return readFileAsync(path.join(jsonPath, name), 'utf8') .then(str => JSON.parse(stripBOM(str))) .catch({code: 'ENOENT'}, err => { if (!ignoreMissing) { throw err } }) }