heroku 10.7.1-alpha.1 → 10.7.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,6 @@ 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
40
39
  * [`heroku apps`](docs/apps.md) - manage apps on Heroku
41
40
  * [`heroku auth`](docs/auth.md) - manage authentication for your Heroku account
42
41
  * [`heroku authorizations`](docs/authorizations.md) - OAuth authorizations
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()
package/lib/analytics.js CHANGED
@@ -15,8 +15,6 @@ 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';
20
18
  const plugin = opts.Command.plugin;
21
19
  if (!plugin) {
22
20
  debug('no plugin found for analytics');
@@ -31,7 +29,7 @@ class AnalyticsCommand {
31
29
  cli: this.config.name,
32
30
  command: opts.Command.id,
33
31
  completion: await this._acAnalytics(opts.Command.id),
34
- version: `${this.config.version}${mcpMode ? ` (MCP ${mcpServerVersion})` : ''}`,
32
+ version: this.config.version,
35
33
  plugin: plugin.name,
36
34
  plugin_version: plugin.version,
37
35
  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: string;
28
+ version: any;
29
29
  exitCode: number;
30
30
  exitState: string;
31
31
  cliRunDuration: number;
@@ -57,12 +57,10 @@ 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';
62
60
  const irregularTelemetryObject = {
63
61
  command: opts.id,
64
62
  os: config.platform,
65
- version: `${config.version}${mcpMode ? ` (MCP ${mcpServerVersion})` : ''}`,
63
+ version: config.version,
66
64
  exitCode: 0,
67
65
  exitState: 'successful',
68
66
  cliRunDuration: 0,
@@ -533,134 +533,6 @@
533
533
  "update.js"
534
534
  ]
535
535
  },
536
- "accounts:add": {
537
- "aliases": [],
538
- "args": {
539
- "name": {
540
- "description": "name of Heroku account to add",
541
- "name": "name",
542
- "required": true
543
- }
544
- },
545
- "description": "add a Heroku account to your cache",
546
- "examples": "heroku accounts:add my-account",
547
- "flags": {},
548
- "hasDynamicHelp": false,
549
- "hiddenAliases": [],
550
- "id": "accounts:add",
551
- "pluginAlias": "heroku",
552
- "pluginName": "heroku",
553
- "pluginType": "core",
554
- "strict": true,
555
- "example": "heroku accounts:add my-account",
556
- "isESM": false,
557
- "relativePath": [
558
- "lib",
559
- "commands",
560
- "accounts",
561
- "add.js"
562
- ]
563
- },
564
- "accounts:current": {
565
- "aliases": [],
566
- "args": {},
567
- "description": "display the current Heroku account",
568
- "examples": "heroku accounts:current",
569
- "flags": {},
570
- "hasDynamicHelp": false,
571
- "hiddenAliases": [],
572
- "id": "accounts:current",
573
- "pluginAlias": "heroku",
574
- "pluginName": "heroku",
575
- "pluginType": "core",
576
- "strict": true,
577
- "example": "heroku accounts:current",
578
- "isESM": false,
579
- "relativePath": [
580
- "lib",
581
- "commands",
582
- "accounts",
583
- "current.js"
584
- ]
585
- },
586
- "accounts": {
587
- "aliases": [],
588
- "args": {},
589
- "description": "list the Heroku accounts in your cache",
590
- "examples": "heroku accounts",
591
- "flags": {},
592
- "hasDynamicHelp": false,
593
- "hiddenAliases": [],
594
- "id": "accounts",
595
- "pluginAlias": "heroku",
596
- "pluginName": "heroku",
597
- "pluginType": "core",
598
- "strict": true,
599
- "example": "heroku accounts",
600
- "isESM": false,
601
- "relativePath": [
602
- "lib",
603
- "commands",
604
- "accounts",
605
- "index.js"
606
- ]
607
- },
608
- "accounts:remove": {
609
- "aliases": [],
610
- "args": {
611
- "name": {
612
- "description": "name of Heroku account to remove",
613
- "name": "name",
614
- "required": true
615
- }
616
- },
617
- "description": "remove a Heroku account from your cache",
618
- "examples": "heroku accounts:remove my-account",
619
- "flags": {},
620
- "hasDynamicHelp": false,
621
- "hiddenAliases": [],
622
- "id": "accounts:remove",
623
- "pluginAlias": "heroku",
624
- "pluginName": "heroku",
625
- "pluginType": "core",
626
- "strict": true,
627
- "example": "heroku accounts:remove my-account",
628
- "isESM": false,
629
- "relativePath": [
630
- "lib",
631
- "commands",
632
- "accounts",
633
- "remove.js"
634
- ]
635
- },
636
- "accounts:set": {
637
- "aliases": [],
638
- "args": {
639
- "name": {
640
- "description": "name of account to set",
641
- "name": "name",
642
- "required": true
643
- }
644
- },
645
- "description": "set the current Heroku account from your cache",
646
- "examples": "heroku accounts:set my-account",
647
- "flags": {},
648
- "hasDynamicHelp": false,
649
- "hiddenAliases": [],
650
- "id": "accounts:set",
651
- "pluginAlias": "heroku",
652
- "pluginName": "heroku",
653
- "pluginType": "core",
654
- "strict": true,
655
- "example": "heroku accounts:set my-account",
656
- "isESM": false,
657
- "relativePath": [
658
- "lib",
659
- "commands",
660
- "accounts",
661
- "set.js"
662
- ]
663
- },
664
536
  "addons:attach": {
665
537
  "aliases": [],
666
538
  "args": {
@@ -1315,6 +1187,134 @@
1315
1187
  "wait.js"
1316
1188
  ]
1317
1189
  },
1190
+ "accounts:add": {
1191
+ "aliases": [],
1192
+ "args": {
1193
+ "name": {
1194
+ "description": "name of Heroku account to add",
1195
+ "name": "name",
1196
+ "required": true
1197
+ }
1198
+ },
1199
+ "description": "add a Heroku account to your cache",
1200
+ "examples": "heroku accounts:add my-account",
1201
+ "flags": {},
1202
+ "hasDynamicHelp": false,
1203
+ "hiddenAliases": [],
1204
+ "id": "accounts:add",
1205
+ "pluginAlias": "heroku",
1206
+ "pluginName": "heroku",
1207
+ "pluginType": "core",
1208
+ "strict": true,
1209
+ "example": "heroku accounts:add my-account",
1210
+ "isESM": false,
1211
+ "relativePath": [
1212
+ "lib",
1213
+ "commands",
1214
+ "accounts",
1215
+ "add.js"
1216
+ ]
1217
+ },
1218
+ "accounts:current": {
1219
+ "aliases": [],
1220
+ "args": {},
1221
+ "description": "display the current Heroku account",
1222
+ "examples": "heroku accounts:current",
1223
+ "flags": {},
1224
+ "hasDynamicHelp": false,
1225
+ "hiddenAliases": [],
1226
+ "id": "accounts:current",
1227
+ "pluginAlias": "heroku",
1228
+ "pluginName": "heroku",
1229
+ "pluginType": "core",
1230
+ "strict": true,
1231
+ "example": "heroku accounts:current",
1232
+ "isESM": false,
1233
+ "relativePath": [
1234
+ "lib",
1235
+ "commands",
1236
+ "accounts",
1237
+ "current.js"
1238
+ ]
1239
+ },
1240
+ "accounts": {
1241
+ "aliases": [],
1242
+ "args": {},
1243
+ "description": "list the Heroku accounts in your cache",
1244
+ "examples": "heroku accounts",
1245
+ "flags": {},
1246
+ "hasDynamicHelp": false,
1247
+ "hiddenAliases": [],
1248
+ "id": "accounts",
1249
+ "pluginAlias": "heroku",
1250
+ "pluginName": "heroku",
1251
+ "pluginType": "core",
1252
+ "strict": true,
1253
+ "example": "heroku accounts",
1254
+ "isESM": false,
1255
+ "relativePath": [
1256
+ "lib",
1257
+ "commands",
1258
+ "accounts",
1259
+ "index.js"
1260
+ ]
1261
+ },
1262
+ "accounts:remove": {
1263
+ "aliases": [],
1264
+ "args": {
1265
+ "name": {
1266
+ "description": "name of Heroku account to remove",
1267
+ "name": "name",
1268
+ "required": true
1269
+ }
1270
+ },
1271
+ "description": "remove a Heroku account from your cache",
1272
+ "examples": "heroku accounts:remove my-account",
1273
+ "flags": {},
1274
+ "hasDynamicHelp": false,
1275
+ "hiddenAliases": [],
1276
+ "id": "accounts:remove",
1277
+ "pluginAlias": "heroku",
1278
+ "pluginName": "heroku",
1279
+ "pluginType": "core",
1280
+ "strict": true,
1281
+ "example": "heroku accounts:remove my-account",
1282
+ "isESM": false,
1283
+ "relativePath": [
1284
+ "lib",
1285
+ "commands",
1286
+ "accounts",
1287
+ "remove.js"
1288
+ ]
1289
+ },
1290
+ "accounts:set": {
1291
+ "aliases": [],
1292
+ "args": {
1293
+ "name": {
1294
+ "description": "name of account to set",
1295
+ "name": "name",
1296
+ "required": true
1297
+ }
1298
+ },
1299
+ "description": "set the current Heroku account from your cache",
1300
+ "examples": "heroku accounts:set my-account",
1301
+ "flags": {},
1302
+ "hasDynamicHelp": false,
1303
+ "hiddenAliases": [],
1304
+ "id": "accounts:set",
1305
+ "pluginAlias": "heroku",
1306
+ "pluginName": "heroku",
1307
+ "pluginType": "core",
1308
+ "strict": true,
1309
+ "example": "heroku accounts:set my-account",
1310
+ "isESM": false,
1311
+ "relativePath": [
1312
+ "lib",
1313
+ "commands",
1314
+ "accounts",
1315
+ "set.js"
1316
+ ]
1317
+ },
1318
1318
  "apps:create": {
1319
1319
  "aliases": [],
1320
1320
  "args": {
@@ -14652,5 +14652,5 @@
14652
14652
  ]
14653
14653
  }
14654
14654
  },
14655
- "version": "10.7.1-alpha.1"
14655
+ "version": "10.7.1-beta.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.7.1-alpha.1",
4
+ "version": "10.7.1-beta.0",
5
5
  "author": "Heroku",
6
6
  "bin": "./bin/run",
7
7
  "bugs": "https://github.com/heroku/cli/issues",
@@ -13,9 +13,8 @@
13
13
  "@heroku-cli/schema": "^1.0.25",
14
14
  "@heroku/buildpack-registry": "^1.0.1",
15
15
  "@heroku/eventsource": "^1.0.7",
16
- "@heroku/heroku-cli-util": "^9.0.1",
16
+ "@heroku/heroku-cli-util": "^9.0.2",
17
17
  "@heroku/http-call": "^5.4.0",
18
- "@heroku/plugin-ai": "^1.0.1",
19
18
  "@inquirer/prompts": "^5.0.5",
20
19
  "@oclif/core": "^2.16.0",
21
20
  "@oclif/plugin-commands": "2.2.28",
@@ -80,8 +79,7 @@
80
79
  "validator": "^13.7.0",
81
80
  "word-wrap": "^1.2.5",
82
81
  "ws": "^6.2.2",
83
- "yaml": "^2.0.1",
84
- "yargs-parser": "18.1.3"
82
+ "yaml": "^2.0.1"
85
83
  },
86
84
  "devDependencies": {
87
85
  "@heroku-cli/schema": "^1.0.25",
@@ -187,8 +185,7 @@
187
185
  "@oclif/plugin-update",
188
186
  "@oclif/plugin-version",
189
187
  "@oclif/plugin-warn-if-update-available",
190
- "@oclif/plugin-which",
191
- "@heroku/plugin-ai"
188
+ "@oclif/plugin-which"
192
189
  ],
193
190
  "bin": "heroku",
194
191
  "dirname": "heroku",
@@ -391,12 +388,12 @@
391
388
  "test:acceptance": "yarn pretest && mocha --forbid-only \"test/**/*.acceptance.test.ts\" && node ./bin/bats-test-runner",
392
389
  "test:integration": "yarn pretest && mocha --forbid-only \"test/**/*.integration.test.ts\"",
393
390
  "test:smoke": "yarn pretest && mocha --forbid-only \"test/**/smoke.acceptance.test.ts\"",
394
- "test:unit:justTest:local": "mocha \"test/**/*.unit.test.ts\"",
391
+ "test:unit:justTest:local": "nyc mocha \"test/**/*.unit.test.ts\"",
395
392
  "test:unit:justTest:ci": "nyc --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.unit.test.ts\"",
396
393
  "test": "yarn pretest && yarn test:unit:justTest:ci",
397
394
  "test:local": "yarn pretest && yarn test:unit:justTest:local",
398
395
  "version": "oclif readme --multi && git add README.md ../../docs"
399
396
  },
400
397
  "types": "lib/index.d.ts",
401
- "gitHead": "c89feca949fc3319131e316a95c819f5da92d23e"
398
+ "gitHead": "d26183b2263b8a7b5c7aae58af5bcb1c8ebe9fe8"
402
399
  }
@@ -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,620 +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
- 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
- }