heroku 10.8.0-beta.0 → 10.8.1-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.
package/README.md CHANGED
@@ -36,6 +36,7 @@ For other issues, [submit a support ticket](https://help.heroku.com/).
36
36
  * [`heroku access`](docs/access.md) - manage user access to apps
37
37
  * [`heroku accounts`](docs/accounts.md) - list the Heroku accounts in your cache
38
38
  * [`heroku addons`](docs/addons.md) - tools and services for developing, extending, and operating your app
39
+ * [`heroku ai`](docs/ai.md) - manage Heroku AI models
39
40
  * [`heroku apps`](docs/apps.md) - manage apps on Heroku
40
41
  * [`heroku auth`](docs/auth.md) - manage authentication for your Heroku account
41
42
  * [`heroku authorizations`](docs/authorizations.md) - OAuth authorizations
@@ -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, name: flagOrArgName} = commandFlag
54
+ const {choices} = await choicesPrompt(description, [
55
+ {name: 'yes', value: true},
56
+ {name: 'no', value: false},
57
+ ], defaultOption)
58
+
59
+ // user cancelled
60
+ if (choices === undefined || choices === 'Cancel') {
61
+ return true
62
+ }
63
+
64
+ if (choices) {
65
+ userInputMap.set(flagOrArgName, {input: true})
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,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
+ }
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,36 @@ 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 (flags.repl && args.length === 0 && Object.keys(flags).length === 1) {
56
+ return herokuRepl(config)
57
+ }
47
58
 
48
- return require('@oclif/core/handle')(error)
49
- })
59
+ if (flags.prompt) {
60
+ delete flags.prompt
61
+ await promptUser(config, commandName, args, flags)
62
+ await oclif.run([commandName, ...args], config)
63
+ } else {
64
+ await oclif.run(undefined, config)
65
+ }
66
+
67
+ await oclifFlush()
68
+ } catch (error) {
69
+ // capture any errors raised by oclif
70
+ const cliError = error
71
+ cliError.cliRunDuration = globalTelemetry.computeDuration(cliStartTime)
72
+ await globalTelemetry.sendTelemetry(cliError)
73
+
74
+ oclifError(error)
75
+ }
76
+ }
50
77
 
78
+ 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,
@@ -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,
@@ -14929,5 +14929,5 @@
14929
14929
  ]
14930
14930
  }
14931
14931
  },
14932
- "version": "10.8.0-beta.0"
14932
+ "version": "10.8.1-alpha.0"
14933
14933
  }
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.8.0-beta.0",
4
+ "version": "10.8.1-alpha.0",
5
5
  "author": "Heroku",
6
6
  "bin": "./bin/run",
7
7
  "bugs": "https://github.com/heroku/cli/issues",
@@ -15,6 +15,7 @@
15
15
  "@heroku/eventsource": "^1.0.7",
16
16
  "@heroku/heroku-cli-util": "^9.0.2",
17
17
  "@heroku/http-call": "^5.4.0",
18
+ "@heroku/plugin-ai": "^1.0.1",
18
19
  "@inquirer/prompts": "^5.0.5",
19
20
  "@oclif/core": "^2.16.0",
20
21
  "@oclif/plugin-commands": "2.2.28",
@@ -79,7 +80,8 @@
79
80
  "validator": "^13.7.0",
80
81
  "word-wrap": "^1.2.5",
81
82
  "ws": "^6.2.2",
82
- "yaml": "^2.0.1"
83
+ "yaml": "^2.0.1",
84
+ "yargs-parser": "18.1.3"
83
85
  },
84
86
  "devDependencies": {
85
87
  "@heroku-cli/schema": "^1.0.25",
@@ -185,7 +187,8 @@
185
187
  "@oclif/plugin-update",
186
188
  "@oclif/plugin-version",
187
189
  "@oclif/plugin-warn-if-update-available",
188
- "@oclif/plugin-which"
190
+ "@oclif/plugin-which",
191
+ "@heroku/plugin-ai"
189
192
  ],
190
193
  "bin": "heroku",
191
194
  "dirname": "heroku",
@@ -388,12 +391,12 @@
388
391
  "test:acceptance": "yarn pretest && mocha --forbid-only \"test/**/*.acceptance.test.ts\" && node ./bin/bats-test-runner",
389
392
  "test:integration": "yarn pretest && mocha --forbid-only \"test/**/*.integration.test.ts\"",
390
393
  "test:smoke": "yarn pretest && mocha --forbid-only \"test/**/smoke.acceptance.test.ts\"",
391
- "test:unit:justTest:local": "nyc mocha \"test/**/*.unit.test.ts\"",
394
+ "test:unit:justTest:local": "mocha \"test/**/*.unit.test.ts\"",
392
395
  "test:unit:justTest:ci": "nyc --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.unit.test.ts\"",
393
396
  "test": "yarn pretest && yarn test:unit:justTest:ci",
394
397
  "test:local": "yarn pretest && yarn test:unit:justTest:local",
395
398
  "version": "oclif readme --multi && git add README.md ../../docs"
396
399
  },
397
400
  "types": "lib/index.d.ts",
398
- "gitHead": "738723f76c2a100555b463cef070814a705d35bb"
401
+ "gitHead": "9bc0afca647ef869c379b2ac054e798652d5de63"
399
402
  }