heroku 10.8.0 → 10.9.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/heroku-prompts.js +235 -0
- package/bin/heroku-repl.js +682 -0
- package/bin/run +35 -7
- package/lib/analytics.js +3 -1
- package/lib/global_telemetry.d.ts +1 -1
- package/lib/global_telemetry.js +3 -1
- package/oclif.manifest.json +1161 -1161
- package/package.json +8 -5
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// do not use the older node:readline module
|
|
2
|
+
// else things will break
|
|
3
|
+
const readline = require('node:readline/promises')
|
|
4
|
+
const yargs = require('yargs-parser')
|
|
5
|
+
const util = require('util')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const {ux, run} = require('@oclif/core')
|
|
9
|
+
const os = require('node:os')
|
|
10
|
+
const historyFile = path.join(process.env.HOME || process.env.USERPROFILE || os.homedir(), '.heroku_repl_history')
|
|
11
|
+
const stateFile = path.join(process.env.HOME || process.env.USERPROFILE || os.homedir(), '.heroku_repl_state')
|
|
12
|
+
const shellQuote = require('shell-quote')
|
|
13
|
+
|
|
14
|
+
const maxHistory = 1000
|
|
15
|
+
const mcpMode = process.env.HEROKU_MCP_MODE === 'true'
|
|
16
|
+
/**
|
|
17
|
+
* Map of commands used to provide completion
|
|
18
|
+
* data. The key is the flag or arg name to
|
|
19
|
+
* get data for and the value is an array containing
|
|
20
|
+
* the command name and an array of arguments to
|
|
21
|
+
* pass to the command if needed.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* heroku > pipelines:create --app <tab><tab>
|
|
25
|
+
* heroku > spaces:create --team <tab><tab>
|
|
26
|
+
*/
|
|
27
|
+
const completionCommandByName = new Map([
|
|
28
|
+
['app', ['apps', ['--all', '--json']]],
|
|
29
|
+
['org', ['orgs', ['--json']]],
|
|
30
|
+
['team', ['teams', ['--json']]],
|
|
31
|
+
['space', ['spaces', ['--json']]],
|
|
32
|
+
['pipeline', ['pipelines', ['--json']]],
|
|
33
|
+
['addon', ['addons', ['--json']]],
|
|
34
|
+
['domain', ['domains', ['--json']]],
|
|
35
|
+
['dyno', ['ps', ['--json']]],
|
|
36
|
+
['release', ['releases', ['--json']]],
|
|
37
|
+
['stack', ['apps:stacks', ['--json']]],
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Map of completion data by flag or arg name.
|
|
42
|
+
* This is used as a cache for completion data
|
|
43
|
+
* that is retrieved from a remote source.
|
|
44
|
+
*
|
|
45
|
+
* No attempt is made to invalidate these caches
|
|
46
|
+
* at runtime but they are not preserved between
|
|
47
|
+
* sessions.
|
|
48
|
+
*/
|
|
49
|
+
const completionResultsByName = new Map()
|
|
50
|
+
|
|
51
|
+
class HerokuRepl {
|
|
52
|
+
/**
|
|
53
|
+
* The OClif config object containing
|
|
54
|
+
* the command metadata and the means
|
|
55
|
+
* to execute commands
|
|
56
|
+
*/
|
|
57
|
+
#config
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A map of key/value pairs used for
|
|
61
|
+
* the 'set' and 'unset' command
|
|
62
|
+
*/
|
|
63
|
+
#setValues = new Map()
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The history of the REPL commands used
|
|
67
|
+
*/
|
|
68
|
+
#history = []
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The write stream for the history file
|
|
72
|
+
*/
|
|
73
|
+
#historyStream
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The readline interface used for the REPL
|
|
77
|
+
*/
|
|
78
|
+
#rl
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Constructs a new instance of the HerokuRepl class.
|
|
82
|
+
*
|
|
83
|
+
* @param {Config} config The oclif core config object
|
|
84
|
+
*/
|
|
85
|
+
constructor(config) {
|
|
86
|
+
this.#createInterface()
|
|
87
|
+
this.#config = config
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prepares the REPL history by loading
|
|
92
|
+
* the previous history from the history file
|
|
93
|
+
* and opening a write stream for new entries.
|
|
94
|
+
*
|
|
95
|
+
* @returns {Promise<void>} a promise that resolves when the history has been loaded
|
|
96
|
+
*/
|
|
97
|
+
#prepareHistory() {
|
|
98
|
+
this.#historyStream = fs.createWriteStream(historyFile, {
|
|
99
|
+
flags: 'a',
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Load existing history first
|
|
104
|
+
if (fs.existsSync(historyFile)) {
|
|
105
|
+
this.#history = fs.readFileSync(historyFile, 'utf8')
|
|
106
|
+
.split('\n')
|
|
107
|
+
.filter(line => line.trim())
|
|
108
|
+
.reverse()
|
|
109
|
+
.splice(0, maxHistory)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Loads the previous session state from the state file.
|
|
115
|
+
* @returns {void}
|
|
116
|
+
*/
|
|
117
|
+
#loadState() {
|
|
118
|
+
if (fs.existsSync(stateFile)) {
|
|
119
|
+
try {
|
|
120
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
|
|
121
|
+
for (const entry of Object.entries(state)) {
|
|
122
|
+
this.#updateFlagsByName('set', entry, true)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process.stdout.write('session restored')
|
|
126
|
+
} catch {
|
|
127
|
+
// noop
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Creates a new readline interface.
|
|
134
|
+
*
|
|
135
|
+
* @returns {readline.Interface} the readline interface
|
|
136
|
+
*/
|
|
137
|
+
#createInterface() {
|
|
138
|
+
this.#rl = readline.createInterface({
|
|
139
|
+
input: process.stdin,
|
|
140
|
+
output: process.stdout,
|
|
141
|
+
prompt: 'heroku > ',
|
|
142
|
+
removeHistoryDuplicates: true,
|
|
143
|
+
historySize: maxHistory,
|
|
144
|
+
completer: async line => {
|
|
145
|
+
if (mcpMode) {
|
|
146
|
+
return [[], line]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Use shell-quote to tokenize the line for robust parsing
|
|
150
|
+
const tokens = shellQuote.parse(line)
|
|
151
|
+
const stringTokens = tokens.filter(t => typeof t === 'string')
|
|
152
|
+
const [command = '', ...parts] = stringTokens
|
|
153
|
+
if (command === 'set') {
|
|
154
|
+
return this.#buildSetCompletions(parts)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const commandMeta = this.#config.findCommand(command)
|
|
158
|
+
if (!commandMeta) {
|
|
159
|
+
const matches = this.#config.commands.filter(({id}) => id.startsWith(command))
|
|
160
|
+
return [matches.map(({id}) => id).sort(), line]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return this.#buildCompletions(commandMeta, parts, line)
|
|
164
|
+
},
|
|
165
|
+
})
|
|
166
|
+
this.#prepareHistory()
|
|
167
|
+
this.#loadState()
|
|
168
|
+
|
|
169
|
+
this.#rl.history.push(...this.#history)
|
|
170
|
+
this.#rl.on('line', this.#processLine)
|
|
171
|
+
this.#rl.once('close', () => {
|
|
172
|
+
this.#historyStream?.close()
|
|
173
|
+
fs.writeFileSync(stateFile, JSON.stringify(Object.fromEntries(this.#setValues)), 'utf8')
|
|
174
|
+
})
|
|
175
|
+
this.#rl.prompt()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Processes the line received from the terminal stdin
|
|
180
|
+
*
|
|
181
|
+
* @param {string} input the line to process
|
|
182
|
+
* @returns {Promise<void>} a promise that resolves when the command has been executed
|
|
183
|
+
*/
|
|
184
|
+
#processLine = async input => {
|
|
185
|
+
if (input.trim() === '') {
|
|
186
|
+
this.#rl.prompt()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
this.#history.push(input)
|
|
191
|
+
this.#historyStream?.write(input + '\n')
|
|
192
|
+
|
|
193
|
+
const tokens = shellQuote.parse(input)
|
|
194
|
+
const stringTokens = tokens.filter(t => typeof t === 'string')
|
|
195
|
+
// flag/arg extraction
|
|
196
|
+
const {_: [command, ...positionalArgs], ...flags} = yargs(stringTokens, {
|
|
197
|
+
configuration: {
|
|
198
|
+
'camel-case-expansion': false,
|
|
199
|
+
'boolean-negation': false,
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
const args = Object.entries(flags).flatMap(([key, value]) => {
|
|
203
|
+
if (typeof value === 'string') {
|
|
204
|
+
return [`--${key}`, value]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return [`--${key}`]
|
|
208
|
+
}).concat(positionalArgs)
|
|
209
|
+
|
|
210
|
+
if (command === 'exit') {
|
|
211
|
+
process.exit(0)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (command === 'history') {
|
|
215
|
+
process.stdout.write(this.#history.join('\n'))
|
|
216
|
+
this.#rl.prompt()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (command === 'set' || command === 'unset') {
|
|
221
|
+
this.#updateFlagsByName(command, args)
|
|
222
|
+
this.#rl.prompt()
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const cmd = this.#config.findCommand(command)
|
|
227
|
+
|
|
228
|
+
if (!cmd) {
|
|
229
|
+
console.error(`"${command}" is not a valid command`)
|
|
230
|
+
this.#rl.prompt()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const {flags} = cmd
|
|
236
|
+
for (const [key, value] of this.#setValues) {
|
|
237
|
+
if (Reflect.has(flags, key)) {
|
|
238
|
+
args.push(`--${key}`, value)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Any commands that prompt the user will cause
|
|
243
|
+
// the REPL to enter an invalid state. We need
|
|
244
|
+
// to pause the readline interface and restore
|
|
245
|
+
// it when the command is done.
|
|
246
|
+
if (process.stdin.isTTY) {
|
|
247
|
+
process.stdin.setRawMode(false)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.#rl.close()
|
|
251
|
+
this.#rl.off('line', this.#processLine)
|
|
252
|
+
if (mcpMode) {
|
|
253
|
+
process.stdout.write('<<<BEGIN RESULTS>>>\n')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
process.argv.length = 2
|
|
257
|
+
process.argv.push(command, ...args.filter(Boolean))
|
|
258
|
+
await run([command, ...args.filter(Boolean)], this.#config)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (mcpMode) {
|
|
261
|
+
process.stderr.write(`<<<ERROR>>>\n${error.message}\n<<<END ERROR>>>\n`)
|
|
262
|
+
} else {
|
|
263
|
+
console.error(error.message)
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
if (process.stdin.isTTY) {
|
|
267
|
+
process.stdin.setRawMode(true)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (mcpMode) {
|
|
271
|
+
process.stdout.write('<<<END RESULTS>>>\n')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.#createInterface()
|
|
275
|
+
this.start()
|
|
276
|
+
// Force readline to refresh the current line
|
|
277
|
+
this.#rl.write(null, {ctrl: true, name: 'u'})
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Updates the session state based on the command and args.
|
|
283
|
+
*
|
|
284
|
+
* @param {'set'|'unset'} command either 'set' or 'unset'
|
|
285
|
+
* @param {string[]} args an array of arg names
|
|
286
|
+
* @param {boolean} omitConfirmation when false. no confirmation is printed to stdout
|
|
287
|
+
* @returns {void}
|
|
288
|
+
*/
|
|
289
|
+
#updateFlagsByName(command, args, omitConfirmation) {
|
|
290
|
+
if (command === 'set') {
|
|
291
|
+
const [key, value] = args
|
|
292
|
+
if (key && value) {
|
|
293
|
+
this.#setValues.set(key, value)
|
|
294
|
+
|
|
295
|
+
if (!omitConfirmation) {
|
|
296
|
+
process.stdout.write(`setting --${key} to ${value}\n`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (key === 'app') {
|
|
300
|
+
this.#rl.setPrompt(`${value} > `)
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
const values = []
|
|
304
|
+
for (const [flag, value] of this.#setValues) {
|
|
305
|
+
values.push({flag, value})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (values.length === 0) {
|
|
309
|
+
return console.info('no flags set')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
ux.table(values, {
|
|
313
|
+
flag: {header: 'Flag'},
|
|
314
|
+
value: {header: 'Value'},
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (command === 'unset') {
|
|
320
|
+
const [key] = args
|
|
321
|
+
|
|
322
|
+
if (!omitConfirmation) {
|
|
323
|
+
process.stdout.write(`unsetting --${key}\n`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.#setValues.delete(key)
|
|
327
|
+
if (key === 'app') {
|
|
328
|
+
this.#rl.setPrompt('heroku > ')
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Build completions for a command.
|
|
335
|
+
* The completions are based on the
|
|
336
|
+
* metadata for the command and the
|
|
337
|
+
* user input.
|
|
338
|
+
*
|
|
339
|
+
* @param {Record<string, unknown>} commandMeta the metadata for the command
|
|
340
|
+
* @param {string[]} flagsOrArgs the flags or args for the command
|
|
341
|
+
* @param {string} line the current line
|
|
342
|
+
* @returns {Promise<[string[], string]>} the completions and the current input
|
|
343
|
+
*/
|
|
344
|
+
async #buildCompletions(commandMeta, flagsOrArgs = [], line = '') {
|
|
345
|
+
const {args, flags} = commandMeta
|
|
346
|
+
const {requiredInputs: requiredFlags, optionalInputs: optionalFlags} = this.#collectInputsFromManifest(flags)
|
|
347
|
+
const {requiredInputs: requiredArgs, optionalInputs: optionalArgs} = this.#collectInputsFromManifest(args)
|
|
348
|
+
|
|
349
|
+
const {_: userArgs, ...userFlags} = yargs(flagsOrArgs, {
|
|
350
|
+
configuration: {
|
|
351
|
+
'camel-case-expansion': false,
|
|
352
|
+
'boolean-negation': false,
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
const current = flagsOrArgs[flagsOrArgs.length - 1] ?? ''
|
|
356
|
+
|
|
357
|
+
// Order of precedence:
|
|
358
|
+
// 1. Required flags
|
|
359
|
+
// 2. Required args
|
|
360
|
+
// 3. Optional flags
|
|
361
|
+
// 4. Optional args
|
|
362
|
+
// 5. End of line
|
|
363
|
+
// Flags *must* occur first since they may influence
|
|
364
|
+
// the completions for args.
|
|
365
|
+
return await this.#getCompletionsForFlag(line, current, requiredFlags, userFlags, commandMeta) ||
|
|
366
|
+
await this.#getCompletionsForArg(current, requiredArgs, userArgs) ||
|
|
367
|
+
await this.#getCompletionsForFlag(line, current, optionalFlags, userFlags, commandMeta) ||
|
|
368
|
+
await this.#getCompletionsForArg(current, optionalArgs, userArgs) ||
|
|
369
|
+
this.#getCompletionsForEndOfLine(line, flags, userFlags)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Get completions for a command.
|
|
374
|
+
* The completions are based on the
|
|
375
|
+
* metadata for the command and the
|
|
376
|
+
* user input.
|
|
377
|
+
*
|
|
378
|
+
* @param {[string, string]} parts the parts for a line to get completions for
|
|
379
|
+
* @returns {[string[], string]} the completions and the current input
|
|
380
|
+
*/
|
|
381
|
+
async #buildSetCompletions(parts) {
|
|
382
|
+
const [name, current] = parts
|
|
383
|
+
if (parts.length > 0 && completionCommandByName.has(name)) {
|
|
384
|
+
return [await this.#getCompletion(name, current), current]
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Critical to completions operating as expected;
|
|
388
|
+
// the completions must be filtered to omit keys
|
|
389
|
+
// that do not match our name (if a name exists).
|
|
390
|
+
const completions = [...completionCommandByName.keys()]
|
|
391
|
+
.filter(c => !name || c.startsWith(name))
|
|
392
|
+
|
|
393
|
+
return [completions, name ?? current]
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Get completions for the end of the line.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} line the current line
|
|
400
|
+
* @param {Record<string, unknown>} flags the flags for the command
|
|
401
|
+
* @param {Record<string, unknown>} userFlags the flags that have already been used
|
|
402
|
+
* @returns {[string[], string]} the completions and the current input
|
|
403
|
+
*/
|
|
404
|
+
#getCompletionsForEndOfLine(line, flags, userFlags) {
|
|
405
|
+
const flagKeys = Object.keys(userFlags)
|
|
406
|
+
// If there are no more flags to complete,
|
|
407
|
+
// return an empty array.
|
|
408
|
+
return flagKeys.length < Object.keys(flags).length ? [[line.endsWith(' ') ? '--' : ' --'], ''] : [[], '']
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Get completions for a flag or flag value.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} line the current line
|
|
415
|
+
* @param {string} current the current input
|
|
416
|
+
* @param {string[]} flags the flags for the command
|
|
417
|
+
* @param {string[]} userFlags the flags that have already been used
|
|
418
|
+
* @param {Record<string, unknown>} commandMeta the metadata for the command
|
|
419
|
+
* @return {Promise<[string[], string]>} the completions and the current input
|
|
420
|
+
*/
|
|
421
|
+
async #getCompletionsForFlag(line, current, flags, userFlags, commandMeta) {
|
|
422
|
+
const commandMetaWithCharKeys = {...commandMeta}
|
|
423
|
+
// make sure the commandMeta also contains keys for char fields
|
|
424
|
+
Object.keys(commandMeta.flags).forEach(key => {
|
|
425
|
+
const flag = commandMeta.flags[key]
|
|
426
|
+
if (flag.char) {
|
|
427
|
+
commandMetaWithCharKeys.flags[flag.char] = flag
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
// flag completion for long and short flags.
|
|
431
|
+
// flags that have already been used are
|
|
432
|
+
// not included in the completions.
|
|
433
|
+
const isFlag = current.startsWith('-')
|
|
434
|
+
const isLongFlag = current.startsWith('--')
|
|
435
|
+
if (isFlag) {
|
|
436
|
+
const rawFlag = isLongFlag ? current.slice(2) : current.slice(1)
|
|
437
|
+
const prop = isLongFlag ? 'long' : 'short'
|
|
438
|
+
const matched = flags
|
|
439
|
+
.filter(flag => {
|
|
440
|
+
return !Reflect.has(userFlags, flag.short) && !Reflect.has(userFlags, flag.long) &&
|
|
441
|
+
(!rawFlag || flag[prop]?.startsWith(rawFlag))
|
|
442
|
+
})
|
|
443
|
+
.map(f => isLongFlag ? f.long : f.short)
|
|
444
|
+
.filter(Boolean)
|
|
445
|
+
|
|
446
|
+
if (matched?.length > 0) {
|
|
447
|
+
return [matched, rawFlag]
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Does the flag have a value or is it
|
|
452
|
+
// expected to have a value?
|
|
453
|
+
const flagKeys = Object.keys(userFlags)
|
|
454
|
+
const flag = flagKeys[flagKeys.length - 1]
|
|
455
|
+
const isBooleanFlag = commandMetaWithCharKeys.flags[flag]?.type === 'boolean'
|
|
456
|
+
if (this.#isFlagValueComplete(line) || isBooleanFlag || current === '--' || current === '-') {
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const {options, type, name} = commandMetaWithCharKeys.flags[flag] ?? {}
|
|
461
|
+
// Options are defined in the metadata
|
|
462
|
+
// for the command. If the flag has options
|
|
463
|
+
// defined, we will attempt to complete
|
|
464
|
+
// based on the options.
|
|
465
|
+
if (type === 'option') {
|
|
466
|
+
if (options?.length > 0) {
|
|
467
|
+
const optionComplete = options.includes(current)
|
|
468
|
+
const matched = options.filter(o => o.startsWith(current))
|
|
469
|
+
|
|
470
|
+
if (!optionComplete) {
|
|
471
|
+
return matched.length > 0 ? [matched, current] : [options, current]
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return [await this.#getCompletion(name, isFlag ? '' : current), current]
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Get completions for a flag.
|
|
481
|
+
*
|
|
482
|
+
* @param {string} flag the flag to get the completion for
|
|
483
|
+
* @param {string} startsWith the string to match against
|
|
484
|
+
* @returns {Promise<[string[]]>} the completions
|
|
485
|
+
*/
|
|
486
|
+
async #getCompletion(flag, startsWith) {
|
|
487
|
+
// attempt to retrieve the options from the
|
|
488
|
+
// Heroku API. If the options have already
|
|
489
|
+
// been retrieved, they will be cached.
|
|
490
|
+
if (completionCommandByName.has(flag)) {
|
|
491
|
+
let result
|
|
492
|
+
if (completionResultsByName.has(flag)) {
|
|
493
|
+
result = completionResultsByName.get(flag)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!result || result.length === 0) {
|
|
497
|
+
const [command, args] = completionCommandByName.get(flag)
|
|
498
|
+
const completionsStr = await this.#captureStdout(() => this.#config.runCommand(command, args)) ?? '[]'
|
|
499
|
+
result = JSON.parse(util.stripVTControlCharacters(completionsStr))
|
|
500
|
+
completionResultsByName.set(flag, result)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const matched = result
|
|
504
|
+
.map(obj => obj.name ?? obj.id)
|
|
505
|
+
.filter(name => !startsWith || name.startsWith(startsWith))
|
|
506
|
+
.sort()
|
|
507
|
+
|
|
508
|
+
return matched
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#isFlagValueComplete(input) {
|
|
513
|
+
const tokens = shellQuote.parse(input.trim())
|
|
514
|
+
const len = tokens.length
|
|
515
|
+
if (len === 0) {
|
|
516
|
+
return false
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const lastToken = tokens[len - 1]
|
|
520
|
+
|
|
521
|
+
// "-" or "--" means the flag name is absent
|
|
522
|
+
if (lastToken === '-' || lastToken === '--') {
|
|
523
|
+
return false
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// back up to the last flag and store the index
|
|
527
|
+
let lastFlagIndex = -1
|
|
528
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
529
|
+
if (typeof tokens[i] === 'string' && tokens[i].startsWith('-')) {
|
|
530
|
+
lastFlagIndex = i
|
|
531
|
+
break
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// No flag, nothing to complete
|
|
536
|
+
if (lastFlagIndex === -1) {
|
|
537
|
+
return true
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// If the last flag is the last token
|
|
541
|
+
// e.g., "run hello.sh --app"
|
|
542
|
+
if (lastFlagIndex === len - 1) {
|
|
543
|
+
return false
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// If the last flag has a value
|
|
547
|
+
if (lastFlagIndex === len - 2) {
|
|
548
|
+
// e.g., "run hello.sh --app heroku-vscode "
|
|
549
|
+
// If input ends with whitespace assume the value is complete
|
|
550
|
+
return /\s$/.test(input)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// If the last flag is followed by more than one value, treat as complete
|
|
554
|
+
// since the last value is likely to be an argument
|
|
555
|
+
return true
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Capture stdout by deflecting it to a
|
|
560
|
+
* trap function and returning the output.
|
|
561
|
+
*
|
|
562
|
+
* This is useful for silently capturing the output
|
|
563
|
+
* of a command that normally prints to stdout.
|
|
564
|
+
*
|
|
565
|
+
* @param {CallableFunction} fn the function to capture stdout for
|
|
566
|
+
* @returns {Promise<string>} the output from stdout
|
|
567
|
+
*/
|
|
568
|
+
async #captureStdout(fn) {
|
|
569
|
+
const output = []
|
|
570
|
+
const originalWrite = process.stdout.write
|
|
571
|
+
// Replace stdout.write temporarily
|
|
572
|
+
process.stdout.write = chunk => {
|
|
573
|
+
output.push(typeof chunk === 'string' ? chunk : chunk.toString())
|
|
574
|
+
return true
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
await fn()
|
|
579
|
+
return output.join('')
|
|
580
|
+
} finally {
|
|
581
|
+
// Restore original stdout
|
|
582
|
+
process.stdout.write = originalWrite
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Get completions for an arg.
|
|
588
|
+
*
|
|
589
|
+
* @param {string} current the current input
|
|
590
|
+
* @param {({long: string}[])} args the args for the command
|
|
591
|
+
* @param {string[]} userArgs the args that have already been used
|
|
592
|
+
* @returns {Promise<[string[], string] | null>} the completions and the current input
|
|
593
|
+
*/
|
|
594
|
+
async #getCompletionsForArg(current, args = [], userArgs = []) {
|
|
595
|
+
if (userArgs.length <= args.length) {
|
|
596
|
+
const arg = args[userArgs.length]
|
|
597
|
+
if (arg) {
|
|
598
|
+
const {long} = arg
|
|
599
|
+
if (completionCommandByName.has(long)) {
|
|
600
|
+
const completions = await this.#getCompletion(long, current)
|
|
601
|
+
if (completions.length > 0) {
|
|
602
|
+
return [completions, current]
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return [[`<${long}>`], current]
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return null
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Collect inputs from the command manifest and sorts
|
|
615
|
+
* them by type and then by required status.
|
|
616
|
+
*
|
|
617
|
+
* @param {Record<string, unknown>} commandMeta the metadata from the command manifest
|
|
618
|
+
* @returns {{requiredInputs: {long: string, short: string}[], optionalInputs: {long: string, short: string}[]}} the inputs from the command manifest
|
|
619
|
+
*/
|
|
620
|
+
#collectInputsFromManifest(commandMeta) {
|
|
621
|
+
const requiredInputs = []
|
|
622
|
+
const optionalInputs = []
|
|
623
|
+
|
|
624
|
+
// Prioritize options over booleans
|
|
625
|
+
const keysByType = Object.keys(commandMeta).sort((a, b) => {
|
|
626
|
+
const {type: aType} = commandMeta[a]
|
|
627
|
+
const {type: bType} = commandMeta[b]
|
|
628
|
+
if (aType === bType) {
|
|
629
|
+
return 0
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (aType === 'option') {
|
|
633
|
+
return -1
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (bType === 'option') {
|
|
637
|
+
return 1
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return 0
|
|
641
|
+
})
|
|
642
|
+
const includedFlags = new Set()
|
|
643
|
+
for (const key of keysByType) {
|
|
644
|
+
const {required: isRequired, char: short, name: long} = commandMeta[key]
|
|
645
|
+
if (includedFlags.has(long)) {
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
includedFlags.add(long)
|
|
650
|
+
if (isRequired) {
|
|
651
|
+
requiredInputs.push({long, short})
|
|
652
|
+
continue
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
optionalInputs.push({long, short})
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Prioritize required inputs
|
|
659
|
+
// over optional inputs
|
|
660
|
+
// required inputs are sorted
|
|
661
|
+
// alphabetically. optional
|
|
662
|
+
// inputs are sorted alphabetically
|
|
663
|
+
// and then pushed to the end of
|
|
664
|
+
// the list.
|
|
665
|
+
requiredInputs.sort((a, b) => {
|
|
666
|
+
if (a.long < b.long) {
|
|
667
|
+
return -1
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (a.long > b.long) {
|
|
671
|
+
return 1
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return 0
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
return {requiredInputs, optionalInputs}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
module.exports.herokuRepl = async function (config) {
|
|
681
|
+
return new HerokuRepl(config)
|
|
682
|
+
}
|