From 4a8f3841ca10832fab925bbf15acf0b06d39e834 Mon Sep 17 00:00:00 2001 From: Justin Wilaby Date: Tue, 14 Jan 2025 12:36:53 -0800 Subject: [PATCH] feature: POC for heroku REPL and prompt mode --- cspell-dictionary.txt | 1 + packages/cli/bin/heroku-prompts.js | 235 +++++++++++++++++++++++++++++ packages/cli/bin/heroku-repl.js | 44 ++++++ packages/cli/bin/run | 38 ++++- packages/cli/package.json | 3 +- yarn.lock | 21 +-- 6 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 packages/cli/bin/heroku-prompts.js create mode 100644 packages/cli/bin/heroku-repl.js diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index d3c32c9537..d03e43f640 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -357,6 +357,7 @@ wubalubadubdub xact xlarge xvzf +yargs yetanotherapp yourdomain ztestdomain7 diff --git a/packages/cli/bin/heroku-prompts.js b/packages/cli/bin/heroku-prompts.js new file mode 100644 index 0000000000..5b609dfdb8 --- /dev/null +++ b/packages/cli/bin/heroku-prompts.js @@ -0,0 +1,235 @@ +const fs = require('node:fs') +const inquirer = require('inquirer') + +function choicesPrompt(description, choices, required, defaultValue) { + return inquirer.prompt([{ + type: 'list', + name: 'choices', + message: description, + choices, + default: defaultValue, + validate(input) { + if (!required || input) { + return true + } + + return `${description} is required` + }, + }]) +} + +function prompt(description, required) { + return inquirer.prompt([{ + type: 'input', + name: 'input', + message: description, + validate(input) { + if (!required || input.trim()) { + return true + } + + return `${description} is required` + }, + }]) +} + +function filePrompt(description, defaultPath) { + return inquirer.prompt([{ + type: 'input', + name: 'path', + message: description, + default: defaultPath, + validate(input) { + if (fs.existsSync(input)) { + return true + } + + return 'File does not exist. Please enter a valid file path.' + }, + }]) +} + +const showBooleanPrompt = async (commandFlag, userInputMap, defaultOption) => { + const {description, default: defaultValue, name: flagOrArgName} = commandFlag + const choice = await choicesPrompt(description, [ + {name: 'yes', value: true}, + {name: 'no', value: false}, + ], defaultOption) + + // user cancelled + if (choice === undefined || choice === 'Cancel') { + return true + } + + if (choice === 'Yes') { + userInputMap.set(flagOrArgName, defaultValue) + } + + return false +} + +const showOtherDialog = async (commandFlagOrArg, userInputMap) => { + const {description, default: defaultValue, options, required, name: flagOrArgName} = commandFlagOrArg + + let input + const isFileInput = description?.includes('absolute path') + if (isFileInput) { + input = await filePrompt(description, '') + } else if (options) { + const choices = options.map(option => ({name: option, value: option})) + input = await choicesPrompt(`Select the ${description}`, choices, required, defaultValue) + } else { + input = await prompt(`${description.slice(0, 1).toUpperCase()}${description.slice(1)} (${required ? 'required' : 'optional - press "Enter" to bypass'})`, required) + } + + if (input === undefined) { + return true + } + + if (input !== '') { + userInputMap.set(flagOrArgName, input) + } + + return false +} + +function collectInputsFromManifest(flagsOrArgsManifest, omitOptional) { + const requiredInputs = [] + const optionalInputs = [] + + // Prioritize options over booleans to + // prevent the user from yo-yo back and + // forth between the different input dialogs + const keysByType = Object.keys(flagsOrArgsManifest).sort((a, b) => { + const {type: aType} = flagsOrArgsManifest[a] + const {type: bType} = flagsOrArgsManifest[b] + if (aType === bType) { + return 0 + } + + if (aType === 'option') { + return -1 + } + + if (bType === 'option') { + return 1 + } + + return 0 + }) + + keysByType.forEach(key => { + const isRequired = Reflect.get(flagsOrArgsManifest[key], 'required'); + (isRequired ? requiredInputs : optionalInputs).push(key) + }) + // Prioritize required inputs + // over optional inputs when + // prompting the user. + // required inputs are sorted + // alphabetically. optional + // inputs are sorted alphabetically + // and then pushed to the end of + // the list. + requiredInputs.sort((a, b) => { + if (a < b) { + return -1 + } + + if (a > b) { + return 1 + } + + return 0 + }) + // Include optional only when not explicitly omitted + return omitOptional ? requiredInputs : [...requiredInputs, ...optionalInputs] +} + +async function getInput(flagsOrArgsManifest, userInputMap, omitOptional) { + const flagsOrArgs = collectInputsFromManifest(flagsOrArgsManifest, omitOptional) + + for (const flagOrArg of flagsOrArgs) { + const {name, description, type, hidden} = flagsOrArgsManifest[flagOrArg] + if (userInputMap.has(name)) { + continue + } + + // hidden args and flags may be exposed later + // based on the user type. For now, skip them. + if (!description || hidden) { + continue + } + + const cancelled = await (type === 'boolean' ? showBooleanPrompt : showOtherDialog)(flagsOrArgsManifest[flagOrArg], userInputMap) + if (cancelled) { + return true + } + } + + return false +} + +async function promptForInputs(commandName, commandManifest, userArgs, userFlags) { + const {args, flags} = commandManifest + + const userInputByArg = new Map() + Object.keys(args).forEach((argKey, index) => { + if (userArgs[index]) { + userInputByArg.set(argKey, userArgs[index]) + } + }) + + let cancelled = await getInput(args, userInputByArg) + if (cancelled) { + return {userInputByArg} + } + + const userInputByFlag = new Map() + Object.keys(flags).forEach(flagKey => { + const {name, char} = flags[flagKey] + if (userFlags[name] || userFlags[char]) { + userInputByFlag.set(flagKey, userFlags[flagKey]) + } + }) + cancelled = await getInput(flags, userInputByFlag) + if (cancelled) { + return + } + + return {userInputByArg, userInputByFlag} +} + +module.exports.promptUser = async (config, commandName, args, flags) => { + const commandMeta = config.findCommand(commandName) + if (!commandMeta) { + process.stderr.write(`"${commandName}" not a valid command\n$ `) + return + } + + const {userInputByArg, userInputByFlag} = await promptForInputs(commandName, commandMeta, args, flags) + + try { + for (const [, {input: argValue}] of userInputByArg) { + if (argValue) { + args.push(argValue) + } + } + + for (const [flagName, {input: flagValue}] of userInputByFlag) { + if (!flagValue) { + continue + } + + if (flagValue === true) { + args.push(`--${flagName}`) + continue + } + + args.push(`--${flagName}`, flagValue) + } + + return args + } catch (error) { + process.stderr.write(error.message) + } +} diff --git a/packages/cli/bin/heroku-repl.js b/packages/cli/bin/heroku-repl.js new file mode 100644 index 0000000000..ad54935793 --- /dev/null +++ b/packages/cli/bin/heroku-repl.js @@ -0,0 +1,44 @@ +const {Config} = require('@oclif/core') +const root = require.resolve('../package.json') +const config = new Config({root}) +const flagsByName = new Map() +async function * commandGenerator() { + while (true) { + const argv = await new Promise(resolve => { + process.stdin.once('data', resolve) + }) + yield argv.toString().trim().split(' ') + } +} + +module.exports.herokuRepl = async function (config) { + process.stderr.write('Welcome to the Heroku Terminal!\n$ ') + + for await (const input of commandGenerator()) { + const [command, ...argv] = input + if (command.startsWith('set')) { + flagsByName.set(argv[0], argv[1]) + process.stderr.write(`setting --app to "${argv[1]}"\n$ `) + continue + } + + const commandMeta = config.findCommand(command) + if (!commandMeta) { + process.stderr.write(`"${command}" not a valid command\n$ `) + continue + } + + try { + const {flags} = commandMeta + if (flags.app && flagsByName.has('app') && !argv?.includes('--app')) { + argv.push('--app', flagsByName.get('app')) + } + + await config.runCommand(command, argv) + } catch (error) { + process.stderr.write(error.message) + } + + process.stderr.write('\n$ ') + } +} diff --git a/packages/cli/bin/run b/packages/cli/bin/run index b4196ffc5b..4bc27ad69a 100755 --- a/packages/cli/bin/run +++ b/packages/cli/bin/run @@ -1,10 +1,15 @@ #!/usr/bin/env node +const {Config} = require('@oclif/core') +const root = require.resolve('../package.json') +const config = new Config({root}) + process.env.HEROKU_UPDATE_INSTRUCTIONS = process.env.HEROKU_UPDATE_INSTRUCTIONS || 'update with: "npm update -g heroku"' const now = new Date() const cliStartTime = now.getTime() const globalTelemetry = require('../lib/global_telemetry') +const yargs = require('yargs-parser')(process.argv.slice(2)) process.once('beforeExit', async code => { // capture as successful exit @@ -38,13 +43,32 @@ process.on('SIGTERM', async () => { globalTelemetry.initializeInstrumentation() const oclif = require('@oclif/core') +const oclifFlush = require('@oclif/core/flush') +const oclifError = require('@oclif/core/handle') +const { promptUser } = require('./heroku-prompts') +const { herokuRepl } = require('./heroku-repl') -oclif.run().then(require('@oclif/core/flush')).catch(async error => { - // capture any errors raised by oclif - const cliError = error - cliError.cliRunDuration = globalTelemetry.computeDuration(cliStartTime) - await globalTelemetry.sendTelemetry(cliError) +const main = async () => { + try { + await config.load() + const {_: [commandName, ...args], ...flags} = yargs + if (!args.length && !Object.keys(flags).length) { + return await herokuRepl(config) + } + if (flags.prompt) { + delete flags.prompt + await promptUser(config, commandName, args, flags) + } + await oclif.run([commandName, ...args], config) + await oclifFlush() + } catch (error) { + // capture any errors raised by oclif + const cliError = error + cliError.cliRunDuration = globalTelemetry.computeDuration(cliStartTime) + await globalTelemetry.sendTelemetry(cliError) - return require('@oclif/core/handle')(error) -}) + oclifError(error) + } +}; +void main(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 22ad36e07e..cd2491463c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -78,7 +78,8 @@ "urijs": "^1.19.11", "validator": "^13.7.0", "word-wrap": "^1.2.5", - "ws": "^6.2.2" + "ws": "^6.2.2", + "yargs-parser": "18.1.3" }, "devDependencies": { "@heroku-cli/schema": "^1.0.25", diff --git a/yarn.lock b/yarn.lock index 01ab6a1fb9..e16172ce7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10482,6 +10482,7 @@ __metadata: validator: ^13.7.0 word-wrap: ^1.2.5 ws: ^6.2.2 + yargs-parser: 18.1.3 bin: heroku: ./bin/run languageName: unknown @@ -17325,6 +17326,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:18.1.3, yargs-parser@npm:^18.1.2, yargs-parser@npm:^18.1.3": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: ^5.0.0 + decamelize: ^1.2.0 + checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9 + languageName: node + linkType: hard + "yargs-parser@npm:20.2.4": version: 20.2.4 resolution: "yargs-parser@npm:20.2.4" @@ -17339,16 +17350,6 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^18.1.2, yargs-parser@npm:^18.1.3": - version: 18.1.3 - resolution: "yargs-parser@npm:18.1.3" - dependencies: - camelcase: ^5.0.0 - decamelize: ^1.2.0 - checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9 - languageName: node - linkType: hard - "yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9"