codekingpro/portable-devtools
115k
1const { log, output, META } = require('proc-log')
2const { errorMessage, getExitCodeFromError } = require('../utils/error-message.js')
3
4class ExitHandler {
5 #npm = null
6 #process = null
7 #exited = false
8 #exitErrorMessage = false
9
10 #noNpmError = false
11
12 get #hasNpm () {
13 return !!this.#npm
14 }
15
16 get #loaded () {
17 return !!this.#npm?.loaded
18 }
19
20 get #showExitErrorMessage () {
21 if (!this.#loaded) {
22 return false
23 }
24 if (!this.#exited) {
25 return true
26 }
27 return this.#exitErrorMessage
28 }
29
30 get #notLoadedOrExited () {
31 return !this.#loaded && !this.#exited
32 }
33
34 setNpm (npm) {
35 this.#npm = npm
36 }
37
38 constructor ({ process }) {
39 this.#process = process
40 this.#process.on('exit', this.#handleProcessExitAndReset)
41 }
42
43 registerUncaughtHandlers () {
44 this.#process.on('uncaughtException', this.#handleExit)
45 this.#process.on('unhandledRejection', this.#handleExit)
46 }
47
48 exit (err) {
49 this.#handleExit(err)
50 }
51
52 #handleProcessExitAndReset = (code) => {
53 this.#handleProcessExit(code)
54
55 // Reset all the state. This is only relevant for tests since in reality the process fully exits here.
56 this.#process.off('exit', this.#handleProcessExitAndReset)
57 this.#process.off('uncaughtException', this.#handleExit)
58 this.#process.off('unhandledRejection', this.#handleExit)
59 if (this.#loaded) {
60 this.#npm.unload()
61 }
62 this.#npm = null
63 this.#exited = false
64 this.#exitErrorMessage = false
65 }
66
67 #handleProcessExit (code) {
68 const numCode = Number(code) || 0
69 // Always exit w/ a non-zero code if exit handler was not called
70 const exitCode = this.#exited ? numCode : (numCode || 1)
71 this.#process.exitCode = exitCode
72
73 if (this.#notLoadedOrExited) {
74 // Exit handler was not called and npm was not loaded so we have to log something
75 this.#logConsoleError(new Error(`Process exited unexpectedly with code: ${exitCode}`))
76 return
77 }
78
79 if (this.#logNoNpmError()) {
80 return
81 }
82
83 const os = require('node:os')
84 log.verbose('cwd', this.#process.cwd())
85 log.verbose('os', `${os.type()} ${os.release()}`)
86 log.verbose('node', this.#process.version)
87 log.verbose('npm ', `v${this.#npm.version}`)
88
89 // only show the notification if it finished
90 if (typeof this.#npm.updateNotification === 'string') {
91 log.notice('', this.#npm.updateNotification, { [META]: true, force: true })
92 }
93
94 if (!this.#exited) {
95 log.error('', 'Exit handler never called!')
96 log.error('', 'This is an error with npm itself. Please report this error at:')
97 log.error('', ' <https://github.com/npm/cli/issues>')
98 if (this.#npm.silent) {
99 output.error('')
100 }
101 }
102
103 log.verbose('exit', exitCode)
104
105 if (exitCode) {
106 log.verbose('code', exitCode)
107 } else {
108 log.info('ok')
109 }
110
111 if (this.#showExitErrorMessage) {
112 log.error('', this.#npm.exitErrorMessage())
113 }
114 }
115
116 #logConsoleError (err) {
117 // Run our error message formatters on all errors even if we have no npm or an unloaded npm.
118 // This will clean the error and possible return a formatted message about EACCESS or something.
119 const { summary, detail } = errorMessage(err, this.#npm)
120 const formatted = [...new Set([...summary, ...detail].flat().filter(Boolean))].join('\n')
121 // If we didn't get anything from the formatted message then just display the full stack
122 // eslint-disable-next-line no-console
123 console.error(formatted === err.message ? err.stack : formatted)
124 }
125
126 #logNoNpmError (err) {
127 if (this.#hasNpm) {
128 return false
129 }
130 // Make sure we only log this error once
131 if (!this.#noNpmError) {
132 this.#noNpmError = true
133 this.#logConsoleError(
134 new Error(`Exit prior to setting npm in exit handler`, err ? { cause: err } : {})
135 )
136 }
137 return true
138 }
139
140 #handleExit = (err) => {
141 this.#exited = true
142
143 // No npm at all
144 if (this.#logNoNpmError(err)) {
145 return this.#process.exit(this.#process.exitCode || getExitCodeFromError(err) || 1)
146 }
147
148 // npm was never loaded but we still might have a config loading error or something similar that we can run through the error message formatter to give the user a clue as to what happened.
149 if (!this.#loaded) {
150 this.#logConsoleError(new Error('Exit prior to config file resolving', { cause: err }))
151 return this.#process.exit(this.#process.exitCode || getExitCodeFromError(err) || 1)
152 }
153
154 this.#exitErrorMessage = err?.suppressError === true ? false : !!err
155
156 // Prefer the exit code of the error, then the current process exit code, then set it to 1 if we still have an error.
157 // Otherwise, we call process.exit with undefined so that it can determine the final exit code
158 const exitCode = err?.exitCode ?? this.#process.exitCode ?? (err ? 1 : undefined)
159
160 // explicitly call process.exit now so we don't hang on things like the update notifier
161 // also flush stdout/err beforehand because process.exit doesn't wait for that to happen.
162 this.#process.stderr.write('', () => this.#process.stdout.write('', () => {
163 this.#process.exit(exitCode)
164 }))
165 }
166}
167
168module.exports = ExitHandler
169 