diff options
author | Nathan Fritz <fritzy@github.com> | 2021-12-16 21:01:56 +0300 |
---|---|---|
committer | Nathan Fritz <fritzy@github.com> | 2021-12-16 21:05:19 +0300 |
commit | d7265045730555c03b3142c004c7438e9577028c (patch) | |
tree | 035d81b3124bdaa09c21854934bf2b2b50e52e44 /workspaces/libnpmpublish | |
parent | d8aac8448e983692cacb427e03f4688cd1b62e30 (diff) |
Bring in all libnpm modules + arborist as workspaces (#4166)
Added libnpm workspaces and arborist
Diffstat (limited to 'workspaces/libnpmpublish')
-rw-r--r-- | workspaces/libnpmpublish/.eslintrc.js | 14 | ||||
-rw-r--r-- | workspaces/libnpmpublish/.gitignore | 23 | ||||
-rw-r--r-- | workspaces/libnpmpublish/.npmrc | 3 | ||||
-rw-r--r-- | workspaces/libnpmpublish/CHANGELOG.md | 91 | ||||
-rw-r--r-- | workspaces/libnpmpublish/LICENSE | 13 | ||||
-rw-r--r-- | workspaces/libnpmpublish/README.md | 105 | ||||
-rw-r--r-- | workspaces/libnpmpublish/SECURITY.md | 3 | ||||
-rw-r--r-- | workspaces/libnpmpublish/lib/index.js | 4 | ||||
-rw-r--r-- | workspaces/libnpmpublish/lib/publish.js | 182 | ||||
-rw-r--r-- | workspaces/libnpmpublish/lib/unpublish.js | 102 | ||||
-rw-r--r-- | workspaces/libnpmpublish/package.json | 56 | ||||
-rw-r--r-- | workspaces/libnpmpublish/test/fixtures/tnock.js | 12 | ||||
-rw-r--r-- | workspaces/libnpmpublish/test/index.js | 6 | ||||
-rw-r--r-- | workspaces/libnpmpublish/test/publish.js | 574 | ||||
-rw-r--r-- | workspaces/libnpmpublish/test/unpublish.js | 242 |
15 files changed, 1430 insertions, 0 deletions
diff --git a/workspaces/libnpmpublish/.eslintrc.js b/workspaces/libnpmpublish/.eslintrc.js new file mode 100644 index 000000000..022767bc3 --- /dev/null +++ b/workspaces/libnpmpublish/.eslintrc.js @@ -0,0 +1,14 @@ +// This file is automatically added by @npmcli/template-oss. Do not edit. + +const { readdirSync: readdir } = require('fs') + +const localConfigs = readdir(__dirname) + .filter((file) => file.startsWith('.eslintrc.local.')) + .map((file) => `./${file}`) + +module.exports = { + extends: [ + '@npmcli', + ...localConfigs, + ], +} diff --git a/workspaces/libnpmpublish/.gitignore b/workspaces/libnpmpublish/.gitignore new file mode 100644 index 000000000..6ed44c72b --- /dev/null +++ b/workspaces/libnpmpublish/.gitignore @@ -0,0 +1,23 @@ +# This file is automatically added by @npmcli/template-oss. Do not edit. + +# ignore everything in the root +/* + +# keep these +!/.commitlintrc.js +!/.npmrc +!/.eslintrc* +!/.github +!**/.gitignore +!/package.json +!/docs +!/bin +!/lib +!/map.js +!/tap-snapshots +!/test +!/scripts +!/README* +!/LICENSE* +!/SECURITY* +!/CHANGELOG* diff --git a/workspaces/libnpmpublish/.npmrc b/workspaces/libnpmpublish/.npmrc new file mode 100644 index 000000000..878b7ddef --- /dev/null +++ b/workspaces/libnpmpublish/.npmrc @@ -0,0 +1,3 @@ +;This file is automatically added by @npmcli/template-oss. Do not edit. + +package-lock=false diff --git a/workspaces/libnpmpublish/CHANGELOG.md b/workspaces/libnpmpublish/CHANGELOG.md new file mode 100644 index 000000000..57d21f840 --- /dev/null +++ b/workspaces/libnpmpublish/CHANGELOG.md @@ -0,0 +1,91 @@ +# Change Log + +<a name="3.0.1"></a> +# [3.0.1](https://github.com/npm/libnpmpublish/compare/v3.0.0...v3.0.1) (2020-03-27) + +### Features + +* [`3e02307`](https://github.com/npm/libnpmpublish/commit/3e02307) chore: pack tarballs using libnpmpack ([@claudiahdz](https://github.com/claudiahdz)) + +<a name="3.0.0"></a> +# [3.0.0](https://github.com/npm/libnpmpublish/compare/v2.0.0...v3.0.0) (2020-03-09) + +### Breaking Changes + +* [`ecaeb0b`](https://github.com/npm/libnpmpublish/commit/ecaeb0b) feat: pack tarballs from source code using pacote v10 ([@claudiahdz](https://github.com/claudiahdz)) + +* [`f6bf2b8`](https://github.com/npm/libnpmpublish/commit/f6bf2b8) feat: unpublish code refactor ([@claudiahdz](https://github.com/claudiahdz)) + +### Miscellaneuous + +* [`5cea10f`](https://github.com/npm/libnpmpublish/commit/5cea10f) chore: basic project updates ([@claudiahdz](https://github.com/claudiahdz)) +* [`3010b93`](https://github.com/npm/libnpmpublish/commit/3010b93) chore: cleanup badges + contributing ([@ruyadorno](https://github.com/ruyadorno)) + +--- + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## [2.0.0](https://github.com/npm/libnpmpublish/compare/v1.1.3...v2.0.0) (2019-09-18) + + +### ⚠ BREAKING CHANGES + +* This drops support for Node.js version 6. + +### Bug Fixes + +* audit warnings, drop support for Node.js v6 ([d9a1fb6](https://github.com/npm/libnpmpublish/commit/d9a1fb6)) + +### [1.1.3](https://github.com/npm/libnpmpublish/compare/v1.1.2...v1.1.3) (2019-09-18) + +<a name="1.1.2"></a> +## [1.1.2](https://github.com/npm/libnpmpublish/compare/v1.1.1...v1.1.2) (2019-07-16) + + + +<a name="1.1.1"></a> +## [1.1.1](https://github.com/npm/libnpmpublish/compare/v1.1.0...v1.1.1) (2019-01-22) + + +### Bug Fixes + +* **auth:** send username in correct key ([#3](https://github.com/npm/libnpmpublish/issues/3)) ([38422d0](https://github.com/npm/libnpmpublish/commit/38422d0)) + + + +<a name="1.1.0"></a> +# [1.1.0](https://github.com/npm/libnpmpublish/compare/v1.0.1...v1.1.0) (2018-08-31) + + +### Features + +* **publish:** add support for publishConfig on manifests ([161723b](https://github.com/npm/libnpmpublish/commit/161723b)) + + + +<a name="1.0.1"></a> +## [1.0.1](https://github.com/npm/libnpmpublish/compare/v1.0.0...v1.0.1) (2018-08-31) + + +### Bug Fixes + +* **opts:** remove unused opts ([2837098](https://github.com/npm/libnpmpublish/commit/2837098)) + + + +<a name="1.0.0"></a> +# 1.0.0 (2018-08-31) + + +### Bug Fixes + +* **api:** use opts.algorithms, return true on success ([80fe34b](https://github.com/npm/libnpmpublish/commit/80fe34b)) +* **publish:** first test pass w/ bugfixes ([74135c9](https://github.com/npm/libnpmpublish/commit/74135c9)) +* **publish:** full coverage test and related fixes ([b5a3446](https://github.com/npm/libnpmpublish/commit/b5a3446)) + + +### Features + +* **docs:** add README with api docs ([553c13d](https://github.com/npm/libnpmpublish/commit/553c13d)) +* **publish:** add initial publish support. tests tbd ([5b3fe94](https://github.com/npm/libnpmpublish/commit/5b3fe94)) +* **unpublish:** add new api with unpublish support ([1c9d594](https://github.com/npm/libnpmpublish/commit/1c9d594)) diff --git a/workspaces/libnpmpublish/LICENSE b/workspaces/libnpmpublish/LICENSE new file mode 100644 index 000000000..209e4477f --- /dev/null +++ b/workspaces/libnpmpublish/LICENSE @@ -0,0 +1,13 @@ +Copyright npm, Inc + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/workspaces/libnpmpublish/README.md b/workspaces/libnpmpublish/README.md new file mode 100644 index 000000000..0da46e89d --- /dev/null +++ b/workspaces/libnpmpublish/README.md @@ -0,0 +1,105 @@ +# libnpmpublish + +[`libnpmpublish`](https://github.com/npm/libnpmpublish) is a Node.js +library for programmatically publishing and unpublishing npm packages. Give +it a manifest as an object and a tarball as a Buffer, and it'll put them on +the registry for you. + +## Table of Contents + +* [Example](#example) +* [Install](#install) +* [API](#api) + * [publish/unpublish opts](#opts) + * [`publish()`](#publish) + * [`unpublish()`](#unpublish) + +## Example + +```js +const { publish, unpublish } = require('libnpmpublish') +``` + +## Install + +`$ npm install libnpmpublish` + +### API + +#### <a name="opts"></a> `opts` for `libnpmpublish` commands + +`libnpmpublish` uses +[`npm-registry-fetch`](https://npm.im/npm-registry-fetch). Most options +are passed through directly to that library, so please refer to [its own +`opts` documentation](http://npm.im/npm-registry-fetch#fetch-options) for +options that can be passed in. + +A couple of options of note: + +* `opts.defaultTag` - registers the published package with the given tag, + defaults to `latest`. + +* `opts.access` - tells the registry whether this package should be + published as public or restricted. Only applies to scoped packages, which + default to restricted. + +* `opts.token` - can be passed in and will be used as the authentication + token for the registry. For other ways to pass in auth details, see the + n-r-f docs. + +#### <a name="publish"></a> `> libpub.publish(manifest, tarData, [opts]) -> Promise` + +Sends the package represented by the `manifest` and `tarData` to the +configured registry. + +`manifest` should be the parsed `package.json` for the package being +published (which can also be the manifest pulled from a packument, a git +repo, tarball, etc.) + +`tarData` is a `Buffer` of the tarball being published. + +If `opts.npmVersion` is passed in, it will be used as the `_npmVersion` +field in the outgoing packument. You may put your own user-agent string in +there to identify your publishes. + +If `opts.algorithms` is passed in, it should be an array of hashing +algorithms to generate `integrity` hashes for. The default is `['sha512']`, +which means you end up with `dist.integrity = 'sha512-deadbeefbadc0ffee'`. +Any algorithm supported by your current node version is allowed -- npm +clients that do not support those algorithms will simply ignore the +unsupported hashes. + +##### Example + +```js +// note that pacote.manifest() and pacote.tarball() can also take +// any spec that npm can install. a folder shown here, since that's +// far and away the most common use case. +const path = '/a/path/to/your/source/code' +const pacote = require('pacote') // see: http://npm.im/pacote +const manifest = await pacote.manifest(path) +const tarData = await pacote.tarball(path) +await libpub.publish(manifest, tarData, { + npmVersion: 'my-pub-script@1.0.2', + token: 'my-auth-token-here' +}, opts) +// Package has been published to the npm registry. +``` + +#### <a name="unpublish"></a> `> libpub.unpublish(spec, [opts]) -> Promise` + +Unpublishes `spec` from the appropriate registry. The registry in question may +have its own limitations on unpublishing. + +`spec` should be either a string, or a valid +[`npm-package-arg`](https://npm.im/npm-package-arg) parsed spec object. For +legacy compatibility reasons, only `tag` and `version` specs will work as +expected. `range` specs will fail silently in most cases. + +##### Example + +```js +await libpub.unpublish('lodash', { token: 'i-am-the-worst'}) +// +// `lodash` has now been unpublished, along with all its versions +``` diff --git a/workspaces/libnpmpublish/SECURITY.md b/workspaces/libnpmpublish/SECURITY.md new file mode 100644 index 000000000..a93106d0c --- /dev/null +++ b/workspaces/libnpmpublish/SECURITY.md @@ -0,0 +1,3 @@ +<!-- This file is automatically added by @npmcli/template-oss. Do not edit. --> + +Please send vulnerability reports through [hackerone](https://hackerone.com/github). diff --git a/workspaces/libnpmpublish/lib/index.js b/workspaces/libnpmpublish/lib/index.js new file mode 100644 index 000000000..35687e026 --- /dev/null +++ b/workspaces/libnpmpublish/lib/index.js @@ -0,0 +1,4 @@ +module.exports = { + publish: require('./publish.js'), + unpublish: require('./unpublish.js'), +} diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js new file mode 100644 index 000000000..f6d88f732 --- /dev/null +++ b/workspaces/libnpmpublish/lib/publish.js @@ -0,0 +1,182 @@ +const { fixer } = require('normalize-package-data') +const npmFetch = require('npm-registry-fetch') +const npa = require('npm-package-arg') +const semver = require('semver') +const { URL } = require('url') +const ssri = require('ssri') + +const publish = async (manifest, tarballData, opts) => { + if (manifest.private) { + throw Object.assign( + new Error(`This package has been marked as private +Remove the 'private' field from the package.json to publish it.`), + { code: 'EPRIVATE' } + ) + } + + // spec is used to pick the appropriate registry/auth combo + const spec = npa.resolve(manifest.name, manifest.version) + opts = { + defaultTag: 'latest', + // if scoped, restricted by default + access: spec.scope ? 'restricted' : 'public', + algorithms: ['sha512'], + ...opts, + spec, + } + + const reg = npmFetch.pickRegistry(spec, opts) + const pubManifest = patchManifest(manifest, opts) + + // registry-frontdoor cares about the access level, + // which is only configurable for scoped packages + if (!spec.scope && opts.access === 'restricted') { + throw Object.assign( + new Error("Can't restrict access to unscoped packages."), + { code: 'EUNSCOPED' } + ) + } + + const metadata = buildMetadata(reg, pubManifest, tarballData, opts) + + try { + return await npmFetch(spec.escapedName, { + ...opts, + method: 'PUT', + body: metadata, + ignoreBody: true, + }) + } catch (err) { + if (err.code !== 'E409') { + throw err + } + // if E409, we attempt exactly ONE retry, to protect us + // against malicious activity like trying to publish + // a bunch of new versions of a package at the same time + // and/or spamming the registry + const current = await npmFetch.json(spec.escapedName, { + ...opts, + query: { write: true }, + }) + const newMetadata = patchMetadata(current, metadata, opts) + return npmFetch(spec.escapedName, { + ...opts, + method: 'PUT', + body: newMetadata, + ignoreBody: true, + }) + } +} + +const patchManifest = (_manifest, opts) => { + const { npmVersion } = opts + // we only update top-level fields, so a shallow clone is fine + const manifest = { ..._manifest } + + manifest._nodeVersion = process.versions.node + if (npmVersion) { + manifest._npmVersion = npmVersion + } + + fixer.fixNameField(manifest, { strict: true, allowLegacyCase: true }) + const version = semver.clean(manifest.version) + if (!version) { + throw Object.assign( + new Error('invalid semver: ' + manifest.version), + { code: 'EBADSEMVER' } + ) + } + manifest.version = version + return manifest +} + +const buildMetadata = (registry, manifest, tarballData, opts) => { + const { access, defaultTag, algorithms } = opts + const root = { + _id: manifest.name, + name: manifest.name, + description: manifest.description, + 'dist-tags': {}, + versions: {}, + access, + } + + root.versions[manifest.version] = manifest + const tag = manifest.tag || defaultTag + root['dist-tags'][tag] = manifest.version + + const tarballName = `${manifest.name}-${manifest.version}.tgz` + const tarballURI = `${manifest.name}/-/${tarballName}` + const integrity = ssri.fromData(tarballData, { + algorithms: [...new Set(['sha1'].concat(algorithms))], + }) + + manifest._id = `${manifest.name}@${manifest.version}` + manifest.dist = { ...manifest.dist } + // Don't bother having sha1 in the actual integrity field + manifest.dist.integrity = integrity.sha512[0].toString() + // Legacy shasum support + manifest.dist.shasum = integrity.sha1[0].hexDigest() + + // NB: the CLI always fetches via HTTPS if the registry is HTTPS, + // regardless of what's here. This makes it so that installing + // from an HTTP-only mirror doesn't cause problems, though. + manifest.dist.tarball = new URL(tarballURI, registry).href + .replace(/^https:\/\//, 'http://') + + root._attachments = {} + root._attachments[tarballName] = { + content_type: 'application/octet-stream', + data: tarballData.toString('base64'), + length: tarballData.length, + } + + return root +} + +const patchMetadata = (current, newData) => { + const curVers = Object.keys(current.versions || {}) + .map(v => semver.clean(v, true)) + .concat(Object.keys(current.time || {}) + .map(v => semver.valid(v, true) && semver.clean(v, true)) + .filter(v => v)) + + const newVersion = Object.keys(newData.versions)[0] + + if (curVers.indexOf(newVersion) !== -1) { + const { name: pkgid, version } = newData + throw Object.assign( + new Error( + `Cannot publish ${pkgid}@${version} over existing version.` + ), { + code: 'EPUBLISHCONFLICT', + pkgid, + version, + }) + } + + current.versions = current.versions || {} + current.versions[newVersion] = newData.versions[newVersion] + for (const i in newData) { + switch (i) { + // objects that copy over the new stuffs + case 'dist-tags': + case 'versions': + case '_attachments': + for (const j in newData[i]) { + current[i] = current[i] || {} + current[i][j] = newData[i][j] + } + break + + // copy + default: + current[i] = newData[i] + break + } + } + + return current +} + +module.exports = publish diff --git a/workspaces/libnpmpublish/lib/unpublish.js b/workspaces/libnpmpublish/lib/unpublish.js new file mode 100644 index 000000000..7fbeea503 --- /dev/null +++ b/workspaces/libnpmpublish/lib/unpublish.js @@ -0,0 +1,102 @@ +'use strict' + +const npa = require('npm-package-arg') +const npmFetch = require('npm-registry-fetch') +const semver = require('semver') +const { URL } = require('url') + +const unpublish = async (spec, opts) => { + spec = npa(spec) + // spec is used to pick the appropriate registry/auth combo. + opts = { + force: false, + ...opts, + spec, + } + + try { + const pkgUri = spec.escapedName + const pkg = await npmFetch.json(pkgUri, { + ...opts, + query: { write: true }, + }) + + const version = spec.rawSpec + const allVersions = pkg.versions || {} + const versionData = allVersions[version] + + const rawSpecs = (!spec.rawSpec || spec.rawSpec === '*') + const onlyVersion = Object.keys(allVersions).length === 1 + const noVersions = !Object.keys(allVersions).length + + // if missing specific version, + // assumed unpublished + if (!versionData && !rawSpecs && !noVersions) { + return true + } + + // unpublish all versions of a package: + // - no specs supplied "npm unpublish foo" + // - all specs ("*") "npm unpublish foo@*" + // - there was only one version + // - has no versions field on packument + if (rawSpecs || onlyVersion || noVersions) { + await npmFetch(`${pkgUri}/-rev/${pkg._rev}`, { + ...opts, + method: 'DELETE', + ignoreBody: true, + }) + return true + } else { + const dist = allVersions[version].dist + delete allVersions[version] + + const latestVer = pkg['dist-tags'].latest + + // deleting dist tags associated to version + Object.keys(pkg['dist-tags']).forEach(tag => { + if (pkg['dist-tags'][tag] === version) { + delete pkg['dist-tags'][tag] + } + }) + + if (latestVer === version) { + pkg['dist-tags'].latest = Object.keys( + allVersions + ).sort(semver.compareLoose).pop() + } + + delete pkg._revisions + delete pkg._attachments + + // Update packument with removed versions + await npmFetch(`${pkgUri}/-rev/${pkg._rev}`, { + ...opts, + method: 'PUT', + body: pkg, + ignoreBody: true, + }) + + // Remove the tarball itself + const { _rev } = await npmFetch.json(pkgUri, { + ...opts, + query: { write: true }, + }) + const tarballUrl = new URL(dist.tarball).pathname.substr(1) + await npmFetch(`${tarballUrl}/-rev/${_rev}`, { + ...opts, + method: 'DELETE', + ignoreBody: true, + }) + return true + } + } catch (err) { + if (err.code !== 'E404') { + throw err + } + + return true + } +} + +module.exports = unpublish diff --git a/workspaces/libnpmpublish/package.json b/workspaces/libnpmpublish/package.json new file mode 100644 index 000000000..ebedad68f --- /dev/null +++ b/workspaces/libnpmpublish/package.json @@ -0,0 +1,56 @@ +{ + "name": "libnpmpublish", + "version": "5.0.0", + "description": "Programmatic API for the bits behind npm publish and unpublish", + "author": "GitHub Inc.", + "main": "lib/index.js", + "contributors": [ + "Kat Marchán <kzm@zkat.tech>", + "Claudia Hernández <claudia@npmjs.com>" + ], + "files": [ + "bin", + "lib" + ], + "license": "ISC", + "scripts": { + "eslint": "eslint", + "lint": "eslint '**/*.js'", + "lintfix": "npm run lint -- --fix", + "preversion": "npm test", + "postversion": "npm publish", + "prepublishOnly": "git push origin --follow-tags", + "test": "tap", + "posttest": "npm run lint", + "postlint": "npm-template-check", + "snap": "tap" + }, + "tap": { + "check-coverage": true + }, + "devDependencies": { + "libnpmpack": "^3.0.0", + "lodash.clonedeep": "^4.5.0", + "nock": "^12.0.2", + "tap": "^15" + }, + "repository": { + "type": "git", + "url": "https://github.com/npm/libnpmpublish.git" + }, + "bugs": "https://github.com/npm/libnpmpublish/issues", + "homepage": "https://npmjs.com/package/libnpmpublish", + "dependencies": { + "normalize-package-data": "^3.0.2", + "npm-package-arg": "^8.1.2", + "npm-registry-fetch": "^11.0.0", + "semver": "^7.1.3", + "ssri": "^8.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16" + }, + "templateOSS": { + "version": "2.4.1" + } +} diff --git a/workspaces/libnpmpublish/test/fixtures/tnock.js b/workspaces/libnpmpublish/test/fixtures/tnock.js new file mode 100644 index 000000000..048639a50 --- /dev/null +++ b/workspaces/libnpmpublish/test/fixtures/tnock.js @@ -0,0 +1,12 @@ +'use strict' + +const nock = require('nock') + +module.exports = tnock +function tnock (t, host) { + const server = nock(host) + t.teardown(function () { + server.done() + }) + return server +} diff --git a/workspaces/libnpmpublish/test/index.js b/workspaces/libnpmpublish/test/index.js new file mode 100644 index 000000000..7238e65a4 --- /dev/null +++ b/workspaces/libnpmpublish/test/index.js @@ -0,0 +1,6 @@ +const t = require('tap') +const index = require('../lib/index.js') +t.strictSame(index, { + publish: require('../lib/publish.js'), + unpublish: require('../lib/unpublish.js'), +}) diff --git a/workspaces/libnpmpublish/test/publish.js b/workspaces/libnpmpublish/test/publish.js new file mode 100644 index 000000000..fdd20f899 --- /dev/null +++ b/workspaces/libnpmpublish/test/publish.js @@ -0,0 +1,574 @@ +'use strict' + +const t = require('tap') +const ssri = require('ssri') +const crypto = require('crypto') +const pack = require('libnpmpack') +const cloneDeep = require('lodash.clonedeep') + +const publish = require('../lib/publish.js') +const tnock = require('./fixtures/tnock.js') + +const testDir = t.testdir({ + 'package.json': JSON.stringify({ + name: 'libnpmpublish', + version: '1.0.0', + }, null, 2), + 'index.js': 'hello', +}) + +const OPTS = { + registry: 'https://mock.reg/', +} + +const REG = OPTS.registry + +t.test('basic publish', async t => { + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + const packument = { + _id: 'libnpmpublish', + name: 'libnpmpublish', + description: 'some stuff', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: 'libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.0.tgz', + }, + }, + }, + access: 'public', + _attachments: { + 'libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + } + + const srv = tnock(t, REG) + srv.put('/libnpmpublish', body => { + t.same(body, packument, 'posted packument matches expectations') + return true + }, { + authorization: 'Bearer deadbeef', + }).reply(201, {}) + + const ret = await publish(manifest, tarData, { + ...OPTS, + token: 'deadbeef', + }) + t.ok(ret, 'publish succeeded') +}) + +t.test('scoped publish', async t => { + const manifest = { + name: '@claudiahdz/libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + const packument = { + _id: '@claudiahdz/libnpmpublish', + name: '@claudiahdz/libnpmpublish', + description: 'some stuff', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: '@claudiahdz/libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + _npmVersion: '6.13.7', + name: '@claudiahdz/libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/@claudiahdz/libnpmpublish/' + + '-/@claudiahdz/libnpmpublish-1.0.0.tgz', + }, + }, + }, + access: 'restricted', + _attachments: { + '@claudiahdz/libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + } + + const srv = tnock(t, REG) + srv.put('/@claudiahdz%2flibnpmpublish', body => { + t.same(body, packument, 'posted packument matches expectations') + return true + }, { + authorization: 'Bearer deadbeef', + }).reply(201, {}) + + const ret = await publish(manifest, tarData, { + ...OPTS, + npmVersion: '6.13.7', + token: 'deadbeef', + }) + t.ok(ret, 'publish succeeded') +}) + +t.test('retry after a conflict', async t => { + const REV = '72-47f2986bfd8e8b55068b204588bbf484' + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + + const basePackument = { + name: 'libnpmpublish', + description: 'some stuff', + access: 'public', + _id: 'libnpmpublish', + 'dist-tags': {}, + versions: {}, + _attachments: {}, + } + const currentPackument = cloneDeep({ + ...basePackument, + time: { + modified: new Date().toISOString(), + created: new Date().toISOString(), + '1.0.1': new Date().toISOString(), + }, + 'dist-tags': { latest: '1.0.1' }, + versions: { + '1.0.1': { + _id: 'libnpmpublish@1.0.1', + _nodeVersion: process.versions.node, + _npmVersion: '13.7.0', + name: 'libnpmpublish', + version: '1.0.1', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.1.tgz', + }, + }, + }, + _attachments: { + 'libnpmpublish-1.0.1.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + }) + const newPackument = cloneDeep({ + ...basePackument, + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + _id: 'libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + _npmVersion: '6.13.7', + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.0.tgz', + }, + }, + }, + _attachments: { + 'libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + }) + const mergedPackument = cloneDeep({ + ...basePackument, + time: currentPackument.time, + 'dist-tags': { latest: '1.0.0' }, + versions: { ...currentPackument.versions, ...newPackument.versions }, + _attachments: { ...currentPackument._attachments, ...newPackument._attachments }, + }) + + const srv = tnock(t, REG) + srv.put('/libnpmpublish', body => { + t.notOk(body._rev, 'no _rev in initial post') + t.same(body, newPackument, 'got conflicting packument') + return true + }).reply(409, { error: 'gimme _rev plz' }) + + srv.get('/libnpmpublish?write=true').reply(200, { + _rev: REV, + ...currentPackument, + }) + + srv.put('/libnpmpublish', body => { + t.same(body, { + _rev: REV, + ...mergedPackument, + }, 'posted packument includes _rev and a merged version') + return true + }).reply(201, {}) + + const ret = await publish(manifest, tarData, { + ...OPTS, + token: 'deadbeef', + npmVersion: '6.13.7', + }) + + t.ok(ret, 'publish succeeded') +}) + +t.test('retry after a conflict -- no versions on remote', async t => { + const REV = '72-47f2986bfd8e8b55068b204588bbf484' + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + + const basePackument = { + name: 'libnpmpublish', + description: 'some stuff', + access: 'public', + _id: 'libnpmpublish', + } + const currentPackument = { ...basePackument } + const newPackument = cloneDeep({ + ...basePackument, + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + _id: 'libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + _npmVersion: '6.13.7', + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.0.tgz', + }, + }, + }, + _attachments: { + 'libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + }) + const mergedPackument = cloneDeep({ + ...basePackument, + 'dist-tags': { latest: '1.0.0' }, + versions: { ...newPackument.versions }, + _attachments: { ...newPackument._attachments }, + }) + + const srv = tnock(t, REG) + srv.put('/libnpmpublish', body => { + t.notOk(body._rev, 'no _rev in initial post') + t.same(body, newPackument, 'got conflicting packument') + return true + }).reply(409, { error: 'gimme _rev plz' }) + + srv.get('/libnpmpublish?write=true').reply(200, { + _rev: REV, + ...currentPackument, + }) + + srv.put('/libnpmpublish', body => { + t.same(body, { + _rev: REV, + ...mergedPackument, + }, 'posted packument includes _rev and a merged version') + return true + }).reply(201, {}) + + const ret = await publish(manifest, tarData, { + ...OPTS, + npmVersion: '6.13.7', + token: 'deadbeef', + }) + + t.ok(ret, 'publish succeeded') +}) + +t.test('version conflict', async t => { + const REV = '72-47f2986bfd8e8b55068b204588bbf484' + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + const basePackument = { + name: 'libnpmpublish', + description: 'some stuff', + access: 'public', + _id: 'libnpmpublish', + 'dist-tags': {}, + versions: {}, + _attachments: {}, + } + const newPackument = cloneDeep(Object.assign({}, basePackument, { + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { + _id: 'libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + _npmVersion: '6.13.7', + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.0.tgz', + }, + }, + }, + _attachments: { + 'libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + })) + + const srv = tnock(t, REG) + srv.put('/libnpmpublish', body => { + t.notOk(body._rev, 'no _rev in initial post') + t.same(body, newPackument, 'got conflicting packument') + return true + }).reply(409, { error: 'gimme _rev plz' }) + + srv.get('/libnpmpublish?write=true').reply(200, { + _rev: REV, + ...newPackument, + }) + + try { + await publish(manifest, tarData, { + ...OPTS, + npmVersion: '6.13.7', + token: 'deadbeef', + }) + } catch (err) { + t.equal(err.code, 'EPUBLISHCONFLICT', 'got publish conflict code') + } +}) + +t.test('refuse if package marked private', async t => { + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + private: true, + } + + try { + await publish(manifest, Buffer.from(''), { + ...OPTS, + npmVersion: '6.9.0', + token: 'deadbeef', + }) + } catch (err) { + t.equal(err.code, 'EPRIVATE', 'got correct error code') + } +}) + +t.test('publish includes access', async t => { + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + const packument = { + name: 'libnpmpublish', + description: 'some stuff', + access: 'public', + _id: 'libnpmpublish', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: 'libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.0.tgz', + }, + }, + }, + _attachments: { + 'libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + } + + const srv = tnock(t, REG) + srv.put('/libnpmpublish', body => { + t.same(body, packument, 'posted packument matches expectations') + return true + }, { + authorization: 'Bearer deadbeef', + }).reply(201, {}) + + const ret = await publish(manifest, tarData, { + ...OPTS, + token: 'deadbeef', + access: 'public', + }) + + t.ok(ret, 'publish succeeded') +}) + +t.test('refuse if package is unscoped plus `restricted` access', async t => { + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + try { + await publish(manifest, Buffer.from(''), { + ...OPTS, + npmVersion: '6.13.7', + access: 'restricted', + }) + } catch (err) { + t.equal(err.code, 'EUNSCOPED', 'got correct error code') + } +}) + +t.test('refuse if bad semver on manifest', async t => { + const manifest = { + name: 'libnpmpublish', + version: 'lmao', + description: 'some stuff', + } + + try { + await publish(manifest, Buffer.from(''), OPTS) + } catch (err) { + t.equal(err.code, 'EBADSEMVER', 'got correct error code') + } +}) + +t.test('other error code', async t => { + const manifest = { + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + } + + const tarData = await pack(`file:${testDir}`, { ...OPTS }) + const shasum = crypto.createHash('sha1').update(tarData).digest('hex') + const integrity = ssri.fromData(tarData, { algorithms: ['sha512'] }) + const packument = { + name: 'libnpmpublish', + description: 'some stuff', + access: 'public', + _id: 'libnpmpublish', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + _id: 'libnpmpublish@1.0.0', + _nodeVersion: process.versions.node, + _npmVersion: '6.13.7', + name: 'libnpmpublish', + version: '1.0.0', + description: 'some stuff', + dist: { + shasum, + integrity: integrity.toString(), + tarball: 'http://mock.reg/libnpmpublish/-/libnpmpublish-1.0.0.tgz', + }, + }, + }, + _attachments: { + 'libnpmpublish-1.0.0.tgz': { + content_type: 'application/octet-stream', + data: tarData.toString('base64'), + length: tarData.length, + }, + }, + } + + const srv = tnock(t, REG) + srv.put('/libnpmpublish', body => { + t.same(body, packument, 'posted packument matches expectations') + return true + }, { + authorization: 'Bearer deadbeef', + }).reply(500, { error: 'go away' }) + + try { + await publish(manifest, tarData, { + ...OPTS, + npmVersion: '6.13.7', + token: 'deadbeef', + }) + } catch (err) { + t.match(err.message, /go away/, 'no retry on non-409') + } +}) diff --git a/workspaces/libnpmpublish/test/unpublish.js b/workspaces/libnpmpublish/test/unpublish.js new file mode 100644 index 000000000..6fbe80276 --- /dev/null +++ b/workspaces/libnpmpublish/test/unpublish.js @@ -0,0 +1,242 @@ +'use strict' + +const t = require('tap') +const tnock = require('./fixtures/tnock.js') + +const OPTS = { + registry: 'https://mock.reg/', +} + +const REG = OPTS.registry +const REV = '72-47f2986bfd8e8b55068b204588bbf484' +const unpub = require('../lib/unpublish.js') + +t.test('basic test', async t => { + const doc = { + _id: 'foo', + _rev: REV, + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + }, + } + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(200, doc) + srv.delete(`/foo/-rev/${REV}`).reply(201) + const ret = await unpub('foo', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('scoped basic test', async t => { + const doc = { + _id: '@foo/bar', + _rev: REV, + name: '@foo/bar', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: '@foo/bar', + dist: { + tarball: `${REG}/@foo/bar/-/foo-1.0.0.tgz`, + }, + }, + }, + } + const srv = tnock(t, REG) + srv.get('/@foo%2fbar?write=true').reply(200, doc) + srv.delete(`/@foo%2fbar/-rev/${REV}`).reply(201) + const ret = await unpub('@foo/bar', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('unpublish specific, last version', async t => { + const doc = { + _id: 'foo', + _rev: REV, + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + }, + } + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(200, doc) + srv.delete(`/foo/-rev/${REV}`).reply(201) + const ret = await unpub('foo@1.0.0', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('unpublish specific version', async t => { + const doc = { + _id: 'foo', + _rev: REV, + _revisions: [1, 2, 3], + _attachments: [1, 2, 3], + name: 'foo', + 'dist-tags': { + latest: '1.0.1', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + '1.0.1': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.1.tgz`, + }, + }, + }, + } + const postEdit = { + _id: 'foo', + _rev: REV, + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + }, + } + + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(200, doc) + srv.put(`/foo/-rev/${REV}`, postEdit).reply(200) + srv.get('/foo?write=true').reply(200, postEdit) + srv.delete(`/foo/-/foo-1.0.1.tgz/-rev/${REV}`).reply(200) + const ret = await unpub('foo@1.0.1', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('404 considered a success', async t => { + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(404) + const ret = await unpub('foo', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('non-404 errors', async t => { + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(500) + + try { + await unpub('foo', OPTS) + } catch (err) { + t.equal(err.code, 'E500', 'got right error from server') + } +}) + +t.test('packument with missing versions unpublishes whole thing', async t => { + const doc = { + _id: 'foo', + _rev: REV, + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + } + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(200, doc) + srv.delete(`/foo/-rev/${REV}`).reply(201) + const ret = await unpub('foo@1.0.0', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('packument with missing specific version assumed unpublished', async t => { + const doc = { + _id: 'foo', + _rev: REV, + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + }, + } + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(200, doc) + const ret = await unpub('foo@1.0.1', OPTS) + t.ok(ret, 'foo was unpublished') +}) + +t.test('unpublish specific version without dist-tag update', async t => { + const doc = { + _id: 'foo', + _rev: REV, + _revisions: [1, 2, 3], + _attachments: [1, 2, 3], + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + '1.0.1': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.1.tgz`, + }, + }, + }, + } + const postEdit = { + _id: 'foo', + _rev: REV, + name: 'foo', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + name: 'foo', + dist: { + tarball: `${REG}/foo/-/foo-1.0.0.tgz`, + }, + }, + }, + } + const srv = tnock(t, REG) + srv.get('/foo?write=true').reply(200, doc) + srv.put(`/foo/-rev/${REV}`, postEdit).reply(200) + srv.get('/foo?write=true').reply(200, postEdit) + srv.delete(`/foo/-/foo-1.0.1.tgz/-rev/${REV}`).reply(200) + const ret = await unpub('foo@1.0.1', OPTS) + t.ok(ret, 'foo was unpublished') +}) |