Skip to content

Commit 4dfc7d2

Browse files
authored
fix: pass strings to JSON.stringify in --json mode (#7540)
In refactoring this behavior previously plain strings were no longer being passed through JSON.stringify even in json mode. This commit changes that to the previous behavior which fixes the bug in `npm view`. This also required changing the behavior of `npm pkg` which relied on this. Fixes #7537
1 parent 3cefdf6 commit 4dfc7d2

File tree

4 files changed

+38
-31
lines changed

4 files changed

+38
-31
lines changed

lib/commands/pkg.js

+7-11
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,24 @@ class Pkg extends BaseCommand {
5959
this.npm.config.set('json', true)
6060
const pkgJson = await PackageJson.load(path)
6161

62-
let unwrap = false
6362
let result = pkgJson.content
6463

6564
if (args.length) {
6665
result = new Queryable(result).query(args)
6766
// in case there's only a single result from the query
6867
// just prints that one element to stdout
68+
// TODO(BREAKING_CHANGE): much like other places where we unwrap single
69+
// item arrays this should go away. it makes the behavior unknown for users
70+
// who don't already know the shape of the data.
6971
if (Object.keys(result).length === 1) {
70-
unwrap = true
7172
result = result[args]
7273
}
7374
}
7475

75-
if (workspace) {
76-
// workspaces are always json
77-
output.buffer({ [workspace]: result })
78-
} else {
79-
// if the result was unwrapped, stringify as json which will add quotes around strings
80-
// TODO: https://github.com/npm/cli/issues/5508 a raw mode has been requested similar
81-
// to jq -r. If that was added then it would conditionally not call JSON.stringify here
82-
output.buffer(unwrap ? JSON.stringify(result) : result)
83-
}
76+
// The display layer is responsible for calling JSON.stringify on the result
77+
// TODO: https://github.com/npm/cli/issues/5508 a raw mode has been requested similar
78+
// to jq -r. If that was added then this method should no longer set `json:true` all the time
79+
output.buffer(workspace ? { [workspace]: result } : result)
8480
}
8581

8682
async set (args, { path }) {

lib/utils/display.js

+25-19
Original file line numberDiff line numberDiff line change
@@ -82,26 +82,31 @@ const ERROR_KEY = 'error'
8282
// is a json error that should be merged into the finished output
8383
const JSON_ERROR_KEY = 'jsonError'
8484

85+
const isPlainObject = (v) => v && typeof v === 'object' && !Array.isArray(v)
86+
8587
const getArrayOrObject = (items) => {
86-
// Arrays cant be merged, if the first item is an array return that
87-
if (Array.isArray(items[0])) {
88-
return items[0]
89-
}
90-
// We use objects with 0,1,2,etc keys to merge array
91-
if (items.length && items.every((o, i) => Object.hasOwn(o, i))) {
92-
return Object.assign([], ...items)
88+
if (items.length) {
89+
const foundNonObject = items.find(o => !isPlainObject(o))
90+
// Non-objects and arrays cant be merged, so just return the first item
91+
if (foundNonObject) {
92+
return foundNonObject
93+
}
94+
// We use objects with 0,1,2,etc keys to merge array
95+
if (items.every((o, i) => Object.hasOwn(o, i))) {
96+
return Object.assign([], ...items)
97+
}
9398
}
94-
// Otherwise its an object with all items merged together
95-
return Object.assign({}, ...items)
99+
// Otherwise its an object with all object items merged together
100+
return Object.assign({}, ...items.filter(o => isPlainObject(o)))
96101
}
97102

98-
const mergeJson = ({ [JSON_ERROR_KEY]: metaError }, buffer) => {
103+
const getJsonBuffer = ({ [JSON_ERROR_KEY]: metaError }, buffer) => {
99104
const items = []
100105
// meta also contains the meta object passed to flush
101106
const errors = metaError ? [metaError] : []
102107
// index 1 is the meta, 2 is the logged argument
103108
for (const [, { [JSON_ERROR_KEY]: error }, obj] of buffer) {
104-
if (obj && typeof obj === 'object') {
109+
if (obj) {
105110
items.push(obj)
106111
}
107112
if (error) {
@@ -113,13 +118,12 @@ const mergeJson = ({ [JSON_ERROR_KEY]: metaError }, buffer) => {
113118
return null
114119
}
115120

116-
// If all items are keyed with array indexes, then we return the
117-
// array. This skips any error checking since we cant really set
118-
// an error property on an array in a way that can be stringified
119-
// XXX(BREAKING_CHANGE): remove this in favor of always returning an object
120121
const res = getArrayOrObject(items)
121122

122-
if (!Array.isArray(res) && errors.length) {
123+
// This skips any error checking since we can only set an error property
124+
// on an object that can be stringified
125+
// XXX(BREAKING_CHANGE): remove this in favor of always returning an object with result and error keys
126+
if (isPlainObject(res) && errors.length) {
123127
// This is not ideal. JSON output has always been keyed at the root with an `error`
124128
// key, so we cant change that without it being a breaking change. At the same time
125129
// some commands output arbitrary keys at the top level of the output, such as package
@@ -292,9 +296,11 @@ class Display {
292296
switch (level) {
293297
case output.KEYS.flush: {
294298
this.#outputState.buffering = false
295-
const json = this.#json ? mergeJson(meta, this.#outputState.buffer) : null
296-
if (json) {
297-
this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2))
299+
if (this.#json) {
300+
const json = getJsonBuffer(meta, this.#outputState.buffer)
301+
if (json) {
302+
this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2))
303+
}
298304
} else {
299305
this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
300306
}

test/lib/cli/exit-handler.js

-1
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,6 @@ t.test('merges output buffers errors with --json', async (t) => {
277277

278278
output.buffer({ output_data: 1 })
279279
output.buffer({ more_data: 2 })
280-
output.buffer('not json, will be ignored')
281280

282281
await exitHandler()
283282

test/lib/commands/view.js

+6
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,12 @@ t.test('package with --json and no versions', async t => {
406406
t.equal(joinedOutput(), '', 'no info to display')
407407
})
408408

409+
t.test('package with --json and single string arg', async t => {
410+
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })
411+
await view.exec(['blue', 'dist-tags.latest'])
412+
t.equal(JSON.parse(joinedOutput()), '1.0.0', 'no info to display')
413+
})
414+
409415
t.test('package with single version', async t => {
410416
t.test('full json', async t => {
411417
const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } })

0 commit comments

Comments
 (0)