heroku 10.3.1-alpha.0 → 10.4.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/bin/run CHANGED
@@ -1,15 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const {Config} = require('@oclif/core')
4
- const root = require.resolve('../package.json')
5
- const config = new Config({root})
6
-
7
3
  process.env.HEROKU_UPDATE_INSTRUCTIONS = process.env.HEROKU_UPDATE_INSTRUCTIONS || 'update with: "npm update -g heroku"'
8
4
 
9
5
  const now = new Date()
10
6
  const cliStartTime = now.getTime()
11
7
  const globalTelemetry = require('../lib/global_telemetry')
12
- const yargs = require('yargs-parser')(process.argv.slice(2))
13
8
 
14
9
  process.once('beforeExit', async code => {
15
10
  // capture as successful exit
@@ -43,37 +38,13 @@ process.on('SIGTERM', async () => {
43
38
  globalTelemetry.initializeInstrumentation()
44
39
 
45
40
  const oclif = require('@oclif/core')
46
- const oclifFlush = require('@oclif/core/flush')
47
- const oclifError = require('@oclif/core/handle')
48
- const {promptUser} = require('./heroku-prompts')
49
- const {herokuRepl} = require('./heroku-repl')
50
-
51
- const main = async () => {
52
- try {
53
- await config.load()
54
- const {_: [commandName, ...args], ...flags} = yargs
55
- if (!commandName && args.length === 0 && Object.keys(flags).length === 0) {
56
- await herokuRepl(config)
57
- return
58
- }
59
-
60
- if (flags.prompt) {
61
- delete flags.prompt
62
- await promptUser(config, commandName, args, flags)
63
- await oclif.run([commandName, ...args], config)
64
- } else {
65
- await oclif.run(undefined, config)
66
- }
67
41
 
68
- await oclifFlush()
69
- } catch (error) {
70
- // capture any errors raised by oclif
71
- const cliError = error
72
- cliError.cliRunDuration = globalTelemetry.computeDuration(cliStartTime)
73
- await globalTelemetry.sendTelemetry(cliError)
42
+ oclif.run().then(require('@oclif/core/flush')).catch(async error => {
43
+ // capture any errors raised by oclif
44
+ const cliError = error
45
+ cliError.cliRunDuration = globalTelemetry.computeDuration(cliStartTime)
46
+ await globalTelemetry.sendTelemetry(cliError)
74
47
 
75
- oclifError(error)
76
- }
77
- }
48
+ return require('@oclif/core/handle')(error)
49
+ })
78
50
 
79
- main()
@@ -14486,5 +14486,5 @@
14486
14486
  ]
14487
14487
  }
14488
14488
  },
14489
- "version": "10.3.1-alpha.0"
14489
+ "version": "10.4.0-beta.0"
14490
14490
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "heroku",
3
3
  "description": "CLI to interact with Heroku",
4
- "version": "10.3.1-alpha.0",
4
+ "version": "10.4.0-beta.0",
5
5
  "author": "Heroku",
6
6
  "bin": "./bin/run",
7
7
  "bugs": "https://github.com/heroku/cli/issues",
@@ -78,8 +78,7 @@
78
78
  "urijs": "^1.19.11",
79
79
  "validator": "^13.7.0",
80
80
  "word-wrap": "^1.2.5",
81
- "ws": "^6.2.2",
82
- "yargs-parser": "18.1.3"
81
+ "ws": "^6.2.2"
83
82
  },
84
83
  "devDependencies": {
85
84
  "@heroku-cli/schema": "^1.0.25",
@@ -398,5 +397,5 @@
398
397
  "version": "oclif readme --multi && git add README.md ../../docs"
399
398
  },
400
399
  "types": "lib/index.d.ts",
401
- "gitHead": "8a2d0aaa20a89db92a7ecc1e7c054e2ae95d2abb"
400
+ "gitHead": "bfa6b7ec487f0eba872a5cc219570ac3227d7d68"
402
401
  }
@@ -1,235 +0,0 @@
1
- const fs = require('node:fs')
2
- const inquirer = require('inquirer')
3
-
4
- function choicesPrompt(description, choices, required, defaultValue) {
5
- return inquirer.prompt([{
6
- type: 'list',
7
- name: 'choices',
8
- message: description,
9
- choices,
10
- default: defaultValue,
11
- validate(input) {
12
- if (!required || input) {
13
- return true
14
- }
15
-
16
- return `${description} is required`
17
- },
18
- }])
19
- }
20
-
21
- function prompt(description, required) {
22
- return inquirer.prompt([{
23
- type: 'input',
24
- name: 'input',
25
- message: description,
26
- validate(input) {
27
- if (!required || input.trim()) {
28
- return true
29
- }
30
-
31
- return `${description} is required`
32
- },
33
- }])
34
- }
35
-
36
- function filePrompt(description, defaultPath) {
37
- return inquirer.prompt([{
38
- type: 'input',
39
- name: 'path',
40
- message: description,
41
- default: defaultPath,
42
- validate(input) {
43
- if (fs.existsSync(input)) {
44
- return true
45
- }
46
-
47
- return 'File does not exist. Please enter a valid file path.'
48
- },
49
- }])
50
- }
51
-
52
- const showBooleanPrompt = async (commandFlag, userInputMap, defaultOption) => {
53
- const {description, default: defaultValue, name: flagOrArgName} = commandFlag
54
- const choice = await choicesPrompt(description, [
55
- {name: 'yes', value: true},
56
- {name: 'no', value: false},
57
- ], defaultOption)
58
-
59
- // user cancelled
60
- if (choice === undefined || choice === 'Cancel') {
61
- return true
62
- }
63
-
64
- if (choice === 'Yes') {
65
- userInputMap.set(flagOrArgName, defaultValue)
66
- }
67
-
68
- return false
69
- }
70
-
71
- const showOtherDialog = async (commandFlagOrArg, userInputMap) => {
72
- const {description, default: defaultValue, options, required, name: flagOrArgName} = commandFlagOrArg
73
-
74
- let input
75
- const isFileInput = description?.includes('absolute path')
76
- if (isFileInput) {
77
- input = await filePrompt(description, '')
78
- } else if (options) {
79
- const choices = options.map(option => ({name: option, value: option}))
80
- input = await choicesPrompt(`Select the ${description}`, choices, required, defaultValue)
81
- } else {
82
- input = await prompt(`${description.slice(0, 1).toUpperCase()}${description.slice(1)} (${required ? 'required' : 'optional - press "Enter" to bypass'})`, required)
83
- }
84
-
85
- if (input === undefined) {
86
- return true
87
- }
88
-
89
- if (input !== '') {
90
- userInputMap.set(flagOrArgName, input)
91
- }
92
-
93
- return false
94
- }
95
-
96
- function collectInputsFromManifest(flagsOrArgsManifest, omitOptional) {
97
- const requiredInputs = []
98
- const optionalInputs = []
99
-
100
- // Prioritize options over booleans to
101
- // prevent the user from yo-yo back and
102
- // forth between the different input dialogs
103
- const keysByType = Object.keys(flagsOrArgsManifest).sort((a, b) => {
104
- const {type: aType} = flagsOrArgsManifest[a]
105
- const {type: bType} = flagsOrArgsManifest[b]
106
- if (aType === bType) {
107
- return 0
108
- }
109
-
110
- if (aType === 'option') {
111
- return -1
112
- }
113
-
114
- if (bType === 'option') {
115
- return 1
116
- }
117
-
118
- return 0
119
- })
120
-
121
- keysByType.forEach(key => {
122
- const isRequired = Reflect.get(flagsOrArgsManifest[key], 'required');
123
- (isRequired ? requiredInputs : optionalInputs).push(key)
124
- })
125
- // Prioritize required inputs
126
- // over optional inputs when
127
- // prompting the user.
128
- // required inputs are sorted
129
- // alphabetically. optional
130
- // inputs are sorted alphabetically
131
- // and then pushed to the end of
132
- // the list.
133
- requiredInputs.sort((a, b) => {
134
- if (a < b) {
135
- return -1
136
- }
137
-
138
- if (a > b) {
139
- return 1
140
- }
141
-
142
- return 0
143
- })
144
- // Include optional only when not explicitly omitted
145
- return omitOptional ? requiredInputs : [...requiredInputs, ...optionalInputs]
146
- }
147
-
148
- async function getInput(flagsOrArgsManifest, userInputMap, omitOptional) {
149
- const flagsOrArgs = collectInputsFromManifest(flagsOrArgsManifest, omitOptional)
150
-
151
- for (const flagOrArg of flagsOrArgs) {
152
- const {name, description, type, hidden} = flagsOrArgsManifest[flagOrArg]
153
- if (userInputMap.has(name)) {
154
- continue
155
- }
156
-
157
- // hidden args and flags may be exposed later
158
- // based on the user type. For now, skip them.
159
- if (!description || hidden) {
160
- continue
161
- }
162
-
163
- const cancelled = await (type === 'boolean' ? showBooleanPrompt : showOtherDialog)(flagsOrArgsManifest[flagOrArg], userInputMap)
164
- if (cancelled) {
165
- return true
166
- }
167
- }
168
-
169
- return false
170
- }
171
-
172
- async function promptForInputs(commandName, commandManifest, userArgs, userFlags) {
173
- const {args, flags} = commandManifest
174
-
175
- const userInputByArg = new Map()
176
- Object.keys(args).forEach((argKey, index) => {
177
- if (userArgs[index]) {
178
- userInputByArg.set(argKey, userArgs[index])
179
- }
180
- })
181
-
182
- let cancelled = await getInput(args, userInputByArg)
183
- if (cancelled) {
184
- return {userInputByArg}
185
- }
186
-
187
- const userInputByFlag = new Map()
188
- Object.keys(flags).forEach(flagKey => {
189
- const {name, char} = flags[flagKey]
190
- if (userFlags[name] || userFlags[char]) {
191
- userInputByFlag.set(flagKey, userFlags[flagKey])
192
- }
193
- })
194
- cancelled = await getInput(flags, userInputByFlag)
195
- if (cancelled) {
196
- return
197
- }
198
-
199
- return {userInputByArg, userInputByFlag}
200
- }
201
-
202
- module.exports.promptUser = async (config, commandName, args, flags) => {
203
- const commandMeta = config.findCommand(commandName)
204
- if (!commandMeta) {
205
- process.stderr.write(`"${commandName}" not a valid command\n$ `)
206
- return
207
- }
208
-
209
- const {userInputByArg, userInputByFlag} = await promptForInputs(commandName, commandMeta, args, flags)
210
-
211
- try {
212
- for (const [, {input: argValue}] of userInputByArg) {
213
- if (argValue) {
214
- args.push(argValue)
215
- }
216
- }
217
-
218
- for (const [flagName, {input: flagValue}] of userInputByFlag) {
219
- if (!flagValue) {
220
- continue
221
- }
222
-
223
- if (flagValue === true) {
224
- args.push(`--${flagName}`)
225
- continue
226
- }
227
-
228
- args.push(`--${flagName}`, flagValue)
229
- }
230
-
231
- return args
232
- } catch (error) {
233
- process.stderr.write(error.message)
234
- }
235
- }
@@ -1,581 +0,0 @@
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} = require('@oclif/core')
9
-
10
- const historyFile = path.join(process.env.HOME || process.env.USERPROFILE, '.heroku_repl_history')
11
- const stateFile = path.join(process.env.HOME || process.env.USERPROFILE, '.heroku_repl_state')
12
-
13
- const maxHistory = 1000
14
-
15
- /**
16
- * Map of commands used to provide completion
17
- * data. The key is the flag or arg name to
18
- * get data for and the value is an array containing
19
- * the command name and an array of arguments to
20
- * pass to the command if needed.
21
- *
22
- * @example
23
- * heroku > pipelines:create --app <tab><tab>
24
- * heroku > spaces:create --team <tab><tab>
25
- */
26
- const completionCommandByName = new Map([
27
- ['app', ['apps', ['--all', '--json']]],
28
- ['org', ['orgs', ['--json']]],
29
- ['team', ['teams', ['--json']]],
30
- ['space', ['spaces', ['--json']]],
31
- ['pipeline', ['pipelines', ['--json']]],
32
- ['addon', ['addons', ['--json']]],
33
- ['domain', ['domains', ['--json']]],
34
- ['dyno', ['ps', ['--json']]],
35
- ['release', ['releases', ['--json']]],
36
- ['stack', ['apps:stacks', ['--json']]],
37
- ])
38
-
39
- /**
40
- * Map of completion data by flag or arg name.
41
- * This is used as a cache for completion data
42
- * that is retrieved from a remote source.
43
- *
44
- * No attempt is made to invalidate these caches
45
- * at runtime but they are not preserved between
46
- * sessions.
47
- */
48
- const completionResultsByName = new Map()
49
-
50
- class HerokuRepl {
51
- /**
52
- * The OClif config object containing
53
- * the command metadata and the means
54
- * to execute commands
55
- */
56
- #config
57
-
58
- /**
59
- * A map of key/value pairs used for
60
- * the 'set' and 'unset' command
61
- */
62
- #setValues = new Map()
63
-
64
- /**
65
- * The history of the REPL commands used
66
- */
67
- #history = []
68
-
69
- /**
70
- * The write stream for the history file
71
- */
72
- #historyStream
73
-
74
- /**
75
- * The readline interface used for the REPL
76
- */
77
- #rl = readline.createInterface({
78
- input: process.stdin,
79
- output: process.stdout,
80
- prompt: 'heroku > ',
81
- removeHistoryDuplicates: true,
82
- historySize: maxHistory,
83
- completer: async line => {
84
- const [command, ...parts] = line.split(' ')
85
- if (command === 'set') {
86
- return this.#buildSetCompletions(parts)
87
- }
88
-
89
- const commandMeta = this.#config.findCommand(command)
90
- if (!commandMeta) {
91
- const matches = this.#config.commands.filter(({id}) => id.startsWith(command))
92
- return [matches.map(({id}) => id).sort(), line]
93
- }
94
-
95
- return this.#buildCompletions(commandMeta, parts)
96
- },
97
- })
98
-
99
- /**
100
- * Constructs a new instance of the HerokuRepl class.
101
- *
102
- * @param {Config} config The oclif core config object
103
- */
104
- constructor(config) {
105
- this.#prepareHistory()
106
- this.#loadState()
107
- this.#config = config
108
- }
109
-
110
- /**
111
- * Prepares the REPL history by loading
112
- * the previous history from the history file
113
- * and opening a write stream for new entries.
114
- *
115
- * @returns {Promise<void>} a promise that resolves when the history has been loaded
116
- */
117
- #prepareHistory() {
118
- this.#historyStream = fs.createWriteStream(historyFile, {
119
- flags: 'a',
120
- encoding: 'utf8',
121
- })
122
-
123
- // Load existing history first
124
- if (fs.existsSync(historyFile)) {
125
- this.#history = fs.readFileSync(historyFile, 'utf8')
126
- .split('\n')
127
- .filter(line => line.trim())
128
- .reverse()
129
- .splice(0, maxHistory)
130
-
131
- this.#rl.history.push(...this.#history)
132
- this.#rl.history
133
- }
134
- }
135
-
136
- /**
137
- * Loads the previous session state from the state file.
138
- * @returns {void}
139
- */
140
- #loadState() {
141
- if (fs.existsSync(stateFile)) {
142
- try {
143
- const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
144
- for (const entry of Object.entries(state)) {
145
- this.#updateFlagsByName('set', entry, true)
146
- }
147
-
148
- process.stdout.write('session restored')
149
- } catch {
150
- // noop
151
- }
152
- }
153
- }
154
-
155
- /**
156
- * Waits for the REPL to finish and then
157
- * writes the current session state to the state file.
158
- *
159
- * @returns {Promise<void>} a promise that resolves when the REPL is done
160
- */
161
- async done() {
162
- await new Promise(resolve => {
163
- this.#rl.once('close', () => {
164
- this.#historyStream.close()
165
- fs.writeFileSync(stateFile, JSON.stringify(Object.fromEntries(this.#setValues)), 'utf8')
166
- resolve()
167
- })
168
- })
169
- }
170
-
171
- /**
172
- * Process a line of input.
173
- * This method will parse the input
174
- * and run the command if it is valid.
175
- * If the command is invalid, an error
176
- * message will be displayed.
177
- *
178
- * @param {string} line the line to process
179
- * @returns void
180
- */
181
- start() {
182
- this.#rl.on('line', this.#processLine)
183
- this.#rl.prompt()
184
- }
185
-
186
- /**
187
- * Processes the line received from the terminal stdin
188
- *
189
- * @param {string} input the line to process
190
- * @returns {Promise<void>} a promise that resolves when the command has been executed
191
- */
192
- #processLine = async input => {
193
- this.#history.push(input)
194
- this.#historyStream.write(input + '\n')
195
-
196
- const [command, ...args] = input.split(' ')
197
- if (command === 'exit') {
198
- process.exit(0)
199
- }
200
-
201
- if (command === 'history') {
202
- process.stdout.write(this.#history.join('\n'))
203
- this.#rl.prompt()
204
- return
205
- }
206
-
207
- if (command === 'set' || command === 'unset') {
208
- this.#updateFlagsByName(command, args)
209
- this.#rl.prompt()
210
- return
211
- }
212
-
213
- const cmd = this.#config.findCommand(command)
214
-
215
- if (!cmd) {
216
- console.error(`"${command}" is not a valid command`)
217
- this.#rl.prompt()
218
- return
219
- }
220
-
221
- try {
222
- const {flags} = cmd
223
- for (const [key, value] of this.#setValues) {
224
- if (Reflect.has(flags, key)) {
225
- args.push(`--${key}`, value)
226
- }
227
- }
228
-
229
- // Any commands that prompt the user will cause
230
- // the REPL to enter an invalid state. We need
231
- // to pause the readline interface and restore
232
- // it when the command is done.
233
- process.stdin.setRawMode(false)
234
- this.#rl.pause()
235
- this.#rl.off('line', this.#processLine)
236
- await this.#config.runCommand(command, args.filter(Boolean))
237
- } catch (error) {
238
- console.error(error.message)
239
- } finally {
240
- process.stdin.setRawMode(true)
241
- this.#rl.resume()
242
- this.#rl.on('line', this.#processLine)
243
- // Force readline to refresh the current line
244
- this.#rl.write(null, {ctrl: true, name: 'u'})
245
- }
246
- }
247
-
248
- /**
249
- * Updates the session state based on the command and args.
250
- *
251
- * @param {'set'|'unset'} command either 'set' or 'unset'
252
- * @param {string[]} args an array of arg names
253
- * @param {boolean} omitConfirmation when false. no confirmation is printed to stdout
254
- * @returns {void}
255
- */
256
- #updateFlagsByName(command, args, omitConfirmation) {
257
- if (command === 'set') {
258
- const [key, value] = args
259
- if (key && value) {
260
- this.#setValues.set(key, value)
261
-
262
- if (!omitConfirmation) {
263
- process.stdout.write(`setting --${key} to ${value}\n`)
264
- }
265
-
266
- if (key === 'app') {
267
- this.#rl.setPrompt(`${value} > `)
268
- }
269
- } else {
270
- const values = []
271
- for (const [flag, value] of this.#setValues) {
272
- values.push({flag, value})
273
- }
274
-
275
- if (values.length === 0) {
276
- return console.info('no flags set')
277
- }
278
-
279
- ux.table(values, {
280
- flag: {header: 'Flag'},
281
- value: {header: 'Value'},
282
- })
283
- }
284
- }
285
-
286
- if (command === 'unset') {
287
- const [key] = args
288
-
289
- if (!omitConfirmation) {
290
- process.stdout.write(`unsetting --${key}\n`)
291
- }
292
-
293
- this.#setValues.delete(key)
294
- if (key === 'app') {
295
- this.#rl.setPrompt('heroku > ')
296
- }
297
- }
298
- }
299
-
300
- /**
301
- * Build completions for a command.
302
- * The completions are based on the
303
- * metadata for the command and the
304
- * user input.
305
- *
306
- * @param {Record<string, unknown>} commandMeta the metadata for the command
307
- * @param {string[]} flagsOrArgs the flags or args for the command
308
- * @returns {Promise<[string[], string]>} the completions and the current input
309
- */
310
- async #buildCompletions(commandMeta, flagsOrArgs = []) {
311
- const {args, flags} = commandMeta
312
- const {requiredInputs: requiredFlags, optionalInputs: optionalFlags} = this.#collectInputsFromManifest(flags)
313
- const {requiredInputs: requiredArgs, optionalInputs: optionalArgs} = this.#collectInputsFromManifest(args)
314
-
315
- const {_: userArgs, ...userFlags} = yargs(flagsOrArgs, {
316
- configuration: {
317
- 'camel-case-expansion': false,
318
- 'boolean-negation': false,
319
- },
320
- })
321
- const current = flagsOrArgs[flagsOrArgs.length - 1] ?? ''
322
-
323
- // Order of precedence:
324
- // 1. Required flags
325
- // 2. Required args
326
- // 3. Optional flags
327
- // 4. Optional args
328
- // 5. End of line
329
- // Flags *must* occur first since they may influence
330
- // the completions for args.
331
- return await this.#getCompletionsForFlag(current, requiredFlags, userFlags, commandMeta) ||
332
- await this.#getCompletionsForArg(current, requiredArgs, userArgs, commandMeta) ||
333
- await this.#getCompletionsForFlag(current, optionalFlags, userFlags, commandMeta) ||
334
- await this.#getCompletionsForArg(current, optionalArgs, userArgs, commandMeta) ||
335
- this.#getCompletionsForEndOfLine(flags, userFlags)
336
- }
337
-
338
- /**
339
- * Get completions for a command.
340
- * The completions are based on the
341
- * metadata for the command and the
342
- * user input.
343
- *
344
- * @param {[string, string]} parts the parts for a line to get completions for
345
- * @returns {[string[], string]} the completions and the current input
346
- */
347
- async #buildSetCompletions(parts) {
348
- const [name, current] = parts
349
- if (parts.length > 0 && completionCommandByName.has(name)) {
350
- return [await this.#getCompletion(name, current), current]
351
- }
352
-
353
- // Critical to completions operating as expected;
354
- // the completions must be filtered to omit keys
355
- // that do not match our name (if a name exists).
356
- const completions = [...completionCommandByName.keys()]
357
- .filter(c => !name || c.startsWith(name))
358
-
359
- return [completions, name ?? current]
360
- }
361
-
362
- /**
363
- * Get completions for the end of the line.
364
- *
365
- * @param {Record<string, unknown>} flags the flags for the command
366
- * @param {Record<string, unknown>} userFlags the flags that have already been used
367
- * @returns {[string[], string]} the completions and the current input
368
- */
369
- #getCompletionsForEndOfLine(flags, userFlags) {
370
- const flagKeys = Object.keys(userFlags)
371
- // If there are no more flags to complete,
372
- // return an empty array.
373
- return flagKeys.length < Object.keys(flags).length ? [[' --'], ''] : [[], '']
374
- }
375
-
376
- /**
377
- * Get completions for a flag or flag value.
378
- *
379
- * @param {string} current the current input
380
- * @param {string[]} flags the flags for the command
381
- * @param {string[]} userFlags the flags that have already been used
382
- * @param {Record<string, unknown>} commandMeta the metadata for the command
383
- * @return {Promise<[string[], string]>} the completions and the current input
384
- */
385
- async #getCompletionsForFlag(current, flags, userFlags, commandMeta) {
386
- // flag completion for long and short flags.
387
- // flags that have already been used are
388
- // not included in the completions.
389
- const isFlag = current.startsWith('-')
390
- if (isFlag) {
391
- const isLongFlag = current.startsWith('--')
392
- const rawFlag = isLongFlag ? current.slice(2) : current.slice(1)
393
- const matched = flags
394
- .map(f => isLongFlag ? f.long : f.short)
395
- .filter(flag => !Reflect.has(userFlags, flag) && (!rawFlag || flag.startsWith(rawFlag)))
396
-
397
- if (matched.length > 0) {
398
- return [matched, rawFlag]
399
- }
400
- }
401
-
402
- // Does the flag have a value?
403
- const flagKeys = Object.keys(userFlags)
404
- const flag = flagKeys[flagKeys.length - 1]
405
- if (!flag || !current) {
406
- return null
407
- }
408
-
409
- const {options, type} = commandMeta.flags[flag] ?? {}
410
- // Options are defined in the metadata
411
- // for the command. If the flag has options
412
- // defined, we will attempt to complete
413
- // based on the options.
414
- if (type === 'option') {
415
- if (options?.length > 0) {
416
- const optionComplete = options.includes(current)
417
- const matched = options.filter(o => o.startsWith(current))
418
-
419
- if (!optionComplete) {
420
- return matched.length > 0 ? [matched, current] : [options, current]
421
- }
422
- }
423
-
424
- return [await this.#getCompletion(flag, isFlag ? '' : current), current]
425
- }
426
- }
427
-
428
- /**
429
- * Get completions for a flag.
430
- *
431
- * @param {string} flag the flag to get the completion for
432
- * @param {string} startsWith the string to match against
433
- * @returns {Promise<[string[]]>} the completions
434
- */
435
- async #getCompletion(flag, startsWith) {
436
- // attempt to retrieve the options from the
437
- // Heroku API. If the options have already
438
- // been retrieved, they will be cached.
439
- if (completionCommandByName.has(flag)) {
440
- let result
441
- if (completionResultsByName.has(flag)) {
442
- result = completionResultsByName.get(flag)
443
- }
444
-
445
- if (!result || result.length === 0) {
446
- const [command, args] = completionCommandByName.get(flag)
447
- const completionsStr = await this.#captureStdout(() => this.#config.runCommand(command, args)) ?? '[]'
448
- result = JSON.parse(util.stripVTControlCharacters(completionsStr))
449
- completionResultsByName.set(flag, result)
450
- }
451
-
452
- const matched = result
453
- .map(obj => obj.name ?? obj.id)
454
- .filter(name => !startsWith || name.startsWith(startsWith))
455
- .sort()
456
-
457
- return matched
458
- }
459
- }
460
-
461
- /**
462
- * Capture stdout by deflecting it to a
463
- * trap function and returning the output.
464
- *
465
- * This is useful for silently capturing the output
466
- * of a command that normally prints to stdout.
467
- *
468
- * @param {CallableFunction} fn the function to capture stdout for
469
- * @returns {Promise<string>} the output from stdout
470
- */
471
- async #captureStdout(fn) {
472
- const output = []
473
- const originalWrite = process.stdout.write
474
- // Replace stdout.write temporarily
475
- process.stdout.write = chunk => {
476
- output.push(typeof chunk === 'string' ? chunk : chunk.toString())
477
- return true
478
- }
479
-
480
- try {
481
- await fn()
482
- return output.join('')
483
- } finally {
484
- // Restore original stdout
485
- process.stdout.write = originalWrite
486
- }
487
- }
488
-
489
- /**
490
- * Get completions for an arg.
491
- *
492
- * @param {string} current the current input
493
- * @param {({long: string}[])} args the args for the command
494
- * @param {string[]} userArgs the args that have already been used
495
- * @returns {Promise<[string[], string] | null>} the completions and the current input
496
- */
497
- async #getCompletionsForArg(current, args = [], userArgs = []) {
498
- if (userArgs.length <= args.length) {
499
- const arg = args[userArgs.length - 1]
500
- if (arg) {
501
- const {long} = arg
502
- if (completionCommandByName.has(long)) {
503
- const completions = await this.#getCompletion(long, current)
504
- if (completions.length > 0) {
505
- return [completions, current]
506
- }
507
- }
508
-
509
- return [[`<${long}>`], current]
510
- }
511
- }
512
-
513
- return null
514
- }
515
-
516
- /**
517
- * Collect inputs from the command manifest and sorts
518
- * them by type and then by required status.
519
- *
520
- * @param {Record<string, unknown>} commandMeta the metadata from the command manifest
521
- * @returns {{requiredInputs: {long: string, short: string}[], optionalInputs: {long: string, short: string}[]}} the inputs from the command manifest
522
- */
523
- #collectInputsFromManifest(commandMeta) {
524
- const requiredInputs = []
525
- const optionalInputs = []
526
-
527
- // Prioritize options over booleans
528
- const keysByType = Object.keys(commandMeta).sort((a, b) => {
529
- const {type: aType} = commandMeta[a]
530
- const {type: bType} = commandMeta[b]
531
- if (aType === bType) {
532
- return 0
533
- }
534
-
535
- if (aType === 'option') {
536
- return -1
537
- }
538
-
539
- if (bType === 'option') {
540
- return 1
541
- }
542
-
543
- return 0
544
- })
545
-
546
- keysByType.forEach(long => {
547
- const {required: isRequired, char: short} = commandMeta[long]
548
- if (isRequired) {
549
- requiredInputs.push({long, short})
550
- return
551
- }
552
-
553
- optionalInputs.push({long, short})
554
- })
555
- // Prioritize required inputs
556
- // over optional inputs
557
- // required inputs are sorted
558
- // alphabetically. optional
559
- // inputs are sorted alphabetically
560
- // and then pushed to the end of
561
- // the list.
562
- requiredInputs.sort((a, b) => {
563
- if (a.long < b.long) {
564
- return -1
565
- }
566
-
567
- if (a.long > b.long) {
568
- return 1
569
- }
570
-
571
- return 0
572
- })
573
-
574
- return {requiredInputs, optionalInputs}
575
- }
576
- }
577
- module.exports.herokuRepl = async function (config) {
578
- const repl = new HerokuRepl(config)
579
- repl.start()
580
- return repl.done()
581
- }