heroku 10.6.1-beta.0 → 10.6.2-alpha.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.
@@ -0,0 +1,235 @@
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
+ }
@@ -0,0 +1,620 @@
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
+ const os = require('node:os')
10
+
11
+ const historyFile = path.join(process.env.HOME || process.env.USERPROFILE || os.homedir(), '.heroku_repl_history')
12
+ const stateFile = path.join(process.env.HOME || process.env.USERPROFILE || os.homedir(), '.heroku_repl_state')
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 = readline.createInterface({
79
+ input: process.stdin,
80
+ output: process.stdout,
81
+ prompt: 'heroku > ',
82
+ removeHistoryDuplicates: true,
83
+ historySize: maxHistory,
84
+ completer: async line => {
85
+ if (mcpMode) {
86
+ return [[], line]
87
+ }
88
+
89
+ const [command, ...parts] = line.split(' ')
90
+ if (command === 'set') {
91
+ return this.#buildSetCompletions(parts)
92
+ }
93
+
94
+ const commandMeta = this.#config.findCommand(command)
95
+ if (!commandMeta) {
96
+ const matches = this.#config.commands.filter(({id}) => id.startsWith(command))
97
+ return [matches.map(({id}) => id).sort(), line]
98
+ }
99
+
100
+ return this.#buildCompletions(commandMeta, parts)
101
+ },
102
+ })
103
+
104
+ /**
105
+ * Constructs a new instance of the HerokuRepl class.
106
+ *
107
+ * @param {Config} config The oclif core config object
108
+ */
109
+ constructor(config) {
110
+ if (!mcpMode) {
111
+ this.#prepareHistory()
112
+ this.#loadState()
113
+ }
114
+
115
+ this.#config = config
116
+ }
117
+
118
+ /**
119
+ * Prepares the REPL history by loading
120
+ * the previous history from the history file
121
+ * and opening a write stream for new entries.
122
+ *
123
+ * @returns {Promise<void>} a promise that resolves when the history has been loaded
124
+ */
125
+ #prepareHistory() {
126
+ this.#historyStream = fs.createWriteStream(historyFile, {
127
+ flags: 'a',
128
+ encoding: 'utf8',
129
+ })
130
+
131
+ // Load existing history first
132
+ if (fs.existsSync(historyFile)) {
133
+ this.#history = fs.readFileSync(historyFile, 'utf8')
134
+ .split('\n')
135
+ .filter(line => line.trim())
136
+ .reverse()
137
+ .splice(0, maxHistory)
138
+
139
+ this.#rl.history.push(...this.#history)
140
+ this.#rl.history
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Loads the previous session state from the state file.
146
+ * @returns {void}
147
+ */
148
+ #loadState() {
149
+ if (fs.existsSync(stateFile)) {
150
+ try {
151
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'))
152
+ for (const entry of Object.entries(state)) {
153
+ this.#updateFlagsByName('set', entry, true)
154
+ }
155
+
156
+ process.stdout.write('session restored')
157
+ } catch {
158
+ // noop
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Waits for the REPL to finish and then
165
+ * writes the current session state to the state file.
166
+ *
167
+ * @returns {Promise<void>} a promise that resolves when the REPL is done
168
+ */
169
+ async done() {
170
+ await new Promise(resolve => {
171
+ this.#rl.once('close', () => {
172
+ this.#historyStream?.close()
173
+ fs.writeFileSync(stateFile, JSON.stringify(Object.fromEntries(this.#setValues)), 'utf8')
174
+ resolve()
175
+ })
176
+ })
177
+ }
178
+
179
+ /**
180
+ * Process a line of input.
181
+ * This method will parse the input
182
+ * and run the command if it is valid.
183
+ * If the command is invalid, an error
184
+ * message will be displayed.
185
+ *
186
+ * @param {string} line the line to process
187
+ * @returns void
188
+ */
189
+ start() {
190
+ this.#rl.on('line', this.#processLine)
191
+ this.#rl.prompt()
192
+ }
193
+
194
+ /**
195
+ * Processes the line received from the terminal stdin
196
+ *
197
+ * @param {string} input the line to process
198
+ * @returns {Promise<void>} a promise that resolves when the command has been executed
199
+ */
200
+ #processLine = async input => {
201
+ this.#history.push(input)
202
+ this.#historyStream?.write(input + '\n')
203
+
204
+ const {_: [command, ...positionalArgs], ...flags} = yargs(input, {
205
+ configuration: {
206
+ 'camel-case-expansion': false,
207
+ 'boolean-negation': false,
208
+ },
209
+ })
210
+ const args = Object.entries(flags).flatMap(([key, value]) => {
211
+ if (typeof value === 'string') {
212
+ return [`--${key}`, value]
213
+ }
214
+
215
+ return [`--${key}`]
216
+ }).concat(positionalArgs)
217
+
218
+ if (command === 'exit') {
219
+ process.exit(0)
220
+ }
221
+
222
+ if (command === 'history') {
223
+ process.stdout.write(this.#history.join('\n'))
224
+ this.#rl.prompt()
225
+ return
226
+ }
227
+
228
+ if (command === 'set' || command === 'unset') {
229
+ this.#updateFlagsByName(command, args)
230
+ this.#rl.prompt()
231
+ return
232
+ }
233
+
234
+ const cmd = this.#config.findCommand(command)
235
+
236
+ if (!cmd) {
237
+ console.error(`"${command}" is not a valid command`)
238
+ this.#rl.prompt()
239
+ return
240
+ }
241
+
242
+ try {
243
+ const {flags} = cmd
244
+ for (const [key, value] of this.#setValues) {
245
+ if (Reflect.has(flags, key)) {
246
+ args.push(`--${key}`, value)
247
+ }
248
+ }
249
+
250
+ // Any commands that prompt the user will cause
251
+ // the REPL to enter an invalid state. We need
252
+ // to pause the readline interface and restore
253
+ // it when the command is done.
254
+ if (process.stdin.isTTY) {
255
+ process.stdin.setRawMode(false)
256
+ }
257
+
258
+ this.#rl.pause()
259
+ this.#rl.off('line', this.#processLine)
260
+ if (mcpMode) {
261
+ process.stdout.write('<<<BEGIN RESULTS>>>\n')
262
+ }
263
+
264
+ await this.#config.runCommand(command, args.filter(Boolean))
265
+ } catch (error) {
266
+ if (mcpMode) {
267
+ process.stderr.write(`<<<ERROR>>>\n${error.message}\n<<<END ERROR>>>\n`)
268
+ } else {
269
+ console.error(error.message)
270
+ }
271
+ } finally {
272
+ if (process.stdin.isTTY) {
273
+ process.stdin.setRawMode(true)
274
+ }
275
+
276
+ if (mcpMode) {
277
+ process.stdout.write('<<<END RESULTS>>>\n')
278
+ }
279
+
280
+ this.#rl.resume()
281
+ this.#rl.on('line', this.#processLine)
282
+ // Force readline to refresh the current line
283
+ this.#rl.write(null, {ctrl: true, name: 'u'})
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Updates the session state based on the command and args.
289
+ *
290
+ * @param {'set'|'unset'} command either 'set' or 'unset'
291
+ * @param {string[]} args an array of arg names
292
+ * @param {boolean} omitConfirmation when false. no confirmation is printed to stdout
293
+ * @returns {void}
294
+ */
295
+ #updateFlagsByName(command, args, omitConfirmation) {
296
+ if (command === 'set') {
297
+ const [key, value] = args
298
+ if (key && value) {
299
+ this.#setValues.set(key, value)
300
+
301
+ if (!omitConfirmation) {
302
+ process.stdout.write(`setting --${key} to ${value}\n`)
303
+ }
304
+
305
+ if (key === 'app') {
306
+ this.#rl.setPrompt(`${value} > `)
307
+ }
308
+ } else {
309
+ const values = []
310
+ for (const [flag, value] of this.#setValues) {
311
+ values.push({flag, value})
312
+ }
313
+
314
+ if (values.length === 0) {
315
+ return console.info('no flags set')
316
+ }
317
+
318
+ ux.table(values, {
319
+ flag: {header: 'Flag'},
320
+ value: {header: 'Value'},
321
+ })
322
+ }
323
+ }
324
+
325
+ if (command === 'unset') {
326
+ const [key] = args
327
+
328
+ if (!omitConfirmation) {
329
+ process.stdout.write(`unsetting --${key}\n`)
330
+ }
331
+
332
+ this.#setValues.delete(key)
333
+ if (key === 'app') {
334
+ this.#rl.setPrompt('heroku > ')
335
+ }
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Build completions for a command.
341
+ * The completions are based on the
342
+ * metadata for the command and the
343
+ * user input.
344
+ *
345
+ * @param {Record<string, unknown>} commandMeta the metadata for the command
346
+ * @param {string[]} flagsOrArgs the flags or args for the command
347
+ * @returns {Promise<[string[], string]>} the completions and the current input
348
+ */
349
+ async #buildCompletions(commandMeta, flagsOrArgs = []) {
350
+ const {args, flags} = commandMeta
351
+ const {requiredInputs: requiredFlags, optionalInputs: optionalFlags} = this.#collectInputsFromManifest(flags)
352
+ const {requiredInputs: requiredArgs, optionalInputs: optionalArgs} = this.#collectInputsFromManifest(args)
353
+
354
+ const {_: userArgs, ...userFlags} = yargs(flagsOrArgs, {
355
+ configuration: {
356
+ 'camel-case-expansion': false,
357
+ 'boolean-negation': false,
358
+ },
359
+ })
360
+ const current = flagsOrArgs[flagsOrArgs.length - 1] ?? ''
361
+
362
+ // Order of precedence:
363
+ // 1. Required flags
364
+ // 2. Required args
365
+ // 3. Optional flags
366
+ // 4. Optional args
367
+ // 5. End of line
368
+ // Flags *must* occur first since they may influence
369
+ // the completions for args.
370
+ return await this.#getCompletionsForFlag(current, requiredFlags, userFlags, commandMeta) ||
371
+ await this.#getCompletionsForArg(current, requiredArgs, userArgs, commandMeta) ||
372
+ await this.#getCompletionsForFlag(current, optionalFlags, userFlags, commandMeta) ||
373
+ await this.#getCompletionsForArg(current, optionalArgs, userArgs, commandMeta) ||
374
+ this.#getCompletionsForEndOfLine(flags, userFlags)
375
+ }
376
+
377
+ /**
378
+ * Get completions for a command.
379
+ * The completions are based on the
380
+ * metadata for the command and the
381
+ * user input.
382
+ *
383
+ * @param {[string, string]} parts the parts for a line to get completions for
384
+ * @returns {[string[], string]} the completions and the current input
385
+ */
386
+ async #buildSetCompletions(parts) {
387
+ const [name, current] = parts
388
+ if (parts.length > 0 && completionCommandByName.has(name)) {
389
+ return [await this.#getCompletion(name, current), current]
390
+ }
391
+
392
+ // Critical to completions operating as expected;
393
+ // the completions must be filtered to omit keys
394
+ // that do not match our name (if a name exists).
395
+ const completions = [...completionCommandByName.keys()]
396
+ .filter(c => !name || c.startsWith(name))
397
+
398
+ return [completions, name ?? current]
399
+ }
400
+
401
+ /**
402
+ * Get completions for the end of the line.
403
+ *
404
+ * @param {Record<string, unknown>} flags the flags for the command
405
+ * @param {Record<string, unknown>} userFlags the flags that have already been used
406
+ * @returns {[string[], string]} the completions and the current input
407
+ */
408
+ #getCompletionsForEndOfLine(flags, userFlags) {
409
+ const flagKeys = Object.keys(userFlags)
410
+ // If there are no more flags to complete,
411
+ // return an empty array.
412
+ return flagKeys.length < Object.keys(flags).length ? [[' --'], ''] : [[], '']
413
+ }
414
+
415
+ /**
416
+ * Get completions for a flag or flag value.
417
+ *
418
+ * @param {string} current the current input
419
+ * @param {string[]} flags the flags for the command
420
+ * @param {string[]} userFlags the flags that have already been used
421
+ * @param {Record<string, unknown>} commandMeta the metadata for the command
422
+ * @return {Promise<[string[], string]>} the completions and the current input
423
+ */
424
+ async #getCompletionsForFlag(current, flags, userFlags, commandMeta) {
425
+ // flag completion for long and short flags.
426
+ // flags that have already been used are
427
+ // not included in the completions.
428
+ const isFlag = current.startsWith('-')
429
+ if (isFlag) {
430
+ const isLongFlag = current.startsWith('--')
431
+ const rawFlag = isLongFlag ? current.slice(2) : current.slice(1)
432
+ const matched = flags
433
+ .map(f => isLongFlag ? f.long : f.short)
434
+ .filter(flag => !Reflect.has(userFlags, flag) && (!rawFlag || flag.startsWith(rawFlag)))
435
+
436
+ if (matched.length > 0) {
437
+ return [matched, rawFlag]
438
+ }
439
+ }
440
+
441
+ // Does the flag have a value?
442
+ const flagKeys = Object.keys(userFlags)
443
+ const flag = flagKeys[flagKeys.length - 1]
444
+ if (!flag || !current) {
445
+ return null
446
+ }
447
+
448
+ const {options, type} = commandMeta.flags[flag] ?? {}
449
+ // Options are defined in the metadata
450
+ // for the command. If the flag has options
451
+ // defined, we will attempt to complete
452
+ // based on the options.
453
+ if (type === 'option') {
454
+ if (options?.length > 0) {
455
+ const optionComplete = options.includes(current)
456
+ const matched = options.filter(o => o.startsWith(current))
457
+
458
+ if (!optionComplete) {
459
+ return matched.length > 0 ? [matched, current] : [options, current]
460
+ }
461
+ }
462
+
463
+ return [await this.#getCompletion(flag, isFlag ? '' : current), current]
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Get completions for a flag.
469
+ *
470
+ * @param {string} flag the flag to get the completion for
471
+ * @param {string} startsWith the string to match against
472
+ * @returns {Promise<[string[]]>} the completions
473
+ */
474
+ async #getCompletion(flag, startsWith) {
475
+ // attempt to retrieve the options from the
476
+ // Heroku API. If the options have already
477
+ // been retrieved, they will be cached.
478
+ if (completionCommandByName.has(flag)) {
479
+ let result
480
+ if (completionResultsByName.has(flag)) {
481
+ result = completionResultsByName.get(flag)
482
+ }
483
+
484
+ if (!result || result.length === 0) {
485
+ const [command, args] = completionCommandByName.get(flag)
486
+ const completionsStr = await this.#captureStdout(() => this.#config.runCommand(command, args)) ?? '[]'
487
+ result = JSON.parse(util.stripVTControlCharacters(completionsStr))
488
+ completionResultsByName.set(flag, result)
489
+ }
490
+
491
+ const matched = result
492
+ .map(obj => obj.name ?? obj.id)
493
+ .filter(name => !startsWith || name.startsWith(startsWith))
494
+ .sort()
495
+
496
+ return matched
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Capture stdout by deflecting it to a
502
+ * trap function and returning the output.
503
+ *
504
+ * This is useful for silently capturing the output
505
+ * of a command that normally prints to stdout.
506
+ *
507
+ * @param {CallableFunction} fn the function to capture stdout for
508
+ * @returns {Promise<string>} the output from stdout
509
+ */
510
+ async #captureStdout(fn) {
511
+ const output = []
512
+ const originalWrite = process.stdout.write
513
+ // Replace stdout.write temporarily
514
+ process.stdout.write = chunk => {
515
+ output.push(typeof chunk === 'string' ? chunk : chunk.toString())
516
+ return true
517
+ }
518
+
519
+ try {
520
+ await fn()
521
+ return output.join('')
522
+ } finally {
523
+ // Restore original stdout
524
+ process.stdout.write = originalWrite
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Get completions for an arg.
530
+ *
531
+ * @param {string} current the current input
532
+ * @param {({long: string}[])} args the args for the command
533
+ * @param {string[]} userArgs the args that have already been used
534
+ * @returns {Promise<[string[], string] | null>} the completions and the current input
535
+ */
536
+ async #getCompletionsForArg(current, args = [], userArgs = []) {
537
+ if (userArgs.length <= args.length) {
538
+ const arg = args[userArgs.length - 1]
539
+ if (arg) {
540
+ const {long} = arg
541
+ if (completionCommandByName.has(long)) {
542
+ const completions = await this.#getCompletion(long, current)
543
+ if (completions.length > 0) {
544
+ return [completions, current]
545
+ }
546
+ }
547
+
548
+ return [[`<${long}>`], current]
549
+ }
550
+ }
551
+
552
+ return null
553
+ }
554
+
555
+ /**
556
+ * Collect inputs from the command manifest and sorts
557
+ * them by type and then by required status.
558
+ *
559
+ * @param {Record<string, unknown>} commandMeta the metadata from the command manifest
560
+ * @returns {{requiredInputs: {long: string, short: string}[], optionalInputs: {long: string, short: string}[]}} the inputs from the command manifest
561
+ */
562
+ #collectInputsFromManifest(commandMeta) {
563
+ const requiredInputs = []
564
+ const optionalInputs = []
565
+
566
+ // Prioritize options over booleans
567
+ const keysByType = Object.keys(commandMeta).sort((a, b) => {
568
+ const {type: aType} = commandMeta[a]
569
+ const {type: bType} = commandMeta[b]
570
+ if (aType === bType) {
571
+ return 0
572
+ }
573
+
574
+ if (aType === 'option') {
575
+ return -1
576
+ }
577
+
578
+ if (bType === 'option') {
579
+ return 1
580
+ }
581
+
582
+ return 0
583
+ })
584
+
585
+ keysByType.forEach(long => {
586
+ const {required: isRequired, char: short} = commandMeta[long]
587
+ if (isRequired) {
588
+ requiredInputs.push({long, short})
589
+ return
590
+ }
591
+
592
+ optionalInputs.push({long, short})
593
+ })
594
+ // Prioritize required inputs
595
+ // over optional inputs
596
+ // required inputs are sorted
597
+ // alphabetically. optional
598
+ // inputs are sorted alphabetically
599
+ // and then pushed to the end of
600
+ // the list.
601
+ requiredInputs.sort((a, b) => {
602
+ if (a.long < b.long) {
603
+ return -1
604
+ }
605
+
606
+ if (a.long > b.long) {
607
+ return 1
608
+ }
609
+
610
+ return 0
611
+ })
612
+
613
+ return {requiredInputs, optionalInputs}
614
+ }
615
+ }
616
+ module.exports.herokuRepl = async function (config) {
617
+ const repl = new HerokuRepl(config)
618
+ repl.start()
619
+ return repl.done()
620
+ }
package/bin/run CHANGED
@@ -1,10 +1,15 @@
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
+
3
7
  process.env.HEROKU_UPDATE_INSTRUCTIONS = process.env.HEROKU_UPDATE_INSTRUCTIONS || 'update with: "npm update -g heroku"'
4
8
 
5
9
  const now = new Date()
6
10
  const cliStartTime = now.getTime()
7
11
  const globalTelemetry = require('../lib/global_telemetry')
12
+ const yargs = require('yargs-parser')(process.argv.slice(2))
8
13
 
9
14
  process.once('beforeExit', async code => {
10
15
  // capture as successful exit
@@ -38,13 +43,37 @@ process.on('SIGTERM', async () => {
38
43
  globalTelemetry.initializeInstrumentation()
39
44
 
40
45
  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')
41
50
 
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)
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
+ }
47
59
 
48
- return require('@oclif/core/handle')(error)
49
- })
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
+
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)
74
+
75
+ oclifError(error)
76
+ }
77
+ }
50
78
 
79
+ main()
package/lib/analytics.js CHANGED
@@ -15,6 +15,8 @@ class AnalyticsCommand {
15
15
  }
16
16
  async record(opts) {
17
17
  await this.initialize;
18
+ const mcpMode = process.env.HEROKU_MCP_MODE === 'true';
19
+ const mcpServerVersion = process.env.HEROKU_MCP_SERVER_VERSION || 'unknown';
18
20
  const plugin = opts.Command.plugin;
19
21
  if (!plugin) {
20
22
  debug('no plugin found for analytics');
@@ -29,7 +31,7 @@ class AnalyticsCommand {
29
31
  cli: this.config.name,
30
32
  command: opts.Command.id,
31
33
  completion: await this._acAnalytics(opts.Command.id),
32
- version: this.config.version,
34
+ version: `${this.config.version}${mcpMode ? ` (MCP ${mcpServerVersion})` : ''}`,
33
35
  plugin: plugin.name,
34
36
  plugin_version: plugin.version,
35
37
  os: this.config.platform,
@@ -22,20 +22,6 @@ class Create extends command_1.Command {
22
22
  const dollarAmountMonthly = shield ? '$3000' : '$1000';
23
23
  const dollarAmountHourly = shield ? '$4.17' : '$1.39';
24
24
  const spaceType = shield ? 'Shield' : 'Standard';
25
- if (generation === 'fir') {
26
- core_1.ux.warn((0, tsheredoc_1.default) `
27
- Fir Pilot Features
28
- Fir is currently a pilot service that is subject to the Beta Services Terms
29
- (https://www.salesforce.com/company/legal/) or a written Unified Pilot Agreement
30
- if executed by Customer, and applicable terms in the Product Terms Directory
31
- (https://ptd.salesforce.com/?_ga=2.247987783.1372150065.1709219475-629000709.1639001992).
32
- Use of this pilot or beta service is at the Customer's sole discretion.
33
-
34
- Please note that we’re actively developing and adding new features, and not all
35
- existing features are currently available. See the Dev Center
36
- (https://devcenter.heroku.com/articles/generations) for more info.
37
- `);
38
- }
39
25
  core_1.ux.action.start(`Creating space ${color_1.default.green(spaceName)} in team ${color_1.default.cyan(team)}`);
40
26
  const { body: space } = await this.heroku.post('/spaces', {
41
27
  headers: {
@@ -25,7 +25,7 @@ export declare function initializeInstrumentation(): void;
25
25
  export declare function setupTelemetry(config: any, opts: any): {
26
26
  command: any;
27
27
  os: any;
28
- version: any;
28
+ version: string;
29
29
  exitCode: number;
30
30
  exitState: string;
31
31
  cliRunDuration: number;
@@ -57,10 +57,12 @@ function setupTelemetry(config, opts) {
57
57
  const now = new Date();
58
58
  const cmdStartTime = now.getTime();
59
59
  const isRegularCmd = Boolean(opts.Command);
60
+ const mcpMode = process.env.HEROKU_MCP_MODE === 'true';
61
+ const mcpServerVersion = process.env.HEROKU_MCP_SERVER_VERSION || 'unknown';
60
62
  const irregularTelemetryObject = {
61
63
  command: opts.id,
62
64
  os: config.platform,
63
- version: config.version,
65
+ version: `${config.version}${mcpMode ? ` (MCP ${mcpServerVersion})` : ''}`,
64
66
  exitCode: 0,
65
67
  exitState: 'successful',
66
68
  cliRunDuration: 0,
@@ -14652,5 +14652,5 @@
14652
14652
  ]
14653
14653
  }
14654
14654
  },
14655
- "version": "10.6.1-beta.0"
14655
+ "version": "10.6.2-alpha.0"
14656
14656
  }
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.6.1-beta.0",
4
+ "version": "10.6.2-alpha.0",
5
5
  "author": "Heroku",
6
6
  "bin": "./bin/run",
7
7
  "bugs": "https://github.com/heroku/cli/issues",
@@ -79,7 +79,8 @@
79
79
  "validator": "^13.7.0",
80
80
  "word-wrap": "^1.2.5",
81
81
  "ws": "^6.2.2",
82
- "yaml": "^2.0.1"
82
+ "yaml": "^2.0.1",
83
+ "yargs-parser": "18.1.3"
83
84
  },
84
85
  "devDependencies": {
85
86
  "@heroku-cli/schema": "^1.0.25",
@@ -137,7 +138,7 @@
137
138
  "typescript": "4.8.4"
138
139
  },
139
140
  "engines": {
140
- "node": "~20.x"
141
+ "node": "20.x"
141
142
  },
142
143
  "files": [
143
144
  "/autocomplete-scripts",
@@ -343,7 +344,7 @@
343
344
  },
344
345
  "update": {
345
346
  "node": {
346
- "version": "20.17.0"
347
+ "version": "20.19.1"
347
348
  },
348
349
  "s3": {
349
350
  "xz": true,
@@ -391,12 +392,12 @@
391
392
  "test:acceptance": "yarn pretest && mocha --forbid-only \"test/**/*.acceptance.test.ts\" && node ./bin/bats-test-runner",
392
393
  "test:integration": "yarn pretest && mocha --forbid-only \"test/**/*.integration.test.ts\"",
393
394
  "test:smoke": "yarn pretest && mocha --forbid-only \"test/**/smoke.acceptance.test.ts\"",
394
- "test:unit:justTest:local": "nyc mocha \"test/**/*.unit.test.ts\"",
395
+ "test:unit:justTest:local": "mocha \"test/**/*.unit.test.ts\"",
395
396
  "test:unit:justTest:ci": "nyc --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.unit.test.ts\"",
396
397
  "test": "yarn pretest && yarn test:unit:justTest:ci",
397
398
  "test:local": "yarn pretest && yarn test:unit:justTest:local",
398
399
  "version": "oclif readme --multi && git add README.md ../../docs"
399
400
  },
400
401
  "types": "lib/index.d.ts",
401
- "gitHead": "e1a2d3e68cd0f79dcebe6d77139da6cb81dcb5c7"
402
+ "gitHead": "b7f2716661ac67cbab798d4e1f5b6039279c3570"
402
403
  }