UNPKG

7.92 kBPlain TextView Raw
1// external
2import * as ansi from '@bevry/ansi'
3import figures from '@bevry/figures'
4import versionClean from 'version-clean'
5
6// local
7import {
8 runCommand,
9 runVersion,
10 runInstall,
11 uniq,
12 trim,
13 loadVersion,
14 lastLine,
15} from './util.js'
16
17export type Status =
18 | 'pending'
19 | 'loading'
20 | 'missing'
21 | 'failed'
22 | 'loaded'
23 | 'installing'
24 | 'installed'
25 | 'running'
26 | 'passed'
27
28export type Row = [
29 icon: string,
30 version_or_alias: string,
31 rendered_status: string,
32 duration: string,
33]
34
35function getTime() {
36 return Date.now()
37}
38
39/** Version */
40export class Version {
41 /** The precise version number, or at least the WIP version number/alias until it is resolved further. */
42 version: string
43
44 /** The list of listeners we will call when updates happen. */
45 listeners: Array<Function> = []
46
47 /** An array of aliases for this version if any were used. */
48 aliases: Array<string> = []
49
50 /** The current status of this version, initially it is `pending`. */
51 status: Status = 'pending'
52
53 /**
54 * The version resolution that was successfully loaded.
55 * For instance, if a nvm alias is used such as "current" which resolves to 18.18.2 which is the system Node.js version, but is not installed via nvm itself, then trying to resolve "18.18.2" will fail with [version "v18.18.2" is not yet installed] but the original "current" resolution will work.
56 */
57 loadedVersion: string | null = null
58
59 /** Whether or not this version has been successful. */
60 success: boolean | null = null
61
62 /** Any error that occurred against this version. */
63 error: Error | null = null
64
65 /** The last stdout value that occurred against this version. */
66 stdout: string | null = null
67
68 /** The last stderr value that occurred against this version. */
69 stderr: string | null = null
70
71 /** The time the run started. */
72 started: number | null = null
73
74 /** The time the run finished. */
75 finished: number | null = null
76
77 /** Cache of the message. */
78 private messageCache: [status: Status, message: string] | null = null
79
80 /** Create our Version instance */
81 constructor(version: string | number, listeners: Array<Function> = []) {
82 this.listeners.push(...listeners)
83 this.version = String(version)
84
85 // If it fails to pass, then it is an alias, not a version
86 if (!versionClean(this.version)) {
87 // this uses a setter to add to this.aliases
88 this.alias = this.version
89 }
90 }
91
92 /** The alias for this version if any were provided. E.g. `system` or `current` */
93 get alias() {
94 return this.aliases[0]
95 }
96 set alias(alias) {
97 if (alias) {
98 const aliases = this.aliases.concat(alias)
99 this.aliases = uniq(aliases)
100 }
101 }
102
103 /** Reset the version state. */
104 reset() {
105 this.success = null
106 this.error = null
107 this.stdout = null
108 this.stderr = null
109 this.started = null
110 this.finished = null
111 this.messageCache = null
112 return this
113 }
114
115 /** Notify that an update has occurred.
116 * @param {string?} status
117 * @returns {this}
118 * @private
119 */
120 async update(status?: Status) {
121 if (status) this.status = status
122 await Promise.all(this.listeners.map((listener) => listener(this)))
123 return this
124 }
125
126 /** Load the version, which resolves the precise version number and determines if it is available or not. */
127 async load() {
128 this.status = 'loading'
129 this.reset()
130 await this.update()
131
132 const result = await loadVersion(this.version)
133 if (result.error) {
134 if ((result.error || '').toString().includes('not yet installed')) {
135 this.status = 'missing'
136 this.success = false
137 } else {
138 this.status = 'failed'
139 this.success = false
140 }
141 this.error = result.error
142 this.stdout = (result.stdout || '').toString()
143 this.stderr = (result.stderr || '').toString()
144 } else {
145 const result = await runVersion(this.version)
146 if (result.error) {
147 this.status = 'failed'
148 this.success = false
149 this.error = result.error
150 this.stdout = (result.stdout || '').toString()
151 this.stderr = (result.stderr || '').toString()
152 } else {
153 this.loadedVersion = this.loadedVersion || this.version
154 this.version = lastLine(result.stdout) // resolve the version
155 this.status = 'loaded'
156 }
157 }
158
159 await this.update()
160 return this
161 }
162
163 /**
164 * Install the version if it was missing.
165 * Requires the current state to be `missing`.
166 */
167 async install() {
168 if (this.status !== 'missing') return this
169
170 this.status = 'installing'
171 this.reset()
172 await this.update()
173
174 const result = await runInstall(this.version)
175 if (result.error) {
176 this.error = result.error
177 this.status = 'missing'
178 this.success = false
179 this.stdout = (result.stdout || '').toString()
180 this.stderr = (result.stderr || '').toString()
181 } else {
182 await this.update('installed')
183 await this.load()
184 }
185
186 return this
187 }
188
189 /**
190 * Run the command against the version.
191 * Requires the current state to be `loaded`.
192 */
193 async test(command: string) {
194 if (!command) {
195 throw new Error('no command provided to the testen version runner')
196 }
197 if (this.status !== 'loaded') return this
198
199 this.status = 'running'
200 this.reset()
201 await this.update()
202
203 this.started = getTime()
204 const result = await runCommand(this.loadedVersion || this.version, command)
205 this.finished = getTime()
206
207 this.error = result.error
208 this.stdout = (result.stdout || '').toString()
209 this.stderr = (result.stderr || '').toString()
210
211 this.success = Boolean(result.error) === false
212
213 await this.update(this.success ? 'passed' : 'failed')
214 return this
215 }
216
217 /**
218 * Converts the version properties into an array for use of displaying in a neat table.
219 * Doesn't cache as we want to refresh timers.
220 */
221 get row(): Row {
222 const indicator =
223 this.success === null
224 ? ansi.dim(figures.circle)
225 : this.success
226 ? ansi.green(figures.tick)
227 : ansi.red(figures.cross)
228
229 const result =
230 this.success === null
231 ? ansi.dim(this.status)
232 : this.success
233 ? ansi.green(this.status)
234 : ansi.red(this.status)
235
236 // note that caching prevents realtime updates of duration time
237 const ms = this.started ? (this.finished || getTime()) - this.started : 0
238 const duration = this.started
239 ? ansi.dim(ms > 1000 ? `${Math.round(ms / 1000)}s` : `${ms}ms`)
240 : ''
241
242 const aliases = this.aliases.length
243 ? ansi.dim(` [${this.aliases.join('|')}]`)
244 : ''
245
246 const row: Row = [
247 ' ' + indicator,
248 this.version + aliases,
249 result,
250 duration,
251 ]
252 return row
253 }
254
255 /**
256 * Converts the version properties a detailed message of what has occurred with this version.
257 * Caches for each status change.
258 * @property {string} message
259 * @public
260 */
261 get message() {
262 // Cache
263 if (this.messageCache && this.messageCache[0] === this.status) {
264 return this.messageCache[1]
265 }
266
267 // Prepare
268 const parts: Array<string> = []
269
270 // fetch heading
271 const heading = `Node version ${ansi.underline(this.version)} ${
272 this.status
273 }`
274 if (this.status === 'missing') {
275 parts.push(ansi.bold(ansi.red(heading)))
276 } else if (this.success === true) {
277 parts.push(ansi.bold(ansi.green(heading)))
278 } else if (this.success === false) {
279 parts.push(ansi.bold(ansi.red(heading)))
280 } else {
281 // running, loading, etc - shown in verbose mode
282 parts.push(ansi.bold(ansi.dim(heading)))
283 }
284
285 // Output the command that was run
286 if (this.error) {
287 parts.push(ansi.red(this.error.message.split('\n')[0]))
288 }
289
290 // Output stdout and stderr
291 if (this.status === 'missing') {
292 parts.push(ansi.red(`You need to run: nvm install ${this.version}`))
293 } else {
294 const stdout = trim(this.stdout || '')
295 const stderr = trim(this.stderr || '')
296 if (!stdout && !stderr) {
297 parts.push(ansi.dim('no output'))
298 } else {
299 if (stdout) {
300 parts.push(stdout)
301 }
302 if (stderr) {
303 parts.push(ansi.red(stderr))
304 }
305 }
306 }
307
308 // Join it all together
309 const message = parts.join('\n')
310
311 // Cache
312 this.messageCache = [this.status, message]
313 return message
314 }
315}
316export default Version