diff --git a/lib/pack.js b/lib/pack.js index 8189cb6cedc0b..510c4781b1edd 100644 --- a/lib/pack.js +++ b/lib/pack.js @@ -1,363 +1,58 @@ 'use strict' -// npm pack -// Packs the specified package into a .tgz file, which can then -// be installed. - -// Set this early to avoid issues with circular dependencies. -module.exports = pack - -const BB = require('bluebird') - -const byteSize = require('byte-size') -const cacache = require('cacache') -const columnify = require('columnify') -const cp = require('child_process') -const deprCheck = require('./utils/depr-check') -const fpm = require('./fetch-package-metadata') -const fs = require('graceful-fs') -const install = require('./install') -const lifecycle = BB.promisify(require('./utils/lifecycle')) +const util = require('util') const log = require('npmlog') -const move = require('move-concurrently') -const npm = require('./npm') -const npmConfig = require('./config/figgy-config.js') -const output = require('./utils/output') const pacote = require('pacote') -const path = require('path') -const PassThrough = require('stream').PassThrough -const pathIsInside = require('path-is-inside') -const pipe = BB.promisify(require('mississippi').pipe) -const prepublishWarning = require('./utils/warn-deprecated')('prepublish-on-install') -const pinflight = require('promise-inflight') -const readJson = BB.promisify(require('read-package-json')) -const tar = require('tar') -const packlist = require('npm-packlist') -const ssri = require('ssri') +const libpack = require('libnpmpack') +const npa = require('npm-package-arg') -pack.usage = 'npm pack [[<@scope>/]...] [--dry-run]' +const npm = require('./npm.js') +const output = require('./utils/output.js') +const { getContents, logTar } = require('./utils/tar.js') -// if it can be installed, it can be packed. -pack.completion = install.completion +const writeFile = util.promisify(require('fs').writeFile) -function pack (args, silent, cb) { - const cwd = process.cwd() +cmd.usage = 'npm pack [[<@scope>/]...] [--dry-run]' + +module.exports = cmd +function cmd(args, silent, cb) { if (typeof cb !== 'function') { cb = silent silent = false } - - if (args.length === 0) args = ['.'] - - BB.all( - args.map((arg) => pack_(arg, cwd)) - ).then((tarballs) => { - if (!silent && npm.config.get('json')) { - output(JSON.stringify(tarballs, null, 2)) - } else if (!silent) { - tarballs.forEach(logContents) - output(tarballs.map((f) => path.relative(cwd, f.filename)).join('\n')) - } - return tarballs - }).nodeify(cb) -} - -function pack_ (pkg, dir) { - return BB.fromNode((cb) => fpm(pkg, dir, cb)).then((mani) => { - let name = mani.name[0] === '@' - // scoped packages get special treatment - ? mani.name.substr(1).replace(/\//g, '-') - : mani.name - const target = `${name}-${mani.version}.tgz` - return pinflight(target, () => { - const dryRun = npm.config.get('dry-run') - if (mani._requested.type === 'directory') { - return prepareDirectory(mani._resolved) - .then(() => { - return packDirectory(mani, mani._resolved, target, target, true, dryRun) - }) - } else if (dryRun) { - log.verbose('pack', '--dry-run mode enabled. Skipping write.') - return cacache.tmp.withTmp(npm.tmp, {tmpPrefix: 'packing'}, (tmp) => { - const tmpTarget = path.join(tmp, path.basename(target)) - return packFromPackage(pkg, tmpTarget, target) - }) - } else { - return packFromPackage(pkg, target, target) - } - }) - }) -} - -function packFromPackage (arg, target, filename) { - const opts = npmConfig() - return pacote.tarball.toFile(arg, target, opts) - .then(() => cacache.tmp.withTmp(npm.tmp, {tmpPrefix: 'unpacking'}, (tmp) => { - const tmpTarget = path.join(tmp, filename) - return pacote.extract(arg, tmpTarget, opts) - .then(() => readJson(path.join(tmpTarget, 'package.json'))) - })) - .then((pkg) => getContents(pkg, target, filename)) + pack(args, silent) + .then(() => cb()) + .catch(cb) } -module.exports.prepareDirectory = prepareDirectory -function prepareDirectory (dir) { - return readJson(path.join(dir, 'package.json')).then((pkg) => { - if (!pkg.name) { - throw new Error('package.json requires a "name" field') - } - if (!pkg.version) { - throw new Error('package.json requires a valid "version" field') - } - if (!pathIsInside(dir, npm.tmp)) { - if (pkg.scripts && pkg.scripts.prepublish) { - prepublishWarning([ - 'As of npm@5, `prepublish` scripts are deprecated.', - 'Use `prepare` for build steps and `prepublishOnly` for upload-only.', - 'See the deprecation note in `npm help scripts` for more information.' - ]) - } - if (npm.config.get('ignore-prepublish')) { - return lifecycle(pkg, 'prepare', dir).then(() => pkg) - } else { - return lifecycle(pkg, 'prepublish', dir).then(() => { - return lifecycle(pkg, 'prepare', dir) - }).then(() => pkg) - } - } - return pkg - }) -} - -module.exports.packDirectory = packDirectory -function packDirectory (mani, dir, target, filename, logIt, dryRun) { - deprCheck(mani) - return readJson(path.join(dir, 'package.json')).then((pkg) => { - return lifecycle(pkg, 'prepack', dir) - }).then(() => { - return readJson(path.join(dir, 'package.json')) - }).then((pkg) => { - return cacache.tmp.withTmp(npm.tmp, {tmpPrefix: 'packing'}, (tmp) => { - const tmpTarget = path.join(tmp, path.basename(target)) - - const tarOpt = { - file: tmpTarget, - cwd: dir, - prefix: 'package/', - portable: true, - // Provide a specific date in the 1980s for the benefit of zip, - // which is confounded by files dated at the Unix epoch 0. - mtime: new Date('1985-10-26T08:15:00.000Z'), - gzip: true - } +async function pack (args, silent) { + if (args.length === 0) args = ['.'] - return BB.resolve(packlist({ path: dir })) - // NOTE: node-tar does some Magic Stuff depending on prefixes for files - // specifically with @ signs, so we just neutralize that one - // and any such future "features" by prepending `./` - .then((files) => tar.create(tarOpt, files.map((f) => `./${f}`))) - .then(() => getContents(pkg, tmpTarget, filename, logIt)) - // thread the content info through - .tap(() => { - if (dryRun) { - log.verbose('pack', '--dry-run mode enabled. Skipping write.') - } else { - return move(tmpTarget, target, {Promise: BB, fs}) - } - }) - .tap(() => lifecycle(pkg, 'postpack', dir)) + const opts = { ...npm.flatOptions } + const { unicode, json } = opts + const tarballs = await Promise.all(args.map((arg) => pack_(arg, opts))) + if (!silent && json) { + output(JSON.stringify(tarballs, null, 2)) + } else if (!silent) { + tarballs.forEach((tar) => { + logTar(tar, { log, unicode }) }) - }) -} - -module.exports.logContents = logContents -function logContents (tarball) { - log.notice('') - log.notice('', `${npm.config.get('unicode') ? '📦 ' : 'package:'} ${tarball.name}@${tarball.version}`) - log.notice('=== Tarball Contents ===') - if (tarball.files.length) { - log.notice('', columnify(tarball.files.map((f) => { - const bytes = byteSize(f.size) - return {path: f.path, size: `${bytes.value}${bytes.unit}`} - }), { - include: ['size', 'path'], - showHeaders: false - })) } - if (tarball.bundled.length) { - log.notice('=== Bundled Dependencies ===') - tarball.bundled.forEach((name) => log.notice('', name)) - } - log.notice('=== Tarball Details ===') - log.notice('', columnify([ - {name: 'name:', value: tarball.name}, - {name: 'version:', value: tarball.version}, - tarball.filename && {name: 'filename:', value: tarball.filename}, - {name: 'package size:', value: byteSize(tarball.size)}, - {name: 'unpacked size:', value: byteSize(tarball.unpackedSize)}, - {name: 'shasum:', value: tarball.shasum}, - { - name: 'integrity:', - value: tarball.integrity.toString().substr(0, 20) + '[...]' + tarball.integrity.toString().substr(80)}, - tarball.bundled.length && {name: 'bundled deps:', value: tarball.bundled.length}, - tarball.bundled.length && {name: 'bundled files:', value: tarball.entryCount - tarball.files.length}, - tarball.bundled.length && {name: 'own files:', value: tarball.files.length}, - {name: 'total files:', value: tarball.entryCount} - ].filter((x) => x), { - include: ['name', 'value'], - showHeaders: false - })) - log.notice('', '') -} -module.exports.getContents = getContents -function getContents (pkg, target, filename, silent) { - const bundledWanted = new Set( - pkg.bundleDependencies || - pkg.bundledDependencies || - [] - ) - const files = [] - const bundled = new Set() - let totalEntries = 0 - let totalEntrySize = 0 - return tar.t({ - file: target, - onentry (entry) { - totalEntries++ - totalEntrySize += entry.size - const p = entry.path - if (p.startsWith('package/node_modules/')) { - const name = p.match(/^package\/node_modules\/((?:@[^/]+\/)?[^/]+)/)[1] - if (bundledWanted.has(name)) { - bundled.add(name) - } - } else { - files.push({ - path: entry.path.replace(/^package\//, ''), - size: entry.size, - mode: entry.mode - }) - } - }, - strip: 1 - }) - .then(() => BB.all([ - BB.fromNode((cb) => fs.stat(target, cb)), - ssri.fromStream(fs.createReadStream(target), { - algorithms: ['sha1', 'sha512'] - }) - ])) - .then(([stat, integrity]) => { - const shasum = integrity['sha1'][0].hexDigest() - return { - id: pkg._id, - name: pkg.name, - version: pkg.version, - from: pkg._from, - size: stat.size, - unpackedSize: totalEntrySize, - shasum, - integrity: ssri.parse(integrity['sha512'][0]), - filename, - files, - entryCount: totalEntries, - bundled: Array.from(bundled) - } - }) + return tarballs } -const PASSTHROUGH_OPTS = [ - 'always-auth', - 'auth-type', - 'ca', - 'cafile', - 'cert', - 'git', - 'local-address', - 'maxsockets', - 'offline', - 'prefer-offline', - 'prefer-online', - 'proxy', - 'https-proxy', - 'registry', - 'send-metrics', - 'sso-poll-frequency', - 'sso-type', - 'strict-ssl' -] +async function pack_ (arg, opts) { + const spec = npa(arg) + const manifest = await pacote.manifest(spec) + const filename = `${manifest.name}-${manifest.version}.tgz` + const tarballData = await libpack(arg) + const pkgContents = await getContents(manifest, tarballData) + const { dryRun } = opts -module.exports.packGitDep = packGitDep -function packGitDep (manifest, dir) { - const stream = new PassThrough() - readJson(path.join(dir, 'package.json')).then((pkg) => { - if (pkg.scripts && pkg.scripts.prepare) { - log.verbose('prepareGitDep', `${manifest._spec}: installing devDeps and running prepare script.`) - const cliArgs = PASSTHROUGH_OPTS.reduce((acc, opt) => { - if (npm.config.get(opt, 'cli') != null) { - acc.push(`--${opt}=${npm.config.get(opt)}`) - } - return acc - }, []) - const child = cp.spawn(process.env.NODE || process.execPath, [ - require.resolve('../bin/npm-cli.js'), - 'install', - '--dev', - '--prod', - '--ignore-prepublish', - '--no-progress', - '--no-save' - ].concat(cliArgs), { - cwd: dir, - env: process.env - }) - let errData = [] - let errDataLen = 0 - let outData = [] - let outDataLen = 0 - child.stdout.on('data', (data) => { - outData.push(data) - outDataLen += data.length - log.gauge.pulse('preparing git package') - }) - child.stderr.on('data', (data) => { - errData.push(data) - errDataLen += data.length - log.gauge.pulse('preparing git package') - }) - return BB.fromNode((cb) => { - child.on('error', cb) - child.on('exit', (code, signal) => { - if (code > 0) { - const err = new Error(`${signal}: npm exited with code ${code} while attempting to build ${manifest._requested}. Clone the repository manually and run 'npm install' in it for more information.`) - err.code = code - err.signal = signal - cb(err) - } else { - cb() - } - }) - }).then(() => { - if (outDataLen > 0) log.silly('prepareGitDep', '1>', Buffer.concat(outData, outDataLen).toString()) - if (errDataLen > 0) log.silly('prepareGitDep', '2>', Buffer.concat(errData, errDataLen).toString()) - }, (err) => { - if (outDataLen > 0) log.error('prepareGitDep', '1>', Buffer.concat(outData, outDataLen).toString()) - if (errDataLen > 0) log.error('prepareGitDep', '2>', Buffer.concat(errData, errDataLen).toString()) - throw err - }) - } - }).then(() => { - return readJson(path.join(dir, 'package.json')) - }).then((pkg) => { - return cacache.tmp.withTmp(npm.tmp, { - tmpPrefix: 'pacote-packing' - }, (tmp) => { - const tmpTar = path.join(tmp, 'package.tgz') - return packDirectory(manifest, dir, tmpTar).then(() => { - return pipe(fs.createReadStream(tmpTar), stream) - }) - }) - }).catch((err) => stream.emit('error', err)) - return stream + if (!dryRun) { + await writeFile(filename, tarballData) + } + + return pkgContents } diff --git a/lib/publish.js b/lib/publish.js index 0d3aef678e8b7..4c496a3e49025 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -1,167 +1,94 @@ 'use strict' -const BB = require('bluebird') - -const cacache = require('cacache') -const figgyPudding = require('figgy-pudding') -const libpub = require('libnpmpublish').publish -const libunpub = require('libnpmpublish').unpublish -const lifecycle = BB.promisify(require('./utils/lifecycle.js')) +const util = require('util') const log = require('npmlog') -const npa = require('npm-package-arg') -const npmConfig = require('./config/figgy-config.js') +const semver = require('semver') +const pack = require('libnpmpack') +const libpub = require('libnpmpublish').publish +const runScript = require('@npmcli/run-script') + +const npm = require('./npm.js') const output = require('./utils/output.js') const otplease = require('./utils/otplease.js') -const pack = require('./pack') -const { tarball, extract } = require('pacote') -const path = require('path') -const readFileAsync = BB.promisify(require('graceful-fs').readFile) -const readJson = BB.promisify(require('read-package-json')) -const semver = require('semver') -const statAsync = BB.promisify(require('graceful-fs').stat) +const { getContents, logTar } = require('./utils/tar.js') + +const readJson = util.promisify(require('read-package-json')) -publish.usage = 'npm publish [|] [--tag ] [--access ] [--dry-run]' + +cmd.usage = 'npm publish [] [--tag ] [--access ] [--dry-run]' + "\n\nPublishes '.' if no argument supplied" + '\n\nSets tag `latest` if no --tag specified' -publish.completion = function (opts, cb) { - // publish can complete to a folder with a package.json - // or a tarball, or a tarball url. - // for now, not yet implemented. - return cb() +module.exports = cmd +function cmd(args, cb) { + publish(args, cb) + .then(() => cb()) + .catch(cb) } -const PublishConfig = figgyPudding({ - dryRun: 'dry-run', - 'dry-run': { default: false }, - force: { default: false }, - json: { default: false }, - Promise: { default: () => Promise }, - tag: { default: 'latest' }, - tmp: {} -}) - -module.exports = publish -function publish (args, isRetry, cb) { - if (typeof cb !== 'function') { - cb = isRetry - isRetry = false - } +async function publish (args, cb) { if (args.length === 0) args = ['.'] if (args.length !== 1) return cb(publish.usage) - log.verbose('publish', args) + log.verbose('publish', args) - const opts = PublishConfig(npmConfig()) - const t = opts.tag.trim() - if (semver.validRange(t)) { + const opts = { ...npm.flatOptions } + const { json, defaultTag } = opts + if (semver.validRange(defaultTag)) { return cb(new Error('Tag name must not be a valid SemVer range: ' + t)) } - return publish_(args[0], opts) - .then((tarball) => { - const silent = log.level === 'silent' - if (!silent && opts.json) { - output(JSON.stringify(tarball, null, 2)) - } else if (!silent) { - output(`+ ${tarball.id}`) - } - }) - .nodeify(cb) + const tarball = await publish_(args[0], opts) + const silent = log.level === 'silent' + if (!silent && json) { + output(JSON.stringify(tarball, null, 2)) + } else if (!silent) { + output(`+ ${tarball.id}`) + } + + return tarball } -function publish_ (arg, opts) { - return statAsync(arg).then((stat) => { - if (stat.isDirectory()) { - return stat - } else { - const err = new Error('not a directory') - err.code = 'ENOTDIR' - throw err - } - }).then(() => { - return publishFromDirectory(arg, opts) - }, (err) => { - if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') { - throw err - } else { - return publishFromPackage(arg, opts) - } +async function publish_ (arg, opts) { + const { unicode, dryRun, json } = opts + let manifest = await readJson(`${arg}/package.json`) + + // prepublishOnly + await runScript({ + event: 'prepublishOnly', + path: arg, + stdio: 'inherit', + pkg: manifest }) -} + + const tarballData = await pack(arg) + const pkgContents = await getContents(manifest, tarballData) -function publishFromDirectory (arg, opts) { - // All this readJson is because any of the given scripts might modify the - // package.json in question, so we need to refresh after every step. - let contents - return pack.prepareDirectory(arg).then(() => { - return readJson(path.join(arg, 'package.json')) - }).then((pkg) => { - return lifecycle(pkg, 'prepublishOnly', arg) - }).then(() => { - return readJson(path.join(arg, 'package.json')) - }).then((pkg) => { - return cacache.tmp.withTmp(opts.tmp, {tmpPrefix: 'fromDir'}, (tmpDir) => { - const target = path.join(tmpDir, 'package.tgz') - return pack.packDirectory(pkg, arg, target, null, true) - .tap((c) => { contents = c }) - .then((c) => !opts.json && pack.logContents(c)) - .then(() => upload(pkg, false, target, opts)) - }) - }).then(() => { - return readJson(path.join(arg, 'package.json')) - }).tap((pkg) => { - return lifecycle(pkg, 'publish', arg) - }).tap((pkg) => { - return lifecycle(pkg, 'postpublish', arg) + if (!json) { + logTar(pkgContents, { log, unicode }) + } + + if (!dryRun) { + // The purpose of re-reading the manifest is in case it changed, + // so that we send the latest and greatest thing to the registry + manifest = await readJson(`${arg}/package.json`) + await otplease(opts, opts => libpub(arg, manifest, opts)) + } + + // publish + await runScript({ + event: 'publish', + path: arg, + stdio: 'inherit', + pkg: manifest }) - .then(() => contents) -} -function publishFromPackage (arg, opts) { - return cacache.tmp.withTmp(opts.tmp, {tmpPrefix: 'fromPackage'}, tmp => { - const extracted = path.join(tmp, 'package') - const target = path.join(tmp, 'package.json') - return tarball.toFile(arg, target, opts) - .then(() => extract(arg, extracted, opts)) - .then(() => readJson(path.join(extracted, 'package.json'))) - .then((pkg) => { - return BB.resolve(pack.getContents(pkg, target)) - .tap((c) => !opts.json && pack.logContents(c)) - .tap(() => upload(pkg, false, target, opts)) - }) + // postpublish + await runScript({ + event: 'postpublish', + path: arg, + stdio: 'inherit', + pkg: manifest }) -} -function upload (pkg, isRetry, cached, opts) { - if (!opts.dryRun) { - return readFileAsync(cached).then(tarball => { - return otplease(opts, opts => { - return libpub(pkg, tarball, opts) - }).catch(err => { - if ( - err.code === 'EPUBLISHCONFLICT' && - opts.force && - !isRetry - ) { - log.warn('publish', 'Forced publish over ' + pkg._id) - return otplease(opts, opts => libunpub( - npa.resolve(pkg.name, pkg.version), opts - )).finally(() => { - // ignore errors. Use the force. Reach out with your feelings. - return otplease(opts, opts => { - return upload(pkg, true, tarball, opts) - }).catch(() => { - // but if it fails again, then report the first error. - throw err - }) - }) - } else { - throw err - } - }) - }) - } else { - return opts.Promise.resolve(true) - } + return pkgContents } diff --git a/lib/utils/tar.js b/lib/utils/tar.js new file mode 100644 index 0000000000000..c1cd7e71411e6 --- /dev/null +++ b/lib/utils/tar.js @@ -0,0 +1,112 @@ +'use strict' + +const tar = require('tar') +const ssri = require('ssri') +const byteSize = require('byte-size') +const columnify = require('columnify') + +module.exports = { logTar, getContents } + +function logTar (tarball, opts = {}) { + const { unicode, log } = opts + log.notice('') + log.notice('', `${unicode ? '📦 ' : 'package:'} ${tarball.name}@${tarball.version}`) + log.notice('=== Tarball Contents ===') + if (tarball.files.length) { + log.notice('', columnify(tarball.files.map((f) => { + const bytes = byteSize(f.size) + return { path: f.path, size: `${bytes.value}${bytes.unit}` } + }), { + include: ['size', 'path'], + showHeaders: false + })) + } + if (tarball.bundled.length) { + log.notice('=== Bundled Dependencies ===') + tarball.bundled.forEach((name) => log.notice('', name)) + } + log.notice('=== Tarball Details ===') + log.notice('', columnify([ + { name: 'name:', value: tarball.name }, + { name: 'version:', value: tarball.version }, + tarball.filename && { name: 'filename:', value: tarball.filename }, + { name: 'package size:', value: byteSize(tarball.size) }, + { name: 'unpacked size:', value: byteSize(tarball.unpackedSize) }, + { name: 'shasum:', value: tarball.shasum }, + { + name: 'integrity:', + value: tarball.integrity.toString().substr(0, 20) + '[...]' + tarball.integrity.toString().substr(80) + }, + tarball.bundled.length && { name: 'bundled deps:', value: tarball.bundled.length }, + tarball.bundled.length && { name: 'bundled files:', value: tarball.entryCount - tarball.files.length }, + tarball.bundled.length && { name: 'own files:', value: tarball.files.length }, + { name: 'total files:', value: tarball.entryCount } + ].filter((x) => x), { + include: ['name', 'value'], + showHeaders: false + })) + log.notice('', '') +} + +async function getContents (manifest, tarball) { + const files = [] + const bundled = new Set() + let totalEntries = 0 + let totalEntrySize = 0 + + // reads contents of tarball + const stream = tar.t({ + onentry (entry) { + totalEntries++ + totalEntrySize += entry.size + const p = entry.path + if (p.startsWith('package/node_modules/')) { + const name = p.match(/^package\/node_modules\/((?:@[^/]+\/)?[^/]+)/)[1] + bundled.add(name) + } + files.push({ + path: entry.path.replace(/^package\//, ''), + size: entry.size, + mode: entry.mode + }) + } + }) + stream.end(tarball) + + const integrity = await ssri.fromData(tarball, { + algorithms: ['sha1', 'sha512'] + }) + + const comparator = (a, b) => { + return a.path.localeCompare(b.path, undefined, { + sensitivity: 'case', + numeric: true + }) + } + + const isUpper = (str) => { + const ch = str.charAt(0) + return ch >= 'A' && ch <= 'Z' + } + + const uppers = files.filter(file => isUpper(file.path)) + const others = files.filter(file => !isUpper(file.path)) + + uppers.sort(comparator) + others.sort(comparator) + + const shasum = integrity.sha1[0].hexDigest() + return { + id: manifest._id || `${manifest.name}@${manifest.version}`, + name: manifest.name, + version: manifest.version, + size: tarball.length, + unpackedSize: totalEntrySize, + shasum, + integrity: ssri.parse(integrity.sha512[0]), + filename: `${manifest.name}-${manifest.version}.tgz`, + files: uppers.concat(others), + entryCount: totalEntries, + bundled: Array.from(bundled) + } +}