Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: POC for heroku REPL and prompt mode #3176

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ wubalubadubdub
xact
xlarge
xvzf
yargs
yetanotherapp
yourdomain
ztestdomain7
235 changes: 235 additions & 0 deletions packages/cli/bin/heroku-prompts.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
44 changes: 44 additions & 0 deletions packages/cli/bin/heroku-repl.js
Original file line number Diff line number Diff line change
@@ -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$ ')
}
}
38 changes: 31 additions & 7 deletions packages/cli/bin/run
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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();
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 11 additions & 10 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Loading