Skip to content

Commit 7d84dce

Browse files
justinwilabysbosio
andauthored
feat(REPL):Heroku REPL and prompt mode (#3176)
* feature: POC for heroku REPL and prompt mode * fixed a small issue with enternig repl mode and added an exit command * Route through default command path when no prompt or REPL is used * Added completions and history to REPL * feat(REPL): added session and history restoration * Refinment on prompt delegation and error handling * Improved docs and arg completions * Added MCP mode to REPL * Corrected args handling for quote enclosed values * Add MCP version to telemetry (#3279) * Rebase updates * fix(W-18451520): mcp does not start on windows * Rebased onto main * feat: update Add plugin AI as a dependency in MCP feature branch (#3295) * Adding AI plugin core dependency * Bumping plugin-ai dependency to v1.0.1 * fix: multiple fixes to how REPL processes argv and autocomplete (#3300) * fix: multiple fixes to how REPL processes argv and autocomplete * Added --repl flag * ignore empty lines * removed unneeded args --------- Co-authored-by: Santiago Bosio <santiago.bosio@gmail.com>
1 parent e1b3372 commit 7d84dce

File tree

11 files changed

+1034
-27
lines changed

11 files changed

+1034
-27
lines changed

cspell-dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ wubalubadubdub
360360
xact
361361
xlarge
362362
xvzf
363+
yargs
363364
yetanotherapp
364365
yourdomain
365366
ztestdomain7

packages/cli/bin/heroku-prompts.js

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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, name: flagOrArgName} = commandFlag
54+
const {choices} = await choicesPrompt(description, [
55+
{name: 'yes', value: true},
56+
{name: 'no', value: false},
57+
], defaultOption)
58+
59+
// user cancelled
60+
if (choices === undefined || choices === 'Cancel') {
61+
return true
62+
}
63+
64+
if (choices) {
65+
userInputMap.set(flagOrArgName, {input: true})
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+
}

0 commit comments

Comments
 (0)