@nan0web/ui-cli 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/package.json +18 -3
- package/src/CLI.js +50 -41
- package/src/CLiMessage.js +9 -0
- package/src/CommandHelp.js +2 -10
- package/src/CommandMessage.js +1 -0
- package/src/InputAdapter.js +275 -48
- package/src/OutputAdapter.js +61 -0
- package/src/README.md.js +15 -16
- package/src/components/Alert.js +22 -0
- package/src/index.js +9 -3
- package/src/test/PlaygroundTest.js +157 -0
- package/src/test/index.js +7 -0
- package/src/ui/Adapter.js +3 -0
- package/src/ui/form.js +254 -0
- package/src/ui/input.js +119 -17
- package/src/ui/select.js +64 -27
- package/types/CLI.d.ts +11 -10
- package/types/CLiMessage.d.ts +8 -0
- package/types/CommandMessage.d.ts +1 -0
- package/types/InputAdapter.d.ts +64 -17
- package/types/OutputAdapter.d.ts +29 -0
- package/types/UiMessage.d.ts +40 -0
- package/types/components/Alert.d.ts +15 -0
- package/types/index.d.ts +5 -3
- package/types/test/PlaygroundTest.d.ts +80 -0
- package/types/test/index.d.ts +3 -0
- package/types/ui/Adapter.d.ts +1 -0
- package/types/ui/form.d.ts +90 -0
- package/types/ui/input.d.ts +58 -11
- package/types/ui/select.d.ts +32 -18
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlaygroundTest – utilities for automated stdin sequence testing.
|
|
3
|
+
*
|
|
4
|
+
* Updated to handle errors gracefully and normalize output properly.
|
|
5
|
+
* Changes:
|
|
6
|
+
* - Caught EPIPE errors during setTimeout callback to prevent uncaught exceptions.
|
|
7
|
+
* - Normalize function now properly trims leading whitespace from each line.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/* eslint-disable no-use-before-define */
|
|
11
|
+
import { spawn } from "node:child_process"
|
|
12
|
+
import event, { EventContext } from "@nan0web/event"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} PlaygroundTestConfig
|
|
16
|
+
* @property {NodeJS.ProcessEnv} env Environment variables for the child process.
|
|
17
|
+
* @property {{ includeDebugger?: boolean }} [config={}] Configuration options.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Utility class to run playground demos and capture output.
|
|
22
|
+
*
|
|
23
|
+
* Updated behaviour:
|
|
24
|
+
* – When `PLAY_DEMO_SEQUENCE` is defined, the values are streamed to the
|
|
25
|
+
* child process **asynchronously** with a short delay between writes.
|
|
26
|
+
* – Errors caused by writing to a closed stdin (EPIPE) are ignored, allowing
|
|
27
|
+
* the child process to exit cleanly when a demo cancels early.
|
|
28
|
+
* – After execution, leading whitespace on each output line is stripped so
|
|
29
|
+
* that the test suite can compare raw lines without dealing with logger
|
|
30
|
+
* formatting (e.g. logger prefixes, indentation).
|
|
31
|
+
*/
|
|
32
|
+
export default class PlaygroundTest {
|
|
33
|
+
#bus
|
|
34
|
+
/**
|
|
35
|
+
* @param {NodeJS.ProcessEnv} env Environment variables for the child process.
|
|
36
|
+
* @param {{ includeDebugger?: boolean, includeEmptyLines?: boolean }} [config={}] Configuration options.
|
|
37
|
+
*/
|
|
38
|
+
constructor(env, config = {}) {
|
|
39
|
+
this.env = env
|
|
40
|
+
this.#bus = event()
|
|
41
|
+
/** @type {boolean} Include debugger lines in output (default: false). */
|
|
42
|
+
this.includeDebugger = config.includeDebugger ?? false
|
|
43
|
+
this.incldeEmptyLines = config.includeEmptyLines ?? false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to an event.
|
|
48
|
+
*/
|
|
49
|
+
on(event, fn) {
|
|
50
|
+
this.#bus.on(event, fn)
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Unsubscribe from an event.
|
|
54
|
+
*/
|
|
55
|
+
off(event, fn) {
|
|
56
|
+
this.#bus.off(event, fn)
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Emit an event.
|
|
60
|
+
*/
|
|
61
|
+
async emit(event, data) {
|
|
62
|
+
return await this.#bus.emit(event, data)
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Filter debugger related lines.
|
|
66
|
+
*/
|
|
67
|
+
filterDebugger(str) {
|
|
68
|
+
if (this.includeDebugger) return str
|
|
69
|
+
const words = ["debugger", "https://nodejs.org/en/docs/inspector"]
|
|
70
|
+
return str
|
|
71
|
+
.split("\n")
|
|
72
|
+
.filter(s => !words.some(w => s.toLowerCase().includes(w)))
|
|
73
|
+
.join("\n")
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Slice lines from stdout or stderr.
|
|
77
|
+
*/
|
|
78
|
+
slice(target, start, end) {
|
|
79
|
+
const txt = (this.recentResult?.[target] ?? "")
|
|
80
|
+
return txt.split("\n").slice(start, end)
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Write the answer sequence to the child process **asynchronously**,
|
|
84
|
+
* waiting a short period after each prompt appears.
|
|
85
|
+
*
|
|
86
|
+
* @param {any} child – ChildProcess instance.
|
|
87
|
+
*/
|
|
88
|
+
async #feedSequence(child) {
|
|
89
|
+
const raw = this.env.PLAY_DEMO_SEQUENCE
|
|
90
|
+
if (!raw) return
|
|
91
|
+
|
|
92
|
+
const sequence = raw.split(",").map(s => s.trim()).filter(Boolean)
|
|
93
|
+
if (sequence.length === 0) return
|
|
94
|
+
|
|
95
|
+
if (child.stdin) child.stdin.setDefaultEncoding("utf-8")
|
|
96
|
+
|
|
97
|
+
const writeNext = (idx) => {
|
|
98
|
+
if (idx >= sequence.length) {
|
|
99
|
+
try {
|
|
100
|
+
child.stdin?.end()
|
|
101
|
+
} catch (_) { }
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
try {
|
|
106
|
+
if (!child.killed && child.stdin?.writable) {
|
|
107
|
+
child.stdin.write(`${sequence[idx]}\n`)
|
|
108
|
+
writeNext(idx + 1)
|
|
109
|
+
}
|
|
110
|
+
} catch (_) {
|
|
111
|
+
// Silently swallow EPIPE and stop feeding.
|
|
112
|
+
}
|
|
113
|
+
}, 200)
|
|
114
|
+
}
|
|
115
|
+
writeNext(0)
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Executes the playground script.
|
|
119
|
+
*
|
|
120
|
+
* @param {string[]} [args=["play/main.js"]] Arguments passed to the node process.
|
|
121
|
+
*/
|
|
122
|
+
async run(args = ["play/main.js"]) {
|
|
123
|
+
const child = spawn("node", args, {
|
|
124
|
+
env: this.env,
|
|
125
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
this.#feedSequence(child)
|
|
129
|
+
|
|
130
|
+
let stdout = ""
|
|
131
|
+
for await (const chunk of child.stdout) {
|
|
132
|
+
stdout += chunk.toString()
|
|
133
|
+
this.emit("stdout", { chunk })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let stderr = ""
|
|
137
|
+
for await (const chunk of child.stderr) {
|
|
138
|
+
const clean = this.filterDebugger(chunk.toString())
|
|
139
|
+
stderr += clean
|
|
140
|
+
this.emit("stderr", { chunk, clean })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const exitCode = await new Promise(resolve => child.on("close", resolve))
|
|
144
|
+
|
|
145
|
+
// Trim leading whitespace from every line – the test suite expects raw
|
|
146
|
+
// output without logger prefixes or indentation.
|
|
147
|
+
const normalize = txt => this.incldeEmptyLines ? txt
|
|
148
|
+
: txt.split("\n").filter(row => row.trim() !== "").join("\n")
|
|
149
|
+
|
|
150
|
+
this.recentResult = {
|
|
151
|
+
stdout: normalize(stdout),
|
|
152
|
+
stderr: normalize(stderr),
|
|
153
|
+
exitCode,
|
|
154
|
+
}
|
|
155
|
+
return this.recentResult
|
|
156
|
+
}
|
|
157
|
+
}
|
package/src/ui/form.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form module – generates and processes CLI forms from model schemas.
|
|
3
|
+
*
|
|
4
|
+
* @module ui/form
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CancelError } from "@nan0web/ui/core"
|
|
8
|
+
import { createInput, Input } from "./input.js"
|
|
9
|
+
import { select } from "./select.js"
|
|
10
|
+
import { UiForm, FormInput } from "@nan0web/ui"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates a UiForm instance from a Body class static schema.
|
|
14
|
+
*
|
|
15
|
+
* @param {Function} BodyClass Class containing static field definitions.
|
|
16
|
+
* @param {Object} [options={}] Options.
|
|
17
|
+
* @param {Object} [options.initialState={}] Initial values for the form fields.
|
|
18
|
+
* @param {Function} [options.t] Optional translation function.
|
|
19
|
+
* @returns {UiForm} UiForm populated with fields derived from the schema.
|
|
20
|
+
*
|
|
21
|
+
* The function inspects static properties of `BodyClass` (e.g., `static username = { … }`)
|
|
22
|
+
* and maps each to a {@link FormInput}. The generated {@link UiForm} title defaults
|
|
23
|
+
* to `BodyClass.name` unless overridden via the schema.
|
|
24
|
+
*/
|
|
25
|
+
export function generateForm(BodyClass, options = {}) {
|
|
26
|
+
const { initialState = {}, t } = options
|
|
27
|
+
const fields = []
|
|
28
|
+
|
|
29
|
+
for (const [name, schema] of Object.entries(BodyClass)) {
|
|
30
|
+
if (typeof schema !== "object" || schema === null) continue
|
|
31
|
+
|
|
32
|
+
const translate = (value) => (typeof t === "function" ? t(value) : value)
|
|
33
|
+
|
|
34
|
+
fields.push(
|
|
35
|
+
new FormInput({
|
|
36
|
+
name,
|
|
37
|
+
label: translate(schema.help || name),
|
|
38
|
+
type: schema.type || "text",
|
|
39
|
+
required: Boolean(schema.required),
|
|
40
|
+
placeholder: translate(schema.placeholder || schema.defaultValue || ""),
|
|
41
|
+
options: schema.options
|
|
42
|
+
? schema.options.map((opt) =>
|
|
43
|
+
typeof opt === "string"
|
|
44
|
+
? opt
|
|
45
|
+
: opt.label
|
|
46
|
+
? { label: opt.label, value: opt.value }
|
|
47
|
+
: opt,
|
|
48
|
+
)
|
|
49
|
+
: [],
|
|
50
|
+
validation: schema.validate
|
|
51
|
+
? (value) => {
|
|
52
|
+
const res = schema.validate(value)
|
|
53
|
+
return res === true ? true : typeof res === "string" ? res : `Invalid ${name}`
|
|
54
|
+
}
|
|
55
|
+
: () => true,
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new UiForm({
|
|
61
|
+
title: t ? t(BodyClass.name) : BodyClass.name,
|
|
62
|
+
fields,
|
|
63
|
+
state: { ...initialState },
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* CLI-specific form handler that introspects a model class for static field schemas.
|
|
69
|
+
*
|
|
70
|
+
* @class
|
|
71
|
+
*/
|
|
72
|
+
export default class Form {
|
|
73
|
+
/** @type {Object} Model instance to update. */
|
|
74
|
+
#model
|
|
75
|
+
/** @type {Array} Configured fields derived from model schema. */
|
|
76
|
+
#fields = []
|
|
77
|
+
/** @type {Function} Input handler with cancellation support. */
|
|
78
|
+
handler
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {Object} model - Model instance (e.g., new User({ username: argv[3] })).
|
|
82
|
+
* @param {Object} [options={}] - Options.
|
|
83
|
+
* @param {string[]} [options.stops=["quit", "cancel", "exit"]] - Stop words.
|
|
84
|
+
* @param {(prompt: string) => Promise<Input>} [options.inputFn] - Custom input function.
|
|
85
|
+
* @throws {TypeError} If model is not an object with a constructor.
|
|
86
|
+
*/
|
|
87
|
+
constructor(model, options = {}) {
|
|
88
|
+
if (!model || typeof model !== "object" || !model.constructor) {
|
|
89
|
+
throw new TypeError("Form requires a model instance with a constructor")
|
|
90
|
+
}
|
|
91
|
+
this.#model = model
|
|
92
|
+
const { stops = ["quit", "cancel", "exit"], inputFn } = options
|
|
93
|
+
this.handler = inputFn || createInput(stops)
|
|
94
|
+
this.#fields = this.#generateFields()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generates field configurations from the model's static schema.
|
|
99
|
+
*
|
|
100
|
+
* @returns {Array<{name:string,label:string,type:string,required:boolean,placeholder:string,options:Array,validation:Function}>}
|
|
101
|
+
*/
|
|
102
|
+
#generateFields() {
|
|
103
|
+
const Class = this.#model.constructor
|
|
104
|
+
const fields = []
|
|
105
|
+
for (const [name, schema] of Object.entries(Class)) {
|
|
106
|
+
if (typeof schema !== "object" || schema === null) continue
|
|
107
|
+
const isRequired = schema.required === true || schema.defaultValue === undefined
|
|
108
|
+
const placeholder = schema.placeholder || schema.defaultValue || ""
|
|
109
|
+
const options = schema.options || []
|
|
110
|
+
const validation = schema.validate
|
|
111
|
+
? (value) => {
|
|
112
|
+
const res = schema.validate(value)
|
|
113
|
+
if (res === true) return true
|
|
114
|
+
if (typeof res === "string") return res
|
|
115
|
+
return `Invalid ${name}`
|
|
116
|
+
}
|
|
117
|
+
: () => true
|
|
118
|
+
fields.push({
|
|
119
|
+
name,
|
|
120
|
+
label: schema.help || name,
|
|
121
|
+
type: schema.type || "text",
|
|
122
|
+
required: isRequired,
|
|
123
|
+
placeholder,
|
|
124
|
+
options,
|
|
125
|
+
validation,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
return fields
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates a {@link Form} instance directly from a Body schema.
|
|
133
|
+
*
|
|
134
|
+
* @param {typeof Object} BodyClass Class with static schema definitions.
|
|
135
|
+
* @param {Object} [initialModel={}] Optional initial model data.
|
|
136
|
+
* @param {Object} [options={}] Same options as the constructor.
|
|
137
|
+
* @returns {Form} New Form instance.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* const form = Form.createFromBodySchema(UserBody, { username: "bob" })
|
|
141
|
+
*/
|
|
142
|
+
static createFromBodySchema(BodyClass, initialModel = {}, options = {}) {
|
|
143
|
+
const model = new BodyClass(initialModel)
|
|
144
|
+
return new Form(model, options)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Prompts for selection using the provided configuration.
|
|
149
|
+
*
|
|
150
|
+
* @param {Object} config - Selection configuration.
|
|
151
|
+
* @returns {Promise<{index:number, value:any}>} Selected option.
|
|
152
|
+
*/
|
|
153
|
+
async select(config) {
|
|
154
|
+
return select(config)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Prompts for input using the internal handler.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} prompt - Input prompt.
|
|
161
|
+
* @returns {Promise<Input>} Input result.
|
|
162
|
+
*/
|
|
163
|
+
async input(prompt) {
|
|
164
|
+
return this.handler(prompt)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Prompts for input, validates, and updates the model.
|
|
169
|
+
* Uses `ask` for text fields and `select` for option-based fields.
|
|
170
|
+
* Supports cancellation via stop words.
|
|
171
|
+
*
|
|
172
|
+
* @returns {Promise<{cancelled:boolean}>} Result indicating if cancelled.
|
|
173
|
+
* @throws {Error} Propagates non-cancellation errors.
|
|
174
|
+
*/
|
|
175
|
+
async requireInput() {
|
|
176
|
+
let idx = 0
|
|
177
|
+
while (idx < this.#fields.length) {
|
|
178
|
+
const field = this.#fields[idx]
|
|
179
|
+
const currentValue = this.#model[field.name] ?? field.placeholder
|
|
180
|
+
const prompt = `${field.label}${field.required ? " *" : ""} [${currentValue}]: `
|
|
181
|
+
try {
|
|
182
|
+
if (field.options.length > 0) {
|
|
183
|
+
const selConfig = {
|
|
184
|
+
title: field.label,
|
|
185
|
+
prompt: "Choose (number): ",
|
|
186
|
+
options: field.options.map((opt) =>
|
|
187
|
+
typeof opt === "string" ? opt : opt.label ? { label: opt.label, value: opt.value } : opt,
|
|
188
|
+
),
|
|
189
|
+
console: { info: console.info.bind(console) },
|
|
190
|
+
ask: this.handler,
|
|
191
|
+
}
|
|
192
|
+
const selResult = await this.select(selConfig)
|
|
193
|
+
const val = selResult.value
|
|
194
|
+
const validRes = field.validation(val)
|
|
195
|
+
if (validRes !== true) {
|
|
196
|
+
console.error(`\n${validRes}`)
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
this.#model[field.name] = this.convertValue(field, val)
|
|
200
|
+
idx++
|
|
201
|
+
} else {
|
|
202
|
+
const inputObj = await this.input(prompt)
|
|
203
|
+
if (inputObj.cancelled) {
|
|
204
|
+
return { cancelled: true }
|
|
205
|
+
}
|
|
206
|
+
let answer = inputObj.value.trim()
|
|
207
|
+
if (answer === "" && !field.required) {
|
|
208
|
+
this.#model[field.name] = ""
|
|
209
|
+
idx++
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
const validRes = field.validation(answer)
|
|
213
|
+
if (validRes !== true) {
|
|
214
|
+
console.error(`\n${validRes}`)
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
this.#model[field.name] = this.convertValue(field, answer)
|
|
218
|
+
idx++
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (e instanceof CancelError) {
|
|
222
|
+
return { cancelled: true }
|
|
223
|
+
}
|
|
224
|
+
throw e
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { cancelled: false }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Converts raw input value based on field schema.
|
|
232
|
+
*
|
|
233
|
+
* @param {Object} field - Field config.
|
|
234
|
+
* @param {string} value - Raw string value.
|
|
235
|
+
* @returns {string|number|boolean} Typed value.
|
|
236
|
+
*/
|
|
237
|
+
convertValue(field, value) {
|
|
238
|
+
const schema = this.#model.constructor[field.name]
|
|
239
|
+
const type = schema?.type || typeof (schema?.defaultValue ?? "string")
|
|
240
|
+
switch (type) {
|
|
241
|
+
case "number":
|
|
242
|
+
return Number(value) || 0
|
|
243
|
+
case "boolean":
|
|
244
|
+
return value.toLowerCase() === "true"
|
|
245
|
+
default:
|
|
246
|
+
return String(value)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @returns {Object} The updated model instance. */
|
|
251
|
+
get body() {
|
|
252
|
+
return this.#model
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/ui/input.js
CHANGED
|
@@ -6,14 +6,31 @@
|
|
|
6
6
|
|
|
7
7
|
import { createInterface } from "node:readline"
|
|
8
8
|
import { stdin, stdout } from "node:process"
|
|
9
|
+
import { CancelError } from "@nan0web/ui"
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("./select.js").ConsoleLike} ConsoleLike */
|
|
12
|
+
/** @typedef {(input: Input) => Promise<boolean>} LoopFn */
|
|
13
|
+
/** @typedef {(input: Input) => string} NextQuestionFn */
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
|
-
*
|
|
16
|
+
* Input function.
|
|
17
|
+
* ---
|
|
18
|
+
* Must be used only as a type — typedef does not work with full arguments description for functions.
|
|
19
|
+
* ---
|
|
12
20
|
* @param {string} question - Prompt displayed to the user.
|
|
13
|
-
* @param {
|
|
14
|
-
* @param {
|
|
15
|
-
*
|
|
21
|
+
* @param {boolean | LoopFn} [loop=false] - Loop‑control flag, validator or boolean that forces a single answer.
|
|
22
|
+
* @param {string | NextQuestionFn} [nextQuestion] - When `false` the prompt ends after one answer.
|
|
23
|
+
* When a `function` is supplied it receives the current {@link Input}
|
|
24
|
+
* and must return a new question string for the next iteration.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Promise<Input>} Resolves with an {@link Input} instance that contains the final answer,
|
|
27
|
+
* the raw value and cancellation state.
|
|
28
|
+
*
|
|
29
|
+
* @throws {Error} May propagate errors from the underlying readline interface.
|
|
16
30
|
*/
|
|
31
|
+
export async function InputFn(question, loop = false, nextQuestion = undefined) {
|
|
32
|
+
return new Input()
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
/**
|
|
19
36
|
* Represents a line of user input.
|
|
@@ -69,14 +86,37 @@ export class Input {
|
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
/**
|
|
72
|
-
*
|
|
89
|
+
* Low‑level prompt that returns a trimmed string.
|
|
73
90
|
*
|
|
74
|
-
* @param {
|
|
75
|
-
* @
|
|
91
|
+
* @param {Object} input
|
|
92
|
+
* @param {string} input.question - Text displayed as a prompt.
|
|
93
|
+
* @param {string} [input.predef] - Optional predefined answer (useful for testing).
|
|
94
|
+
* @param {ConsoleLike} [input.console] - Optional console to show predefined value
|
|
95
|
+
* @param {import("node:readline").Interface} [input.rl] - Readline interface instnace
|
|
96
|
+
* @returns {Promise<string>} The answer without surrounding whitespace.
|
|
97
|
+
*
|
|
98
|
+
* When `predef` is supplied the function mimics the usual readline output
|
|
99
|
+
* (`question + answer + newline`) and returns the trimmed value.
|
|
76
100
|
*/
|
|
77
|
-
export async function
|
|
101
|
+
export async function _askRaw(input) {
|
|
102
|
+
const {
|
|
103
|
+
question,
|
|
104
|
+
predef = undefined,
|
|
105
|
+
console,
|
|
106
|
+
rl: initialRL,
|
|
107
|
+
} = input
|
|
108
|
+
// Fast‑path for predefined answers – mimic readline output.
|
|
109
|
+
if (typeof predef === "string") {
|
|
110
|
+
if (console) {
|
|
111
|
+
console.info(`${question}${predef}\n`)
|
|
112
|
+
} else {
|
|
113
|
+
process.stdout.write(`${question}${predef}\n`)
|
|
114
|
+
}
|
|
115
|
+
return predef.trim()
|
|
116
|
+
}
|
|
117
|
+
|
|
78
118
|
return new Promise(resolve => {
|
|
79
|
-
const rl = createInterface({ input: stdin, output: stdout, terminal: true })
|
|
119
|
+
const rl = initialRL ?? createInterface({ input: stdin, output: stdout, terminal: true })
|
|
80
120
|
rl.question(question, answer => {
|
|
81
121
|
rl.close()
|
|
82
122
|
resolve(answer.trim())
|
|
@@ -88,33 +128,95 @@ export async function ask(question) {
|
|
|
88
128
|
* Factory that creates a reusable async input handler.
|
|
89
129
|
*
|
|
90
130
|
* @param {string[]} [stops=[]] Words that trigger cancellation.
|
|
131
|
+
* @param {string|undefined} [predef] Optional predefined answer for testing.
|
|
132
|
+
* @param {ConsoleLike} [console] Optional console instance.
|
|
91
133
|
* @returns {InputFn} Async function that resolves to an {@link Input}.
|
|
92
134
|
*/
|
|
93
|
-
export function createInput(stops = []) {
|
|
135
|
+
export function createInput(stops = [], predef = undefined, console = undefined) {
|
|
94
136
|
const input = new Input({ stops })
|
|
95
137
|
|
|
96
138
|
/**
|
|
97
139
|
* Internal handler used by the factory.
|
|
98
140
|
*
|
|
99
141
|
* @param {string} question - Prompt displayed to the user.
|
|
100
|
-
* @param {
|
|
101
|
-
* @param {
|
|
142
|
+
* @param {boolean | LoopFn} [loop=false] - Loop‑control flag or validator.
|
|
143
|
+
* @param {string | NextQuestionFn} [nextQuestion=false] - Next prompt generator or its value.
|
|
102
144
|
* @returns {Promise<Input>}
|
|
103
145
|
*/
|
|
104
|
-
async function fn(question, loop = false, nextQuestion =
|
|
146
|
+
async function fn(question, loop = false, nextQuestion = undefined) {
|
|
147
|
+
let currentQuestion = question
|
|
105
148
|
while (true) {
|
|
106
|
-
input.value = await
|
|
149
|
+
input.value = await _askRaw({ question: currentQuestion, predef, console })
|
|
107
150
|
|
|
108
151
|
if (false === loop || input.cancelled) return input
|
|
109
152
|
if (true === loop && input.value) return input
|
|
110
153
|
if (typeof loop === "function") {
|
|
111
|
-
|
|
154
|
+
const cont = await loop(input)
|
|
155
|
+
if (!cont) return input
|
|
112
156
|
}
|
|
113
|
-
if (typeof nextQuestion === "string")
|
|
114
|
-
if (typeof nextQuestion === "function")
|
|
157
|
+
if (typeof nextQuestion === "string") currentQuestion = nextQuestion
|
|
158
|
+
if (typeof nextQuestion === "function") currentQuestion = nextQuestion(input)
|
|
115
159
|
}
|
|
116
160
|
}
|
|
117
161
|
return fn
|
|
118
162
|
}
|
|
119
163
|
|
|
164
|
+
/**
|
|
165
|
+
* High‑level input helper `ask`.
|
|
166
|
+
*
|
|
167
|
+
* This constant inherits the full {@link InputFn} signature **and** the
|
|
168
|
+
* detailed JSDoc description for each argument, as defined in {@link InputFn}.
|
|
169
|
+
*
|
|
170
|
+
* @type {InputFn}
|
|
171
|
+
*/
|
|
172
|
+
export const ask = createInput()
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {string[]} predefined
|
|
176
|
+
* @param {ConsoleLike} console
|
|
177
|
+
* @param {string[]} [stops=[]]
|
|
178
|
+
* @returns {import("./select.js").InputFn}
|
|
179
|
+
* @throws {CancelError}
|
|
180
|
+
*/
|
|
181
|
+
export function createPredefinedInput(predefined, console, stops = []) {
|
|
182
|
+
const strPredefined = predefined.map(String)
|
|
183
|
+
const input = new Input({ stops })
|
|
184
|
+
let index = 0
|
|
185
|
+
return async function predefinedHandler(question, loop = false, nextQuestion = undefined) {
|
|
186
|
+
let currentQuestion = question
|
|
187
|
+
while (true) {
|
|
188
|
+
if (index >= strPredefined.length) {
|
|
189
|
+
throw new CancelError("No more predefined answers")
|
|
190
|
+
}
|
|
191
|
+
const predef = strPredefined[index++]
|
|
192
|
+
if (console) {
|
|
193
|
+
console.info(`${currentQuestion}${predef}\n`)
|
|
194
|
+
} else {
|
|
195
|
+
process.stdout.write(`${currentQuestion}${predef}\n`)
|
|
196
|
+
}
|
|
197
|
+
input.value = predef.trim()
|
|
198
|
+
if (input.cancelled) {
|
|
199
|
+
return input
|
|
200
|
+
}
|
|
201
|
+
if (false === loop) {
|
|
202
|
+
return input
|
|
203
|
+
}
|
|
204
|
+
if (true === loop && input.value) {
|
|
205
|
+
return input
|
|
206
|
+
}
|
|
207
|
+
if (typeof loop === "function") {
|
|
208
|
+
const cont = await loop(input)
|
|
209
|
+
if (!cont) {
|
|
210
|
+
return input
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (typeof nextQuestion === "string") {
|
|
214
|
+
currentQuestion = nextQuestion
|
|
215
|
+
} else if (typeof nextQuestion === "function") {
|
|
216
|
+
currentQuestion = nextQuestion(input)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
120
222
|
export default createInput
|