/
command.ts
285 lines (230 loc) · 7.97 KB
/
command.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
import {fileURLToPath} from 'url'
import {format, inspect} from 'util'
import {CliUx} from './index'
import {Config} from './config'
import * as Interfaces from './interfaces'
import * as Errors from './errors'
import {PrettyPrintableError} from './errors'
import * as Parser from './parser'
import * as Flags from './flags'
const pjson = require('../package.json')
/**
* swallows stdout epipe errors
* this occurs when stdout closes such as when piping to head
*/
process.stdout.on('error', (err: any) => {
if (err && err.code === 'EPIPE')
return
throw err
})
const jsonFlag = {
json: Flags.boolean({
description: 'Format output as json.',
helpGroup: 'GLOBAL',
}),
}
/**
* An abstract class which acts as the base for each command
* in your project.
*/
export default abstract class Command {
static _base = `${pjson.name}@${pjson.version}`
/** A command ID, used mostly in error or verbose reporting. */
static id: string
/**
* The tweet-sized description for your class, used in a parent-commands
* sub-command listing and as the header for the command help.
*/
static summary?: string;
/**
* A full description of how to use the command.
*
* If no summary, the first line of the description will be used as the summary.
*/
static description: string | undefined
/** Hide the command from help? */
static hidden: boolean
/** Mark the command as a given state (e.g. beta) in help? */
static state?: string;
/**
* An override string (or strings) for the default usage documentation.
*/
static usage: string | string[] | undefined
static help: string | undefined
/** An array of aliases for this command. */
static aliases: string[] = []
/** When set to false, allows a variable amount of arguments */
static strict = true
static parse = true
/** An order-dependent array of arguments for the command */
static args?: Interfaces.ArgInput
static plugin: Interfaces.Plugin | undefined
/**
* An array of examples to show at the end of the command's help.
*
* IF only a string is provided, it will try to look for a line that starts
* with the cmd.bin as the example command and the rest as the description.
* If found, the command will be formatted appropriately.
*
* ```
* EXAMPLES:
* A description of a particular use case.
*
* $ <%= config.bin => command flags
* ```
*/
static examples: Interfaces.Example[]
static parserOptions = {}
static _enableJsonFlag = false
static get enableJsonFlag(): boolean {
return this._enableJsonFlag
}
static set enableJsonFlag(value: boolean) {
this._enableJsonFlag = value
if (value) this.globalFlags = jsonFlag
}
// eslint-disable-next-line valid-jsdoc
/**
* instantiate and run the command
* @param {Interfaces.Command.Class} this Class
* @param {string[]} argv argv
* @param {Interfaces.LoadOptions} opts options
*/
static run: Interfaces.Command.Class['run'] = async function (this: Interfaces.Command.Class, argv?: string[], opts?) {
if (!argv) argv = process.argv.slice(2)
// 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)
}
// to-do: update in node-14 to module.main
const config = await Config.load(opts || (module.parent && module.parent.parent && module.parent.parent.filename) || __dirname)
const cmd = new this(argv, config)
return cmd._run(argv)
}
protected static _globalFlags: Interfaces.FlagInput
static get globalFlags(): Interfaces.FlagInput {
return this._globalFlags
}
static set globalFlags(flags: Interfaces.FlagInput) {
this._globalFlags = Object.assign({}, this.globalFlags, flags)
this.flags = {} // force the flags setter to run
}
/** A hash of flags for the command */
protected static _flags: Interfaces.FlagInput
static get flags(): Interfaces.FlagInput {
return this._flags
}
static set flags(flags: Interfaces.FlagInput) {
this._flags = Object.assign({}, this._flags ?? {}, this.globalFlags, flags)
}
id: string | undefined
protected debug: (...args: any[]) => void
constructor(public argv: string[], public config: Config) {
this.id = this.ctor.id
try {
this.debug = require('debug')(this.id ? `${this.config.bin}:${this.id}` : this.config.bin)
} catch {
this.debug = () => {}
}
}
get ctor(): typeof Command {
return this.constructor as typeof Command
}
async _run<T>(): Promise<T | undefined> {
let err: Error | undefined
let result
try {
// remove redirected env var to allow subsessions to run autoupdated client
delete process.env[this.config.scopedEnvVarKey('REDIRECTED')]
await this.init()
result = await this.run()
} catch (error: any) {
err = error
await this.catch(error)
} finally {
await this.finally(err)
}
if (result && this.jsonEnabled()) {
CliUx.ux.styledJSON(this.toSuccessJson(result))
}
return result
}
exit(code = 0): void {
return Errors.exit(code)
}
warn(input: string | Error): string | Error {
if (!this.jsonEnabled()) Errors.warn(input)
return input
}
error(input: string | Error, options: {code?: string; exit: false} & PrettyPrintableError): void
error(input: string | Error, options?: {code?: string; exit?: number} & PrettyPrintableError): never
error(input: string | Error, options: {code?: string; exit?: number | false} & PrettyPrintableError = {}): void {
return Errors.error(input, options as any)
}
log(message = '', ...args: any[]): void {
if (!this.jsonEnabled()) {
message = typeof message === 'string' ? message : inspect(message)
process.stdout.write(format(message, ...args) + '\n')
}
}
logToStderr(message = '', ...args: any[]): void {
if (!this.jsonEnabled()) {
message = typeof message === 'string' ? message : inspect(message)
process.stderr.write(format(message, ...args) + '\n')
}
}
public jsonEnabled(): boolean {
return this.ctor.enableJsonFlag && this.argv.includes('--json')
}
/**
* actual command run code goes here
*/
abstract run(): PromiseLike<any>
protected async init(): Promise<any> {
this.debug('init version: %s argv: %o', this.ctor._base, this.argv)
if (this.config.debug) Errors.config.debug = true
if (this.config.errlog) Errors.config.errlog = this.config.errlog
// global['cli-ux'].context = global['cli-ux'].context || {
// command: compact([this.id, ...this.argv]).join(' '),
// version: this.config.userAgent,
// }
const g: any = global
g['http-call'] = g['http-call'] || {}
g['http-call']!.userAgent = this.config.userAgent
}
protected async parse<F, G, A extends { [name: string]: any }>(options?: Interfaces.Input<F, G>, argv = this.argv): Promise<Interfaces.ParserOutput<F, G, A>> {
if (!options) options = this.constructor as any
const opts = {context: this, ...options}
// the spread operator doesn't work with getters so we have to manually add it here
opts.flags = options?.flags
return Parser.parse(argv, opts)
}
protected async catch(err: Error & {exitCode?: number}): Promise<any> {
process.exitCode = process.exitCode ?? err.exitCode ?? 1
if (this.jsonEnabled()) {
CliUx.ux.styledJSON(this.toErrorJson(err))
} else {
if (!err.message) throw err
try {
const chalk = require('chalk')
CliUx.ux.action.stop(chalk.bold.red('!'))
} catch {}
throw err
}
}
protected async finally(_: Error | undefined): Promise<any> {
try {
const config = Errors.config
if (config.errorLogger) await config.errorLogger.flush()
// tslint:disable-next-line no-console
} catch (error: any) {
console.error(error)
}
}
protected toSuccessJson(result: unknown): any {
return result
}
protected toErrorJson(err: unknown): any {
return {error: err}
}
}