-
Notifications
You must be signed in to change notification settings - Fork 68
/
config.ts
656 lines (545 loc) · 21.1 KB
/
config.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
import {CLIError, error, exit, warn} from '../errors'
import * as ejs from 'ejs'
import * as os from 'os'
import * as path from 'path'
import {fileURLToPath, URL} from 'url'
import {format} from 'util'
import {Options, Plugin as IPlugin} from '../interfaces/plugin'
import {Config as IConfig, ArchTypes, PlatformTypes, LoadOptions} from '../interfaces/config'
import {Command, Hook, Hooks, PJSON, Topic} from '../interfaces'
import * as Plugin from './plugin'
import {Debug, compact, flatMap, loadJSON, uniq, permutations} from './util'
import {isProd} from '../util'
import ModuleLoader from '../module-loader'
// eslint-disable-next-line new-cap
const debug = Debug()
const _pjson = require('../../package.json')
function channelFromVersion(version: string) {
const m = version.match(/[^-]+(?:-([^.]+))?/)
return (m && m[1]) || 'stable'
}
const WSL = require('is-wsl')
function isConfig(o: any): o is IConfig {
return o && Boolean(o._base)
}
export class Config implements IConfig {
_base = `${_pjson.name}@${_pjson.version}`
name!: string
version!: string
channel!: string
root!: string
arch!: ArchTypes
bin!: string
cacheDir!: string
configDir!: string
dataDir!: string
dirname!: string
errlog!: string
home!: string
platform!: PlatformTypes
shell!: string
windows!: boolean
userAgent!: string
debug = 0
npmRegistry?: string
pjson!: PJSON.CLI
userPJSON?: PJSON.User
plugins: IPlugin[] = []
binPath?: string
valid!: boolean
topicSeparator: ':' | ' ' = ':'
flexibleTaxonomy!: boolean
protected warned = false
private _commands?: Command.Plugin[]
private _commandIDs?: string[]
private _topics?: Topic[]
// eslint-disable-next-line no-useless-constructor
constructor(public options: Options) {}
static async load(opts: LoadOptions = (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname) {
// Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path.
if (typeof opts === 'string' && opts.startsWith('file://')) {
opts = fileURLToPath(opts)
}
if (typeof opts === 'string') opts = {root: opts}
if (isConfig(opts)) return opts
const config = new Config(opts)
await config.load()
return config
}
// eslint-disable-next-line complexity
async load() {
const plugin = new Plugin.Plugin({root: this.options.root})
await plugin.load()
this.plugins.push(plugin)
this.root = plugin.root
this.pjson = plugin.pjson
this.name = this.pjson.name
this.version = this.options.version || this.pjson.version || '0.0.0'
this.channel = this.options.channel || channelFromVersion(this.version)
this.valid = plugin.valid
this.arch = (os.arch() === 'ia32' ? 'x86' : os.arch() as any)
this.platform = WSL ? 'wsl' : os.platform() as any
this.windows = this.platform === 'win32'
this.bin = this.pjson.oclif.bin || this.name
this.dirname = this.pjson.oclif.dirname || this.name
this.flexibleTaxonomy = this.pjson.oclif.flexibleTaxonomy || false
// currently, only colons or spaces are valid separators
if (this.pjson.oclif.topicSeparator && [':', ' '].includes(this.pjson.oclif.topicSeparator)) this.topicSeparator = this.pjson.oclif.topicSeparator!
if (this.platform === 'win32') this.dirname = this.dirname.replace('/', '\\')
this.userAgent = `${this.name}/${this.version} ${this.platform}-${this.arch} node-${process.version}`
this.shell = this._shell()
this.debug = this._debug()
this.home = process.env.HOME || (this.windows && this.windowsHome()) || os.homedir() || os.tmpdir()
this.cacheDir = this.scopedEnvVar('CACHE_DIR') || this.macosCacheDir() || this.dir('cache')
this.configDir = this.scopedEnvVar('CONFIG_DIR') || this.dir('config')
this.dataDir = this.scopedEnvVar('DATA_DIR') || this.dir('data')
this.errlog = path.join(this.cacheDir, 'error.log')
this.binPath = this.scopedEnvVar('BINPATH')
this.npmRegistry = this.scopedEnvVar('NPM_REGISTRY') || this.pjson.oclif.npmRegistry
this.pjson.oclif.update = this.pjson.oclif.update || {}
this.pjson.oclif.update.node = this.pjson.oclif.update.node || {}
const s3 = this.pjson.oclif.update.s3 || {}
this.pjson.oclif.update.s3 = s3
s3.bucket = this.scopedEnvVar('S3_BUCKET') || s3.bucket
if (s3.bucket && !s3.host) s3.host = `https://${s3.bucket}.s3.amazonaws.com`
s3.templates = {
...s3.templates,
target: {
baseDir: '<%- bin %>',
unversioned: "<%- channel === 'stable' ? '' : 'channels/' + channel + '/' %><%- bin %>-<%- platform %>-<%- arch %><%- ext %>",
versioned: "<%- channel === 'stable' ? '' : 'channels/' + channel + '/' %><%- bin %>-v<%- version %>/<%- bin %>-v<%- version %>-<%- platform %>-<%- arch %><%- ext %>",
manifest: "<%- channel === 'stable' ? '' : 'channels/' + channel + '/' %><%- platform %>-<%- arch %>",
...s3.templates && s3.templates.target,
},
vanilla: {
unversioned: "<%- channel === 'stable' ? '' : 'channels/' + channel + '/' %><%- bin %><%- ext %>",
versioned: "<%- channel === 'stable' ? '' : 'channels/' + channel + '/' %><%- bin %>-v<%- version %>/<%- bin %>-v<%- version %><%- ext %>",
baseDir: '<%- bin %>',
manifest: "<%- channel === 'stable' ? '' : 'channels/' + channel + '/' %>version",
...s3.templates && s3.templates.vanilla,
},
}
await this.loadUserPlugins()
await this.loadDevPlugins()
await this.loadCorePlugins()
debug('config done')
}
async loadCorePlugins() {
if (this.pjson.oclif.plugins) {
await this.loadPlugins(this.root, 'core', this.pjson.oclif.plugins)
}
}
async loadDevPlugins() {
// do not load oclif.devPlugins in production
if (this.isProd) return
try {
const devPlugins = this.pjson.oclif.devPlugins
if (devPlugins) await this.loadPlugins(this.root, 'dev', devPlugins)
} catch (error: any) {
process.emitWarning(error)
}
}
async loadUserPlugins() {
if (this.options.userPlugins !== false) {
try {
const userPJSONPath = path.join(this.dataDir, 'package.json')
debug('reading user plugins pjson %s', userPJSONPath)
const pjson = await loadJSON(userPJSONPath)
this.userPJSON = pjson
if (!pjson.oclif) pjson.oclif = {schema: 1}
if (!pjson.oclif.plugins) pjson.oclif.plugins = []
await this.loadPlugins(userPJSONPath, 'user', pjson.oclif.plugins.filter((p: any) => p.type === 'user'))
await this.loadPlugins(userPJSONPath, 'link', pjson.oclif.plugins.filter((p: any) => p.type === 'link'))
} catch (error: any) {
if (error.code !== 'ENOENT') process.emitWarning(error)
}
}
}
async runHook<T extends keyof Hooks>(event: T, opts: Hooks[T]['options'], timeout?: number): Promise<Hook.Result<Hooks[T]['return']>> {
debug('start %s hook', event)
const search = (m: any): Hook<T> => {
if (typeof m === 'function') return m
if (m.default && typeof m.default === 'function') return m.default
return Object.values(m).find((m: any) => typeof m === 'function') as Hook<T>
}
const withTimeout = async (ms: number, promise: any) => {
let id: NodeJS.Timeout
const timeout = new Promise((_, reject) => {
id = setTimeout(() => {
reject(new Error(`Timed out after ${ms} ms.`))
}, ms).unref()
})
return Promise.race([promise, timeout]).then(result => {
clearTimeout(id)
return result
})
}
const final = {
successes: [],
failures: [],
} as Hook.Result<Hooks[T]['return']>
const promises = this.plugins.map(async p => {
const debug = require('debug')([this.bin, p.name, 'hooks', event].join(':'))
const context: Hook.Context = {
config: this,
debug,
exit(code = 0) {
exit(code)
},
log(message?: any, ...args: any[]) {
process.stdout.write(format(message, ...args) + '\n')
},
error(message, options: { code?: string; exit?: number } = {}) {
error(message, options)
},
warn(message: string) {
warn(message)
},
}
const hooks = p.hooks[event] || []
for (const hook of hooks) {
try {
/* eslint-disable no-await-in-loop */
const {isESM, module, filePath} = await ModuleLoader.loadWithData(p, hook)
debug('start', isESM ? '(import)' : '(require)', filePath)
const result = timeout ?
await withTimeout(timeout, search(module).call(context, {...opts as any, config: this})) :
await search(module).call(context, {...opts as any, config: this})
final.successes.push({plugin: p, result})
debug('done')
} catch (error: any) {
final.failures.push({plugin: p, error: error as Error})
debug(error)
}
}
})
await Promise.all(promises)
debug('%s hook done', event)
return final
}
// eslint-disable-next-line default-param-last
async runCommand<T = unknown>(id: string, argv: string[] = [], cachedCommand?: Command.Plugin): Promise<T> {
debug('runCommand %s %o', id, argv)
const c = cachedCommand || this.findCommand(id)
if (!c) {
const hookResult = await this.runHook('command_not_found', {id, argv})
if (hookResult.successes[0]) {
const cmdResult = hookResult.successes[0].result
return cmdResult as T
}
throw new CLIError(`command ${id} not found`)
}
const command = await c.load()
await this.runHook('prerun', {Command: command, argv})
const result = (await command.run(argv, this)) as T
await this.runHook('postrun', {Command: command, result: result, argv})
return result
}
scopedEnvVar(k: string) {
return process.env[this.scopedEnvVarKey(k)]
}
scopedEnvVarTrue(k: string): boolean {
const v = process.env[this.scopedEnvVarKey(k)]
return v === '1' || v === 'true'
}
scopedEnvVarKey(k: string) {
return [this.bin, k]
.map(p => p.replace(/@/g, '').replace(/[/-]/g, '_'))
.join('_')
.toUpperCase()
}
findCommand(id: string, opts: { must: true }): Command.Plugin
findCommand(id: string, opts?: { must: boolean }): Command.Plugin | undefined
/**
* This function is responsible for locating the correct plugin to use for a named command id
* It searches the {Config} registered commands to match either the raw command id or the command alias
* It is possible that more than one command will be found. This is due the ability of two distinct plugins to
* create the same command or command alias.
*
* In the case of more than one found command, the function will select the command based on the order in which
* the plugin is included in the package.json `oclif.plugins` list. The command that occurs first in the list
* is selected as the command to run.
*
* Commands can also be present from either an install or a link. When a command is one of these and a core plugin
* is present, this function defers to the core plugin.
*
* If there is not a core plugin command present, this function will return the first
* plugin as discovered (will not change the order)
* @param id raw command id or command alias
* @param opts options to control if the command must be found
* @returns command instance {Command.Plugin} or undefined
*/
findCommand(id: string, opts: { must?: boolean } = {}): Command.Plugin | undefined {
const commands = this.commands.filter(c => c.id === id || c.aliases.includes(id))
if (opts.must && commands.length === 0) error(`command ${id} not found`)
if (commands.length === 1) return commands[0]
// more than one command found across available plugins
const oclifPlugins = this.pjson.oclif?.plugins ?? []
const commandPlugins = commands.sort((a, b) => {
const pluginAliasA = a.pluginAlias ?? 'A-Cannot-Find-This'
const pluginAliasB = b.pluginAlias ?? 'B-Cannot-Find-This'
const aIndex = oclifPlugins.indexOf(pluginAliasA)
const bIndex = oclifPlugins.indexOf(pluginAliasB)
// When both plugin types are 'core' plugins sort based on index
if (a.pluginType === 'core' && b.pluginType === 'core') {
// If b appears first in the pjson.plugins sort it first
return aIndex - bIndex
}
// if b is a core plugin and a is not sort b first
if (b.pluginType === 'core' && a.pluginType !== 'core') {
return 1
}
// if a is a core plugin and b is not sort a first
if (a.pluginType === 'core' && b.pluginType !== 'core') {
return -1
}
// neither plugin is core, so do not change the order
return 0
})
return commandPlugins[0]
}
findTopic(id: string, opts: { must: true }): Topic
findTopic(id: string, opts?: { must: boolean }): Topic | undefined
findTopic(name: string, opts: { must?: boolean } = {}) {
const topic = this.topics.find(t => t.name === name)
if (topic) return topic
if (opts.must) throw new Error(`topic ${name} not found`)
}
get commands(): Command.Plugin[] {
if (this._commands) return this._commands
if (this.flexibleTaxonomy) {
const commands = flatMap(this.plugins, p => p.commands)
this._commands = [...commands]
for (const cmd of commands) {
const parts = cmd.id.split(':')
const combos = permutations(parts).flatMap(c => c.join(':'))
for (const combo of combos) {
this._commands.push({...cmd, id: combo})
}
}
} else {
this._commands = flatMap(this.plugins, p => p.commands)
}
return this._commands
}
get commandIDs() {
if (this._commandIDs) return this._commandIDs
const ids = this.commands.flatMap(c => [c.id, ...c.aliases])
this._commandIDs = uniq(ids)
return this._commandIDs
}
get topics(): Topic[] {
if (this._topics) return this._topics
const topics: Topic[] = []
for (const plugin of this.plugins) {
for (const topic of compact(plugin.topics)) {
const existing = topics.find(t => t.name === topic.name)
if (existing) {
existing.description = topic.description || existing.description
existing.hidden = existing.hidden || topic.hidden
} else topics.push(topic)
}
}
// add missing topics
for (const c of this.commands.filter(c => !c.hidden)) {
const parts = c.id.split(':')
while (parts.length > 0) {
const name = parts.join(':')
if (name && !topics.find(t => t.name === name)) {
topics.push({name, description: c.summary || c.description})
}
parts.pop()
}
}
this._topics = topics
return this._topics
}
s3Key(type: keyof PJSON.S3.Templates, ext?: '.tar.gz' | '.tar.xz' | IConfig.s3Key.Options, options: IConfig.s3Key.Options = {}) {
if (typeof ext === 'object') options = ext
else if (ext) options.ext = ext
const template = this.pjson.oclif.update.s3.templates[options.platform ? 'target' : 'vanilla'][type] ?? ''
return ejs.render(template, {...this as any, ...options})
}
s3Url(key: string) {
const host = this.pjson.oclif.update.s3.host
if (!host) throw new Error('no s3 host is set')
const url = new URL(host)
url.pathname = path.join(url.pathname, key)
return url.toString()
}
protected dir(category: 'cache' | 'data' | 'config'): string {
const base = process.env[`XDG_${category.toUpperCase()}_HOME`] ||
(this.windows && process.env.LOCALAPPDATA) ||
path.join(this.home, category === 'data' ? '.local/share' : '.' + category)
return path.join(base, this.dirname)
}
protected windowsHome() {
return this.windowsHomedriveHome() || this.windowsUserprofileHome()
}
protected windowsHomedriveHome() {
return (process.env.HOMEDRIVE && process.env.HOMEPATH && path.join(process.env.HOMEDRIVE!, process.env.HOMEPATH!))
}
protected windowsUserprofileHome() {
return process.env.USERPROFILE
}
protected macosCacheDir(): string | undefined {
return (this.platform === 'darwin' && path.join(this.home, 'Library', 'Caches', this.dirname)) || undefined
}
protected _shell(): string {
let shellPath
const {SHELL, COMSPEC} = process.env
if (SHELL) {
shellPath = SHELL.split('/')
} else if (this.windows && COMSPEC) {
shellPath = COMSPEC.split(/\\|\//)
} else {
shellPath = ['unknown']
}
return shellPath[shellPath.length - 1]
}
protected _debug(): number {
if (this.scopedEnvVarTrue('DEBUG')) return 1
try {
const {enabled} = require('debug')(this.bin)
if (enabled) return 1
} catch {}
return 0
}
protected async loadPlugins(root: string, type: string, plugins: (string | { root?: string; name?: string; tag?: string })[], parent?: Plugin.Plugin) {
if (!plugins || plugins.length === 0) return
debug('loading plugins', plugins)
await Promise.all((plugins || []).map(async plugin => {
try {
const opts: Options = {type, root}
if (typeof plugin === 'string') {
opts.name = plugin
} else {
opts.name = plugin.name || opts.name
opts.tag = plugin.tag || opts.tag
opts.root = plugin.root || opts.root
}
const instance = new Plugin.Plugin(opts)
await instance.load()
if (this.plugins.find(p => p.name === instance.name)) return
this.plugins.push(instance)
if (parent) {
instance.parent = parent
if (!parent.children) parent.children = []
parent.children.push(instance)
}
await this.loadPlugins(instance.root, type, instance.pjson.oclif.plugins || [], instance)
} catch (error: any) {
this.warn(error, 'loadPlugins')
}
}))
}
protected warn(err: string | Error | { name: string; detail: string }, scope?: string) {
if (this.warned) return
if (typeof err === 'string') {
process.emitWarning(err)
return
}
if (err instanceof Error) {
const modifiedErr: any = err
modifiedErr.name = `${err.name} Plugin: ${this.name}`
modifiedErr.detail = compact([
(err as any).detail,
`module: ${this._base}`,
scope && `task: ${scope}`,
`plugin: ${this.name}`,
`root: ${this.root}`,
'See more details with DEBUG=*',
]).join('\n')
process.emitWarning(err)
return
}
// err is an object
process.emitWarning('Config.warn expected either a string or Error, but instead received an object')
err.name = `${err.name} Plugin: ${this.name}`
err.detail = compact([
err.detail,
`module: ${this._base}`,
scope && `task: ${scope}`,
`plugin: ${this.name}`,
`root: ${this.root}`,
'See more details with DEBUG=*',
]).join('\n')
process.emitWarning(JSON.stringify(err))
}
protected get isProd() {
return isProd()
}
}
export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Command> {
const flags = {} as {[k: string]: Command.Flag}
for (const [name, flag] of Object.entries(c.flags || {})) {
if (flag.type === 'boolean') {
flags[name] = {
name,
type: flag.type,
char: flag.char,
summary: flag.summary,
description: flag.description,
hidden: flag.hidden,
required: flag.required,
helpLabel: flag.helpLabel,
helpGroup: flag.helpGroup,
allowNo: flag.allowNo,
dependsOn: flag.dependsOn,
exclusive: flag.exclusive,
}
} else {
flags[name] = {
name,
type: flag.type,
char: flag.char,
summary: flag.summary,
description: flag.description,
hidden: flag.hidden,
required: flag.required,
helpLabel: flag.helpLabel,
helpValue: flag.helpValue,
helpGroup: flag.helpGroup,
multiple: flag.multiple,
options: flag.options,
dependsOn: flag.dependsOn,
exclusive: flag.exclusive,
default: typeof flag.default === 'function' ? await flag.default({options: {}, flags: {}}) : flag.default,
}
}
}
const argsPromise = (c.args || []).map(async a => ({
name: a.name,
description: a.description,
required: a.required,
options: a.options,
default: typeof a.default === 'function' ? await a.default({}) : a.default,
hidden: a.hidden,
}))
const args = await Promise.all(argsPromise)
const stdProperties = {
id: c.id,
summary: c.summary,
description: c.description,
strict: c.strict,
usage: c.usage,
pluginName: plugin && plugin.name,
pluginAlias: plugin && plugin.alias,
pluginType: plugin && plugin.type,
hidden: c.hidden,
state: c.state,
aliases: c.aliases || [],
examples: c.examples || (c as any).example,
flags,
args,
}
// do not include these properties in manifest
const ignoreCommandProperties = ['plugin', '_flags']
const stdKeys = Object.keys(stdProperties)
const keysToAdd = Object.keys(c).filter(property => ![...stdKeys, ...ignoreCommandProperties].includes(property))
const additionalProperties: any = {}
for (const key of keysToAdd) {
additionalProperties[key] = (c as any)[key]
}
return {...stdProperties, ...additionalProperties}
}