Skip to content

Commit be4741f

Browse files
authored
fix: unpublish bugfixes (#7039)
- Properly work in workspace mode. Previously setting the workspace meant the entire package was unpublished. Now it follows the convention of acting as if you were running from the workspace directory. - Better checking of when you are unpublishing the last version of a package. npm checks the actual manifest and compares it to the version you are asking to unpublish. - Error on ranges and tags. npm doesn't unpublish ranges or tags, and giving those as inputs would give unexepected results. - Proper output of what was unpublished. Previously the package (and sometimes version) displayed would not match what was actually unpublished. - Updated docs specifying that unpublishing with no parameters will only unpublish the version represented by the local package.json
1 parent 4ba585c commit be4741f

File tree

3 files changed

+126
-88
lines changed

3 files changed

+126
-88
lines changed

docs/lib/content/commands/npm-unpublish.md

+6-2
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ removing the tarball.
2525
The npm registry will return an error if you are not [logged
2626
in](/commands/npm-adduser).
2727

28-
If you do not specify a version or if you remove all of a package's
29-
versions then the registry will remove the root package entry entirely.
28+
If you do not specify a package name at all, the name and version to be
29+
unpublished will be pulled from the project in the current directory.
30+
31+
If you specify a package name but do not specify a version or if you
32+
remove all of a package's versions then the registry will remove the
33+
root package entry entirely.
3034

3135
Even if you unpublish a package version, that specific name and version
3236
combination can never be reused. In order to publish the package again,

lib/commands/unpublish.js

+56-47
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const libaccess = require('libnpmaccess')
22
const libunpub = require('libnpmpublish').unpublish
33
const npa = require('npm-package-arg')
4-
const npmFetch = require('npm-registry-fetch')
4+
const pacote = require('pacote')
55
const pkgJson = require('@npmcli/package-json')
66

77
const { flatten } = require('@npmcli/config/lib/definitions')
@@ -23,12 +23,12 @@ class Unpublish extends BaseCommand {
2323
static ignoreImplicitWorkspace = false
2424

2525
static async getKeysOfVersions (name, opts) {
26-
const pkgUri = npa(name).escapedName
27-
const json = await npmFetch.json(`${pkgUri}?write=true`, {
26+
const packument = await pacote.packument(name, {
2827
...opts,
2928
spec: name,
29+
query: { write: true },
3030
})
31-
return Object.keys(json.versions)
31+
return Object.keys(packument.versions)
3232
}
3333

3434
static async completion (args, npm) {
@@ -59,28 +59,43 @@ class Unpublish extends BaseCommand {
5959
return pkgs
6060
}
6161

62-
const versions = await this.getKeysOfVersions(pkgs[0], opts)
62+
const versions = await Unpublish.getKeysOfVersions(pkgs[0], opts)
6363
if (!versions.length) {
6464
return pkgs
6565
} else {
6666
return versions.map(v => `${pkgs[0]}@${v}`)
6767
}
6868
}
6969

70-
async exec (args) {
70+
async exec (args, { localPrefix } = {}) {
7171
if (args.length > 1) {
7272
throw this.usageError()
7373
}
7474

75-
let spec = args.length && npa(args[0])
75+
// workspace mode
76+
if (!localPrefix) {
77+
localPrefix = this.npm.localPrefix
78+
}
79+
7680
const force = this.npm.config.get('force')
7781
const { silent } = this.npm
7882
const dryRun = this.npm.config.get('dry-run')
7983

84+
let spec
85+
if (args.length) {
86+
spec = npa(args[0])
87+
if (spec.type !== 'version' && spec.rawSpec !== '*') {
88+
throw this.usageError(
89+
'Can only unpublish a single version, or the entire project.\n' +
90+
'Tags and ranges are not supported.'
91+
)
92+
}
93+
}
94+
8095
log.silly('unpublish', 'args[0]', args[0])
8196
log.silly('unpublish', 'spec', spec)
8297

83-
if ((!spec || !spec.rawSpec) && !force) {
98+
if (spec?.rawSpec === '*' && !force) {
8499
throw this.usageError(
85100
'Refusing to delete entire project.\n' +
86101
'Run with --force to do this.'
@@ -89,69 +104,63 @@ class Unpublish extends BaseCommand {
89104

90105
const opts = { ...this.npm.flatOptions }
91106

92-
let pkgName
93-
let pkgVersion
94107
let manifest
95-
let manifestErr
96108
try {
97-
const { content } = await pkgJson.prepare(this.npm.localPrefix)
109+
const { content } = await pkgJson.prepare(localPrefix)
98110
manifest = content
99111
} catch (err) {
100-
manifestErr = err
101-
}
102-
if (spec) {
103-
// If cwd has a package.json with a name that matches the package being
104-
// unpublished, load up the publishConfig
105-
if (manifest && manifest.name === spec.name && manifest.publishConfig) {
106-
flatten(manifest.publishConfig, opts)
107-
}
108-
const versions = await Unpublish.getKeysOfVersions(spec.name, opts)
109-
if (versions.length === 1 && !force) {
110-
throw this.usageError(LAST_REMAINING_VERSION_ERROR)
111-
}
112-
pkgName = spec.name
113-
pkgVersion = spec.type === 'version' ? `@${spec.rawSpec}` : ''
114-
} else {
115-
if (manifestErr) {
116-
if (manifestErr.code === 'ENOENT' || manifestErr.code === 'ENOTDIR') {
112+
// we needed the manifest to figure out the package to unpublish
113+
if (!spec) {
114+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
117115
throw this.usageError()
118116
} else {
119-
throw manifestErr
117+
throw err
120118
}
121119
}
120+
}
122121

123-
log.verbose('unpublish', manifest)
124-
122+
let pkgVersion // for cli output
123+
if (spec) {
124+
pkgVersion = spec.type === 'version' ? `@${spec.rawSpec}` : ''
125+
} else {
125126
spec = npa.resolve(manifest.name, manifest.version)
126-
if (manifest.publishConfig) {
127-
flatten(manifest.publishConfig, opts)
127+
log.verbose('unpublish', manifest)
128+
pkgVersion = manifest.version ? `@${manifest.version}` : ''
129+
if (!manifest.version && !force) {
130+
throw this.usageError(
131+
'Refusing to delete entire project.\n' +
132+
'Run with --force to do this.'
133+
)
128134
}
135+
}
129136

130-
pkgName = manifest.name
131-
pkgVersion = manifest.version ? `@${manifest.version}` : ''
137+
// If localPrefix has a package.json with a name that matches the package
138+
// being unpublished, load up the publishConfig
139+
if (manifest?.name === spec.name && manifest.publishConfig) {
140+
flatten(manifest.publishConfig, opts)
141+
}
142+
143+
const versions = await Unpublish.getKeysOfVersions(spec.name, opts)
144+
if (versions.length === 1 && spec.rawSpec === versions[0] && !force) {
145+
throw this.usageError(LAST_REMAINING_VERSION_ERROR)
146+
}
147+
if (versions.length === 1) {
148+
pkgVersion = ''
132149
}
133150

134151
if (!dryRun) {
135152
await otplease(this.npm, opts, o => libunpub(spec, o))
136153
}
137154
if (!silent) {
138-
this.npm.output(`- ${pkgName}${pkgVersion}`)
155+
this.npm.output(`- ${spec.name}${pkgVersion}`)
139156
}
140157
}
141158

142159
async execWorkspaces (args) {
143160
await this.setWorkspaces()
144161

145-
const force = this.npm.config.get('force')
146-
if (!force) {
147-
throw this.usageError(
148-
'Refusing to delete entire project(s).\n' +
149-
'Run with --force to do this.'
150-
)
151-
}
152-
153-
for (const name of this.workspaceNames) {
154-
await this.exec([name])
162+
for (const path of this.workspacePaths) {
163+
await this.exec(args, { localPrefix: path })
155164
}
156165
}
157166
}

test/lib/commands/unpublish.js

+64-39
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ t.test('no args --force success', async t => {
2626
authorization: 'test-auth-token',
2727
})
2828
const manifest = registry.manifest({ name: pkg })
29-
await registry.package({ manifest, query: { write: true } })
29+
await registry.package({ manifest, query: { write: true }, times: 2 })
3030
registry.unpublish({ manifest })
3131
await npm.exec('unpublish', [])
32-
t.equal(joinedOutput(), '- test-package@1.0.0')
32+
t.equal(joinedOutput(), '- test-package')
3333
})
3434

3535
t.test('no args --force missing package.json', async t => {
@@ -63,11 +63,11 @@ t.test('no args --force error reading package.json', async t => {
6363
)
6464
})
6565

66-
t.test('no args entire project', async t => {
66+
t.test('no force entire project', async t => {
6767
const { npm } = await loadMockNpm(t)
6868

6969
await t.rejects(
70-
npm.exec('unpublish', []),
70+
npm.exec('unpublish', ['@npmcli/unpublish-test']),
7171
/Refusing to delete entire project/
7272
)
7373
})
@@ -82,6 +82,26 @@ t.test('too many args', async t => {
8282
)
8383
})
8484

85+
t.test('range', async t => {
86+
const { npm } = await loadMockNpm(t)
87+
88+
await t.rejects(
89+
npm.exec('unpublish', ['a@>1.0.0']),
90+
{ code: 'EUSAGE' },
91+
/single version/
92+
)
93+
})
94+
95+
t.test('tag', async t => {
96+
const { npm } = await loadMockNpm(t)
97+
98+
await t.rejects(
99+
npm.exec('unpublish', ['a@>1.0.0']),
100+
{ code: 'EUSAGE' },
101+
/single version/
102+
)
103+
})
104+
85105
t.test('unpublish <pkg>@version not the last version', async t => {
86106
const { joinedOutput, npm } = await loadMockNpm(t, {
87107
config: {
@@ -129,7 +149,24 @@ t.test('unpublish <pkg>@version last version', async t => {
129149
)
130150
})
131151

132-
t.test('no version found in package.json', async t => {
152+
t.test('no version found in package.json no force', async t => {
153+
const { npm } = await loadMockNpm(t, {
154+
config: {
155+
...auth,
156+
},
157+
prefixDir: {
158+
'package.json': JSON.stringify({
159+
name: pkg,
160+
}, null, 2),
161+
},
162+
})
163+
await t.rejects(
164+
npm.exec('unpublish', []),
165+
/Refusing to delete entire project/
166+
)
167+
})
168+
169+
t.test('no version found in package.json with force', async t => {
133170
const { joinedOutput, npm } = await loadMockNpm(t, {
134171
config: {
135172
force: true,
@@ -147,7 +184,7 @@ t.test('no version found in package.json', async t => {
147184
authorization: 'test-auth-token',
148185
})
149186
const manifest = registry.manifest({ name: pkg })
150-
await registry.package({ manifest, query: { write: true } })
187+
await registry.package({ manifest, query: { write: true }, times: 2 })
151188
registry.unpublish({ manifest })
152189

153190
await npm.exec('unpublish', [])
@@ -219,7 +256,7 @@ t.test('workspaces', async t => {
219256
'workspace-b': {
220257
'package.json': JSON.stringify({
221258
name: 'workspace-b',
222-
version: '1.2.3-n',
259+
version: '1.2.3-b',
223260
repository: 'https://github.com/npm/workspace-b',
224261
}),
225262
},
@@ -231,20 +268,20 @@ t.test('workspaces', async t => {
231268
},
232269
}
233270

234-
t.test('no force', async t => {
271+
t.test('with package name no force', async t => {
235272
const { npm } = await loadMockNpm(t, {
236273
config: {
237-
workspaces: true,
274+
workspace: ['workspace-a'],
238275
},
239276
prefixDir,
240277
})
241278
await t.rejects(
242-
npm.exec('unpublish', []),
279+
npm.exec('unpublish', ['workspace-a']),
243280
/Refusing to delete entire project/
244281
)
245282
})
246283

247-
t.test('all workspaces --force', async t => {
284+
t.test('all workspaces last version --force', async t => {
248285
const { joinedOutput, npm } = await loadMockNpm(t, {
249286
config: {
250287
workspaces: true,
@@ -258,9 +295,9 @@ t.test('workspaces', async t => {
258295
registry: npm.config.get('registry'),
259296
authorization: 'test-auth-token',
260297
})
261-
const manifestA = registry.manifest({ name: 'workspace-a' })
262-
const manifestB = registry.manifest({ name: 'workspace-b' })
263-
const manifestN = registry.manifest({ name: 'workspace-n' })
298+
const manifestA = registry.manifest({ name: 'workspace-a', versions: ['1.2.3-a'] })
299+
const manifestB = registry.manifest({ name: 'workspace-b', versions: ['1.2.3-b'] })
300+
const manifestN = registry.manifest({ name: 'workspace-n', versions: ['1.2.3-n'] })
264301
await registry.package({ manifest: manifestA, query: { write: true }, times: 2 })
265302
await registry.package({ manifest: manifestB, query: { write: true }, times: 2 })
266303
await registry.package({ manifest: manifestN, query: { write: true }, times: 2 })
@@ -271,28 +308,6 @@ t.test('workspaces', async t => {
271308
await npm.exec('unpublish', [])
272309
t.equal(joinedOutput(), '- workspace-a\n- workspace-b\n- workspace-n')
273310
})
274-
275-
t.test('one workspace --force', async t => {
276-
const { joinedOutput, npm } = await loadMockNpm(t, {
277-
config: {
278-
workspace: ['workspace-a'],
279-
force: true,
280-
...auth,
281-
},
282-
prefixDir,
283-
})
284-
const registry = new MockRegistry({
285-
tap: t,
286-
registry: npm.config.get('registry'),
287-
authorization: 'test-auth-token',
288-
})
289-
const manifestA = registry.manifest({ name: 'workspace-a' })
290-
await registry.package({ manifest: manifestA, query: { write: true }, times: 2 })
291-
registry.nock.delete(`/workspace-a/-rev/${manifestA._rev}`).reply(201)
292-
293-
await npm.exec('unpublish', [])
294-
t.equal(joinedOutput(), '- workspace-a')
295-
})
296311
})
297312

298313
t.test('dryRun with spec', async t => {
@@ -331,6 +346,16 @@ t.test('dryRun with no args', async t => {
331346
}, null, 2),
332347
},
333348
})
349+
const registry = new MockRegistry({
350+
tap: t,
351+
registry: npm.config.get('registry'),
352+
authorization: 'test-auth-token',
353+
})
354+
const manifest = registry.manifest({
355+
name: pkg,
356+
packuments: ['1.0.0', '1.0.1'],
357+
})
358+
await registry.package({ manifest, query: { write: true } })
334359

335360
await npm.exec('unpublish', [])
336361
t.equal(joinedOutput(), '- [email protected]')
@@ -360,10 +385,10 @@ t.test('publishConfig no spec', async t => {
360385
authorization: 'test-other-token',
361386
})
362387
const manifest = registry.manifest({ name: pkg })
363-
await registry.package({ manifest, query: { write: true } })
388+
await registry.package({ manifest, query: { write: true }, times: 2 })
364389
registry.unpublish({ manifest })
365390
await npm.exec('unpublish', [])
366-
t.equal(joinedOutput(), '- test-package@1.0.0')
391+
t.equal(joinedOutput(), '- test-package')
367392
})
368393

369394
t.test('publishConfig with spec', async t => {
@@ -421,7 +446,7 @@ t.test('scoped registry config', async t => {
421446
authorization: 'test-other-token',
422447
})
423448
const manifest = registry.manifest({ name: scopedPkg })
424-
await registry.package({ manifest, query: { write: true } })
449+
await registry.package({ manifest, query: { write: true }, times: 2 })
425450
registry.unpublish({ manifest })
426451
await npm.exec('unpublish', [scopedPkg])
427452
})

0 commit comments

Comments
 (0)