Skip to content

Commit b54cdb8

Browse files
authored
fix(refactor): create new error output primitives (#7515)
These will be used to generate normal and json error messages in the same format from both commands and the exit handler. This also does a few others things: - makes `did-you-mean` take a package so it can be sync and called more easily from the error handlers - standardize all error messages with 2 space indentation to match the rest of npm
1 parent e40454c commit b54cdb8

File tree

11 files changed

+408
-387
lines changed

11 files changed

+408
-387
lines changed

lib/commands/run-script.js

+7-5
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,13 @@ class RunScript extends BaseCommand {
7474
return
7575
}
7676

77-
const didYouMean = require('../utils/did-you-mean.js')
78-
const suggestions = await didYouMean(path, event)
79-
throw new Error(
80-
`Missing script: "${event}"${suggestions}\n\nTo see a list of scripts, run:\n npm run`
81-
)
77+
const suggestions = require('../utils/did-you-mean.js')(pkg, event)
78+
throw new Error([
79+
`Missing script: "${event}"`,
80+
suggestions,
81+
'To see a list of scripts, run:',
82+
' npm run',
83+
].join('\n'))
8284
}
8385

8486
// positional args only added to the main event, not pre/post

lib/npm.js

+17-77
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const { log, time, output, META } = require('proc-log')
1111
const { redactLog: replaceInfo } = require('@npmcli/redact')
1212
const pkg = require('../package.json')
1313
const { deref } = require('./utils/cmd-list.js')
14+
const { jsonError, outputError } = require('./utils/output-error.js')
1415

1516
class Npm {
1617
static get version () {
@@ -287,110 +288,49 @@ class Npm {
287288

288289
async #handleError (err) {
289290
if (err) {
290-
Object.assign(err, await this.#getError(err))
291+
// Get the local package if it exists for a more helpful error message
292+
const localPkg = await require('@npmcli/package-json')
293+
.normalize(this.localPrefix)
294+
.then(p => p.content)
295+
.catch(() => null)
296+
Object.assign(err, this.#getError(err, { pkg: localPkg }))
291297
}
292298

293299
// TODO: make this not need to be public
294300
this.finish()
295301

296302
output.flush({
297303
[META]: true,
298-
jsonError: err && this.loaded && this.config.get('json') ? {
299-
code: err.code,
300-
summary: (err.summary || []).map(l => l.slice(1).join(' ')).join('\n'),
301-
detail: (err.detail || []).map(l => l.slice(1).join(' ')).join('\n'),
302-
} : null,
304+
jsonError: jsonError(err, this),
303305
})
304306

305307
if (err) {
306308
throw err
307309
}
308310
}
309311

310-
async #getError (err) {
311-
const { errorMessage, getExitCodeFromError } = require('./utils/error-message.js')
312-
313-
// if we got a command that just shells out to something else, then it
314-
// will presumably print its own errors and exit with a proper status
315-
// code if there's a problem. If we got an error with a code=0, then...
316-
// something else went wrong along the way, so maybe an npm problem?
317-
if (this.#command?.constructor?.isShellout && typeof err.code === 'number' && err.code) {
318-
return {
319-
exitCode: err.code,
320-
suppressError: true,
321-
}
322-
}
323-
324-
// XXX: we should stop throwing strings
325-
if (typeof err === 'string') {
326-
log.error('', err)
327-
return {
328-
exitCode: 1,
329-
suppressError: true,
330-
}
331-
}
332-
333-
// XXX: we should stop throwing other non-errors
334-
if (!(err instanceof Error)) {
335-
log.error('weird error', err)
336-
return {
337-
exitCode: 1,
338-
suppressError: true,
339-
}
340-
}
341-
342-
if (err.code === 'EUNKNOWNCOMMAND') {
343-
const didYouMean = require('./utils/did-you-mean.js')
344-
const suggestions = await didYouMean(this.localPrefix, err.command)
345-
output.standard(`Unknown command: "${err.command}"${suggestions}\n`)
346-
output.standard('To see a list of supported npm commands, run:\n npm help')
347-
return {
348-
exitCode: 1,
349-
suppressError: true,
350-
}
351-
}
352-
353-
err.code ??= err.message.match(/^(?:Error: )?(E[A-Z]+)/)?.[1]
354-
355-
for (const k of ['type', 'stack', 'statusCode', 'pkgid']) {
356-
const v = err[k]
357-
if (v) {
358-
log.verbose(k, replaceInfo(v))
359-
}
360-
}
361-
362-
const exitCode = getExitCodeFromError(err) || 1
363-
const { summary, detail, files } = errorMessage(err, this)
312+
#getError (rawErr, opts) {
313+
const { files = [], ...error } = require('./utils/error-message.js').getError(rawErr, {
314+
npm: this,
315+
command: this.#command,
316+
...opts,
317+
})
364318

365319
const { writeFileSync } = require('node:fs')
366320
for (const [file, content] of files) {
367321
const filePath = `${this.logPath}${file}`
368322
const fileContent = `'Log files:\n${this.logFiles.join('\n')}\n\n${content.trim()}\n`
369323
try {
370324
writeFileSync(filePath, fileContent)
371-
detail.push(['', `\n\nFor a full report see:\n${filePath}`])
325+
error.detail.push(['', `\n\nFor a full report see:\n${filePath}`])
372326
} catch (fileErr) {
373327
log.warn('', `Could not write error message to ${file} due to ${fileErr}`)
374328
}
375329
}
376330

377-
for (const k of ['code', 'syscall', 'file', 'path', 'dest', 'errno']) {
378-
const v = err[k]
379-
if (v) {
380-
log.error(k, v)
381-
}
382-
}
331+
outputError(error)
383332

384-
for (const errline of [...summary, ...detail]) {
385-
log.error(...errline)
386-
}
387-
388-
return {
389-
exitCode,
390-
summary,
391-
detail,
392-
suppressError: false,
393-
}
333+
return error
394334
}
395335

396336
get title () {

lib/utils/did-you-mean.js

+22-27
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,34 @@
11
const Npm = require('../npm')
22
const { distance } = require('fastest-levenshtein')
3-
const pkgJson = require('@npmcli/package-json')
43
const { commands } = require('./cmd-list.js')
54

6-
const didYouMean = async (path, scmd) => {
7-
const close = commands.filter(cmd => distance(scmd, cmd) < scmd.length * 0.4 && scmd !== cmd)
8-
let best = []
9-
for (const str of close) {
10-
const cmd = Npm.cmd(str)
11-
best.push(` npm ${str} # ${cmd.description}`)
12-
}
13-
// We would already be suggesting this in `npm x` so omit them here
14-
const runScripts = ['stop', 'start', 'test', 'restart']
15-
try {
16-
const { content: { scripts, bin } } = await pkgJson.normalize(path)
17-
best = best.concat(
18-
Object.keys(scripts || {})
19-
.filter(cmd => distance(scmd, cmd) < scmd.length * 0.4 && !runScripts.includes(cmd))
20-
.map(str => ` npm run ${str} # run the "${str}" package script`),
21-
Object.keys(bin || {})
22-
.filter(cmd => distance(scmd, cmd) < scmd.length * 0.4)
23-
/* eslint-disable-next-line max-len */
24-
.map(str => ` npm exec ${str} # run the "${str}" command from either this or a remote npm package`)
25-
)
26-
} catch {
27-
// gracefully ignore not being in a folder w/ a package.json
28-
}
5+
const runScripts = ['stop', 'start', 'test', 'restart']
6+
7+
const isClose = (scmd, cmd) => distance(scmd, cmd) < scmd.length * 0.4
8+
9+
const didYouMean = (pkg, scmd) => {
10+
const { scripts = {}, bin = {} } = pkg || {}
11+
12+
const best = [
13+
...commands
14+
.filter(cmd => isClose(scmd, cmd) && scmd !== cmd)
15+
.map(str => [str, Npm.cmd(str).description]),
16+
...Object.keys(scripts)
17+
// We would already be suggesting this in `npm x` so omit them here
18+
.filter(cmd => isClose(scmd, cmd) && !runScripts.includes(cmd))
19+
.map(str => [`run ${str}`, `run the "${str}" package script`]),
20+
...Object.keys(bin)
21+
.filter(cmd => isClose(scmd, cmd))
22+
/* eslint-disable-next-line max-len */
23+
.map(str => [`exec ${str}`, `run the "${str}" command from either this or a remote npm package`]),
24+
]
2925

3026
if (best.length === 0) {
3127
return ''
3228
}
3329

34-
return best.length === 1
35-
? `\n\nDid you mean this?\n${best[0]}`
36-
: `\n\nDid you mean one of these?\n${best.slice(0, 3).join('\n')}`
30+
return `\n\nDid you mean ${best.length === 1 ? 'this' : 'one of these'}?\n` +
31+
best.slice(0, 3).map(([msg, comment]) => ` npm ${msg} # ${comment}`).join('\n')
3732
}
3833

3934
module.exports = didYouMean

lib/utils/display.js

+57-13
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const tryJsonParse = (value) => {
7070
try {
7171
return JSON.parse(value)
7272
} catch {
73-
return {}
73+
return
7474
}
7575
}
7676
return value
@@ -86,6 +86,57 @@ const setBlocking = (stream) => {
8686
return stream
8787
}
8888

89+
// These are important
90+
// This is the key that is returned to the user for errors
91+
const ERROR_KEY = 'error'
92+
// This is the key producers use to indicate that there
93+
// is a json error that should be merged into the finished output
94+
const JSON_ERROR_KEY = 'jsonError'
95+
96+
const mergeJson = (meta, buffer) => {
97+
const buffered = buffer.reduce((acc, i) => {
98+
// index 2 is the logged argument
99+
acc[0].push(tryJsonParse(i[2]))
100+
// index 1 is the meta object
101+
acc[1].push(i[1][JSON_ERROR_KEY])
102+
return acc
103+
}, [
104+
// meta also contains the meta object passed to flush
105+
[], [meta[JSON_ERROR_KEY]],
106+
])
107+
108+
const items = buffered[0].filter(Boolean)
109+
const errors = buffered[1].filter(Boolean)
110+
111+
// If all items are keyed with array indexes, then we return the
112+
// array. This skips any error checking since we cant really set
113+
// an error property on an array in a way that can be stringified
114+
// XXX(BREAKING_CHANGE): remove this in favor of always returning an object
115+
/* istanbul ignore next - premature optimization for a PR that comes next */
116+
if (items.length && items.every((o, i) => Object.hasOwn(o, i))) {
117+
return Object.assign([], ...items)
118+
}
119+
120+
const res = Object.assign({}, ...items)
121+
122+
if (errors.length) {
123+
// This is not ideal. JSON output has always been keyed at the root with an `error`
124+
// key, so we cant change that without it being a breaking change. At the same time
125+
// some commands output arbitrary keys at the top level of the output, such as package
126+
// names. So the output could already have the same key. The choice here is to overwrite
127+
// it with our error since that is (probably?) more important.
128+
// XXX(BREAKING_CHANGE): all json output should be keyed under well known keys, eg `result` and `error`
129+
/* istanbul ignore next */
130+
if (res[ERROR_KEY]) {
131+
log.warn('display', `overwriting existing ${ERROR_KEY} on json output`)
132+
}
133+
res[ERROR_KEY] = Object.assign({}, ...errors)
134+
}
135+
136+
// Only write output if we have some json buffered
137+
return Object.keys(res).length ? res : null
138+
}
139+
89140
const withMeta = (handler) => (level, ...args) => {
90141
let meta = {}
91142
const last = args.at(-1)
@@ -240,24 +291,17 @@ class Display {
240291
// directly as a listener and still reference "this"
241292
#outputHandler = withMeta((level, meta, ...args) => {
242293
switch (level) {
243-
case output.KEYS.flush:
294+
case output.KEYS.flush: {
244295
this.#outputState.buffering = false
245-
if (meta.jsonError && this.#json) {
246-
const json = {}
247-
for (const item of this.#outputState.buffer) {
248-
// index 2 skips the level and meta
249-
Object.assign(json, tryJsonParse(item[2]))
250-
}
251-
this.#writeOutput(
252-
output.KEYS.standard,
253-
meta,
254-
JSON.stringify({ ...json, error: meta.jsonError }, null, 2)
255-
)
296+
const json = this.#json ? mergeJson(meta, this.#outputState.buffer) : null
297+
if (json) {
298+
this.#writeOutput(output.KEYS.standard, meta, JSON.stringify(json, null, 2))
256299
} else {
257300
this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
258301
}
259302
this.#outputState.buffer.length = 0
260303
break
304+
}
261305

262306
case output.KEYS.buffer:
263307
this.#outputState.buffer.push([output.KEYS.standard, meta, ...args])

0 commit comments

Comments
 (0)