Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 85fa87a

Browse files
committedMay 14, 2021
feat(unpublish): add workspace/dry-run support
1 parent 2f5c28a commit 85fa87a

File tree

8 files changed

+266
-97
lines changed

8 files changed

+266
-97
lines changed
 

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

+45
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ passed.
4949

5050
<!-- AUTOGENERATED CONFIG DESCRIPTIONS START -->
5151
<!-- automatically generated, do not edit manually -->
52+
#### `dry-run`
53+
54+
* Default: false
55+
* Type: Boolean
56+
57+
Indicates that you don't want npm to make any changes and that it should
58+
only report what it would have done. This can be passed into any of the
59+
commands that modify your local installation, eg, `install`, `update`,
60+
`dedupe`, `uninstall`, as well as `pack` and `publish`.
61+
62+
Note: This is NOT honored by other network related commands, eg `dist-tags`,
63+
`owner`, etc.
64+
5265
#### `force`
5366

5467
* Default: false
@@ -73,6 +86,38 @@ mistakes, unnecessary performance degradation, and malicious input.
7386
If you don't have a clear idea of what you want to do, it is strongly
7487
recommended that you do not use this option!
7588

89+
#### `workspace`
90+
91+
* Default:
92+
* Type: String (can be set multiple times)
93+
94+
Enable running a command in the context of the configured workspaces of the
95+
current project while filtering by running only the workspaces defined by
96+
this configuration option.
97+
98+
Valid values for the `workspace` config are either:
99+
100+
* Workspace names
101+
* Path to a workspace directory
102+
* Path to a parent workspace directory (will result to selecting all of the
103+
nested workspaces)
104+
105+
When set for the `npm init` command, this may be set to the folder of a
106+
workspace which does not yet exist, to create the folder and set it up as a
107+
brand new workspace within the project.
108+
109+
This value is not exported to the environment for child processes.
110+
111+
#### `workspaces`
112+
113+
* Default: false
114+
* Type: Boolean
115+
116+
Enable running a command in the context of **all** the configured
117+
workspaces.
118+
119+
This value is not exported to the environment for child processes.
120+
76121
<!-- AUTOGENERATED CONFIG DESCRIPTIONS END -->
77122

78123
### See Also

‎lib/publish.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class Publish extends BaseCommand {
5858
if (args.length === 0)
5959
args = ['.']
6060
if (args.length !== 1)
61-
throw this.usage
61+
throw this.usageError()
6262

6363
log.verbose('publish', args)
6464

‎lib/unpublish.js

+40-18
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
const path = require('path')
22
const util = require('util')
3-
const log = require('npmlog')
43
const npa = require('npm-package-arg')
54
const libaccess = require('libnpmaccess')
65
const npmFetch = require('npm-registry-fetch')
76
const libunpub = require('libnpmpublish').unpublish
87
const readJson = util.promisify(require('read-package-json'))
98

9+
const getWorkspaces = require('./workspaces/get-workspaces.js')
1010
const otplease = require('./utils/otplease.js')
1111
const getIdentity = require('./utils/get-identity.js')
1212

@@ -22,13 +22,13 @@ class Unpublish extends BaseCommand {
2222
}
2323

2424
/* istanbul ignore next - see test/lib/load-all-commands.js */
25-
static get usage () {
26-
return ['[<@scope>/]<pkg>[@<version>]']
25+
static get params () {
26+
return ['dry-run', 'force', 'workspace', 'workspaces']
2727
}
2828

2929
/* istanbul ignore next - see test/lib/load-all-commands.js */
30-
static get params () {
31-
return ['force']
30+
static get usage () {
31+
return ['[<@scope>/]<pkg>[@<version>]']
3232
}
3333

3434
async completion (args) {
@@ -67,25 +67,29 @@ class Unpublish extends BaseCommand {
6767
this.unpublish(args).then(() => cb()).catch(cb)
6868
}
6969

70+
execWorkspaces (args, filters, cb) {
71+
this.unpublishWorkspaces(args, filters).then(() => cb()).catch(cb)
72+
}
73+
7074
async unpublish (args) {
7175
if (args.length > 1)
72-
throw new Error(this.usage)
76+
throw this.usageError()
7377

7478
const spec = args.length && npa(args[0])
7579
const force = this.npm.config.get('force')
76-
const silent = this.npm.config.get('silent')
7780
const loglevel = this.npm.config.get('loglevel')
81+
const silent = loglevel === 'silent'
82+
const dryRun = this.npm.config.get('dry-run')
7883
let pkgName
7984
let pkgVersion
8085

81-
log.silly('unpublish', 'args[0]', args[0])
82-
log.silly('unpublish', 'spec', spec)
86+
this.npm.log.silly('unpublish', 'args[0]', args[0])
87+
this.npm.log.silly('unpublish', 'spec', spec)
8388

84-
if (!spec.rawSpec && !force) {
85-
throw new Error(
89+
if ((!spec || !spec.rawSpec) && !force) {
90+
throw this.usageError(
8691
'Refusing to delete entire project.\n' +
87-
'Run with --force to do this.\n' +
88-
this.usage
92+
'Run with --force to do this.'
8993
)
9094
}
9195

@@ -101,25 +105,43 @@ class Unpublish extends BaseCommand {
101105
if (err && err.code !== 'ENOENT' && err.code !== 'ENOTDIR')
102106
throw err
103107
else
104-
throw new Error(`Usage: ${this.usage}`)
108+
throw this.usageError()
105109
}
106110

107-
log.verbose('unpublish', manifest)
111+
this.npm.log.verbose('unpublish', manifest)
108112

109113
const { name, version, publishConfig } = manifest
110114
const pkgJsonSpec = npa.resolve(name, version)
111115
const optsWithPub = { ...opts, publishConfig }
112-
await otplease(opts, opts => libunpub(pkgJsonSpec, optsWithPub))
116+
if (!dryRun)
117+
await otplease(opts, opts => libunpub(pkgJsonSpec, optsWithPub))
113118
pkgName = name
114119
pkgVersion = version ? `@${version}` : ''
115120
} else {
116-
await otplease(opts, opts => libunpub(spec, opts))
121+
if (!dryRun)
122+
await otplease(opts, opts => libunpub(spec, opts))
117123
pkgName = spec.name
118124
pkgVersion = spec.type === 'version' ? `@${spec.rawSpec}` : ''
119125
}
120126

121-
if (!silent && loglevel !== 'silent')
127+
if (!silent)
122128
this.npm.output(`- ${pkgName}${pkgVersion}`)
123129
}
130+
131+
async unpublishWorkspaces (args, filters) {
132+
const workspaces =
133+
await getWorkspaces(filters, { path: this.npm.localPrefix })
134+
135+
const force = this.npm.config.get('force')
136+
if (!force) {
137+
throw this.usageError(
138+
'Refusing to delete entire project(s).\n' +
139+
'Run with --force to do this.'
140+
)
141+
}
142+
143+
for (const [name] of workspaces.entries())
144+
await this.unpublish([name])
145+
}
124146
}
125147
module.exports = Unpublish

‎tap-snapshots/test/lib/publish.js.test.cjs

+21-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*/
77
'use strict'
88
exports[`test/lib/publish.js TAP shows usage with wrong set of arguments > should print usage 1`] = `
9-
npm publish
9+
Error:
10+
Usage: npm publish
1011
1112
Publish a package
1213
@@ -18,13 +19,16 @@ Options:
1819
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
1920
[-ws|--workspaces]
2021
21-
Run "npm help publish" for more info
22+
Run "npm help publish" for more info {
23+
"code": "EUSAGE",
24+
}
2225
`
2326

2427
exports[`test/lib/publish.js TAP workspaces all workspaces > should output all publishes 1`] = `
2528
Array [
2629
"+ workspace-a@1.2.3-a",
2730
"+ workspace-b@1.2.3-n",
31+
"+ workspace-n@1.2.3-n",
2832
]
2933
`
3034

@@ -54,6 +58,12 @@ Array [
5458
},
5559
"version": "1.2.3-n",
5660
},
61+
Object {
62+
"_id": "workspace-n@1.2.3-n",
63+
"name": "workspace-n",
64+
"readme": "ERROR: No README data found!",
65+
"version": "1.2.3-n",
66+
},
5767
]
5868
`
5969

@@ -66,6 +76,9 @@ Array [
6676
},
6777
"workspace-b": {
6878
"id": "workspace-b@1.2.3-n"
79+
},
80+
"workspace-n": {
81+
"id": "workspace-n@1.2.3-n"
6982
}
7083
}
7184
),
@@ -98,6 +111,12 @@ Array [
98111
},
99112
"version": "1.2.3-n",
100113
},
114+
Object {
115+
"_id": "workspace-n@1.2.3-n",
116+
"name": "workspace-n",
117+
"readme": "ERROR: No README data found!",
118+
"version": "1.2.3-n",
119+
},
101120
]
102121
`
103122

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/* IMPORTANT
2+
* This snapshot file is auto-generated, but designed for humans.
3+
* It should be checked into source control and tracked carefully.
4+
* Re-generate by setting TAP_SNAPSHOT=1 and running tests.
5+
* Make sure to inspect the output below. Do not ignore changes!
6+
*/
7+
'use strict'
8+
exports[`test/lib/unpublish.js TAP workspaces all workspaces --force > should output all workspaces 1`] = `
9+
- workspace-a- workspace-b- workspace-n
10+
`
11+
12+
exports[`test/lib/unpublish.js TAP workspaces one workspace --force > should output one workspaces 1`] = `
13+
- workspace-a
14+
`

‎tap-snapshots/test/lib/utils/npm-usage.js.test.cjs

+3-1
Original file line numberDiff line numberDiff line change
@@ -1031,7 +1031,9 @@ All commands:
10311031
npm unpublish [<@scope>/]<pkg>[@<version>]
10321032
10331033
Options:
1034-
[-f|--force]
1034+
[--dry-run] [-f|--force]
1035+
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
1036+
[-ws|--workspaces]
10351037
10361038
Run "npm help unpublish" for more info
10371039

‎test/lib/publish.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -538,12 +538,12 @@ t.test('workspaces', (t) => {
538538
repository: 'https://github.com/npm/workspace-b',
539539
}),
540540
},
541-
'workspace-c': JSON.stringify({
542-
'package.json': {
541+
'workspace-c': {
542+
'package.json': JSON.stringify({
543543
name: 'workspace-n',
544544
version: '1.2.3-n',
545-
},
546-
}),
545+
}),
546+
},
547547
})
548548

549549
const publishes = []

‎test/lib/unpublish.js

+138-71
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,46 @@ let result = ''
55
const noop = () => null
66
const config = {
77
force: false,
8-
silent: false,
98
loglevel: 'silly',
109
}
10+
11+
const testDir = t.testdir({
12+
'package.json': JSON.stringify({
13+
name: 'pkg',
14+
version: '1.0.0',
15+
}, null, 2),
16+
})
17+
1118
const npm = mockNpm({
12-
localPrefix: '',
19+
localPrefix: testDir,
20+
log: { silly () {}, verbose () {} },
1321
config,
1422
output: (...msg) => {
1523
result += msg.join('\n')
1624
},
1725
})
26+
1827
const mocks = {
19-
npmlog: { silly () {}, verbose () {} },
2028
libnpmaccess: { lsPackages: noop },
2129
libnpmpublish: { unpublish: noop },
22-
'npm-package-arg': noop,
2330
'npm-registry-fetch': { json: noop },
24-
'read-package-json': cb => cb(),
2531
'../../lib/utils/otplease.js': async (opts, fn) => fn(opts),
26-
'../../lib/utils/usage.js': () => 'usage instructions',
2732
'../../lib/utils/get-identity.js': async () => 'foo',
2833
}
2934

3035
t.afterEach(() => {
36+
npm.log = { silly () {}, verbose () {} }
37+
npm.localPrefix = testDir
3138
result = ''
39+
config['dry-run'] = false
3240
config.force = false
3341
config.loglevel = 'silly'
34-
config.silent = false
3542
})
3643

3744
t.test('no args --force', t => {
38-
t.plan(9)
39-
4045
config.force = true
4146

42-
const npmlog = {
47+
npm.log = {
4348
silly (title) {
4449
t.equal(title, 'unpublish', 'should silly log args')
4550
},
@@ -53,17 +58,9 @@ t.test('no args --force', t => {
5358
},
5459
}
5560

56-
const npa = {
57-
resolve (name, version) {
58-
t.equal(name, 'pkg', 'should npa.resolve package name')
59-
t.equal(version, '1.0.0', 'should npa.resolve package version')
60-
return 'pkg@1.0.0'
61-
},
62-
}
63-
6461
const libnpmpublish = {
6562
unpublish (spec, opts) {
66-
t.equal(spec, 'pkg@1.0.0', 'should unpublish expected spec')
63+
t.equal(spec.raw, 'pkg@1.0.0', 'should unpublish expected spec')
6764
t.same(
6865
opts,
6966
{
@@ -76,14 +73,9 @@ t.test('no args --force', t => {
7673

7774
const Unpublish = t.mock('../../lib/unpublish.js', {
7875
...mocks,
79-
npmlog,
8076
libnpmpublish,
81-
'npm-package-arg': npa,
82-
'read-package-json': (path, cb) => cb(null, {
83-
name: 'pkg',
84-
version: '1.0.0',
85-
}),
8677
})
78+
8779
const unpublish = new Unpublish(npm)
8880

8981
unpublish.exec([], err => {
@@ -95,25 +87,24 @@ t.test('no args --force', t => {
9587
'- pkg@1.0.0',
9688
'should output removed pkg@version on success'
9789
)
90+
t.end()
9891
})
9992
})
10093

10194
t.test('no args --force missing package.json', t => {
10295
config.force = true
10396

97+
const testDir = t.testdir({})
98+
npm.localPrefix = testDir
10499
const Unpublish = t.mock('../../lib/unpublish.js', {
105100
...mocks,
106-
'read-package-json': (path, cb) => cb(Object.assign(
107-
new Error('ENOENT'),
108-
{ code: 'ENOENT' }
109-
)),
110101
})
111102
const unpublish = new Unpublish(npm)
112103

113104
unpublish.exec([], err => {
114105
t.match(
115106
err,
116-
/usage instructions/,
107+
/Usage: npm unpublish/,
117108
'should throw usage instructions on missing package.json'
118109
)
119110
t.end()
@@ -164,37 +155,27 @@ t.test('too many args', t => {
164155
unpublish.exec(['a', 'b'], err => {
165156
t.match(
166157
err,
167-
/usage instructions/,
158+
/Usage: npm unpublish/,
168159
'should throw usage instructions if too many args'
169160
)
170161
t.end()
171162
})
172163
})
173164

174165
t.test('unpublish <pkg>@version', t => {
175-
t.plan(7)
176-
177-
const pa = {
178-
name: 'pkg',
179-
rawSpec: '1.0.0',
180-
type: 'version',
181-
}
182-
183-
const npmlog = {
166+
npm.log = {
184167
silly (title, key, value) {
185168
t.equal(title, 'unpublish', 'should silly log args')
186169
if (key === 'spec')
187-
t.equal(value, pa, 'should log parsed npa object')
170+
t.match(value, { name: 'pkg', rawSpec: '1.0.0' })
188171
else
189172
t.equal(value, 'pkg@1.0.0', 'should log originally passed arg')
190173
},
191174
}
192175

193-
const npa = () => pa
194-
195176
const libnpmpublish = {
196177
unpublish (spec, opts) {
197-
t.equal(spec, pa, 'should unpublish expected parsed spec')
178+
t.equal(spec.raw, 'pkg@1.0.0', 'should unpublish expected parsed spec')
198179
t.same(
199180
opts,
200181
{},
@@ -205,9 +186,7 @@ t.test('unpublish <pkg>@version', t => {
205186

206187
const Unpublish = t.mock('../../lib/unpublish.js', {
207188
...mocks,
208-
npmlog,
209189
libnpmpublish,
210-
'npm-package-arg': npa,
211190
})
212191
const unpublish = new Unpublish(npm)
213192

@@ -220,25 +199,22 @@ t.test('unpublish <pkg>@version', t => {
220199
'- pkg@1.0.0',
221200
'should output removed pkg@version on success'
222201
)
202+
t.end()
223203
})
224204
})
225205

226206
t.test('no version found in package.json', t => {
227207
config.force = true
228208

229-
const npa = () => ({
230-
name: 'pkg',
231-
type: 'version',
209+
const testDir = t.testdir({
210+
'package.json': JSON.stringify({
211+
name: 'pkg',
212+
}, null, 2),
232213
})
233-
234-
npa.resolve = () => ''
214+
npm.localPrefix = testDir
235215

236216
const Unpublish = t.mock('../../lib/unpublish.js', {
237217
...mocks,
238-
'npm-package-arg': npa,
239-
'read-package-json': (path, cb) => cb(null, {
240-
name: 'pkg',
241-
}),
242218
})
243219
const unpublish = new Unpublish(npm)
244220

@@ -260,11 +236,6 @@ t.test('unpublish <pkg> --force no version set', t => {
260236

261237
const Unpublish = t.mock('../../lib/unpublish.js', {
262238
...mocks,
263-
'npm-package-arg': () => ({
264-
name: 'pkg',
265-
rawSpec: '',
266-
type: 'tag',
267-
}),
268239
})
269240
const unpublish = new Unpublish(npm)
270241

@@ -284,28 +255,127 @@ t.test('unpublish <pkg> --force no version set', t => {
284255
t.test('silent', t => {
285256
config.loglevel = 'silent'
286257

287-
const npa = () => ({
288-
name: 'pkg',
289-
rawSpec: '1.0.0',
290-
type: 'version',
258+
const Unpublish = t.mock('../../lib/unpublish.js', {
259+
...mocks,
291260
})
261+
const unpublish = new Unpublish(npm)
262+
263+
unpublish.exec(['pkg@1.0.0'], err => {
264+
if (err)
265+
throw err
292266

293-
npa.resolve = () => ''
267+
t.equal(
268+
result,
269+
'',
270+
'should have no output'
271+
)
272+
t.end()
273+
})
274+
})
294275

276+
t.test('workspaces', t => {
277+
const testDir = t.testdir({
278+
'package.json': JSON.stringify({
279+
name: 'my-cool-pkg',
280+
version: '1.0.0',
281+
workspaces: ['workspace-a', 'workspace-b', 'workspace-c'],
282+
}, null, 2),
283+
'workspace-a': {
284+
'package.json': JSON.stringify({
285+
name: 'workspace-a',
286+
version: '1.2.3-a',
287+
repository: 'http://repo.workspace-a/',
288+
}),
289+
},
290+
'workspace-b': {
291+
'package.json': JSON.stringify({
292+
name: 'workspace-b',
293+
version: '1.2.3-n',
294+
repository: 'https://github.com/npm/workspace-b',
295+
}),
296+
},
297+
'workspace-c': {
298+
'package.json': JSON.stringify({
299+
name: 'workspace-n',
300+
version: '1.2.3-n',
301+
}),
302+
},
303+
})
295304
const Unpublish = t.mock('../../lib/unpublish.js', {
296305
...mocks,
297-
'npm-package-arg': npa,
298306
})
299307
const unpublish = new Unpublish(npm)
300308

309+
t.test('no force', (t) => {
310+
npm.localPrefix = testDir
311+
unpublish.execWorkspaces([], [], (err) => {
312+
t.match(err, /--force/, 'should require force')
313+
t.end()
314+
})
315+
})
316+
317+
t.test('all workspaces --force', (t) => {
318+
npm.localPrefix = testDir
319+
config.force = true
320+
unpublish.execWorkspaces([], [], (err) => {
321+
t.notOk(err)
322+
t.matchSnapshot(result, 'should output all workspaces')
323+
t.end()
324+
})
325+
})
326+
327+
t.test('one workspace --force', (t) => {
328+
npm.localPrefix = testDir
329+
config.force = true
330+
unpublish.execWorkspaces([], ['workspace-a'], (err) => {
331+
t.notOk(err)
332+
t.matchSnapshot(result, 'should output one workspaces')
333+
t.end()
334+
})
335+
})
336+
t.end()
337+
})
338+
339+
t.test('dryRun with spec', (t) => {
340+
config['dry-run'] = true
341+
const Unpublish = t.mock('../../lib/unpublish.js', {
342+
...mocks,
343+
libnpmpublish: { unpublish: () => {
344+
throw new Error('should not be called')
345+
} },
346+
})
347+
const unpublish = new Unpublish(npm)
301348
unpublish.exec(['pkg@1.0.0'], err => {
302349
if (err)
303350
throw err
304351

305352
t.equal(
306353
result,
307-
'',
308-
'should have no output'
354+
'- pkg@1.0.0',
355+
'should output removed pkg@version on success'
356+
)
357+
t.end()
358+
})
359+
})
360+
361+
t.test('dryRun with local package', (t) => {
362+
config['dry-run'] = true
363+
config.force = true
364+
const Unpublish = t.mock('../../lib/unpublish.js', {
365+
...mocks,
366+
libnpmpublish: { unpublish: () => {
367+
throw new Error('should not be called')
368+
} },
369+
})
370+
const unpublish = new Unpublish(npm)
371+
unpublish.exec([], err => {
372+
if (err)
373+
throw err
374+
375+
t.equal(
376+
result,
377+
'- pkg@1.0.0',
378+
'should output removed pkg@1.0.0 on success'
309379
)
310380
t.end()
311381
})
@@ -331,7 +401,6 @@ t.test('completion', async t => {
331401
}
332402
},
333403
},
334-
'npm-package-arg': require('npm-package-arg'),
335404
'npm-registry-fetch': {
336405
async json () {
337406
return {
@@ -369,7 +438,6 @@ t.test('completion', async t => {
369438
}
370439
},
371440
},
372-
'npm-package-arg': require('npm-package-arg'),
373441
'npm-registry-fetch': {
374442
async json () {
375443
return {
@@ -403,7 +471,6 @@ t.test('completion', async t => {
403471
}
404472
},
405473
},
406-
'npm-package-arg': require('npm-package-arg'),
407474
})
408475
const unpublish = new Unpublish(npm)
409476

0 commit comments

Comments
 (0)
Please sign in to comment.