-
Notifications
You must be signed in to change notification settings - Fork 227
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature: POC for heroku REPL and prompt mode
- Loading branch information
1 parent
43d9164
commit 4a8f384
Showing
6 changed files
with
324 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -357,6 +357,7 @@ wubalubadubdub | |
xact | ||
xlarge | ||
xvzf | ||
yargs | ||
yetanotherapp | ||
yourdomain | ||
ztestdomain7 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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$ ') | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters