Skip to content

Commit

Permalink
feature: POC for heroku REPL and prompt mode
Browse files Browse the repository at this point in the history
  • Loading branch information
justinwilaby committed Jan 14, 2025
1 parent 43d9164 commit 4a8f384
Show file tree
Hide file tree
Showing 6 changed files with 324 additions and 18 deletions.
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

0 comments on commit 4a8f384

Please sign in to comment.