Skip to content

Commit 8b03990

Browse files
committed
fix: pass strings to JSON.stringify in --json mode
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 8b03990

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)