@nan0web/ui-cli 1.0.2 → 1.1.1

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.
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import PlaygroundTest from "./PlaygroundTest.js"
2
+
3
+ export {
4
+ PlaygroundTest,
5
+ }
6
+
7
+ export default PlaygroundTest
@@ -0,0 +1,3 @@
1
+ // export default class CLiUiAdapter extends UiAdapter {
2
+
3
+ // }
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
- * @typedef {Function} InputFn
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 {Function|boolean} [loop=false] - Loop control or validator.
14
- * @param {Function|false} [nextQuestion=false] - Function to compute the next prompt.
15
- * @returns {Promise<Input>} Resolves with an {@link Input} instance containing the answer.
21
+ * @param {boolean | LoopFn} [loop=false] - Loopcontrol 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
- * Prompt a question and return the trimmed answer.
89
+ * Low‑level prompt that returns a trimmed string.
73
90
  *
74
- * @param {string} question - Text displayed as a prompt.
75
- * @returns {Promise<string>} User answer without surrounding whitespace.
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 ask(question) {
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 {Function|boolean} [loop=false] - Loop‑control flag or validator.
101
- * @param {Function|false} [nextQuestion=false] - Next prompt generator.
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 = false) {
146
+ async function fn(question, loop = false, nextQuestion = undefined) {
147
+ let currentQuestion = question
105
148
  while (true) {
106
- input.value = await ask(question)
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
- if (!loop(input)) return input
154
+ const cont = await loop(input)
155
+ if (!cont) return input
112
156
  }
113
- if (typeof nextQuestion === "string") question = nextQuestion
114
- if (typeof nextQuestion === "function") question = nextQuestion(input)
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