@nan0web/ui-cli 1.0.0 → 1.0.2
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 +2 -11
- package/package.json +17 -7
- package/src/CLI.js +141 -0
- package/src/Command.js +210 -0
- package/src/CommandError.js +35 -0
- package/src/CommandHelp.js +204 -0
- package/src/CommandMessage.js +181 -0
- package/src/CommandParser.js +217 -0
- package/src/InputAdapter.js +78 -149
- package/src/README.md.js +7 -6
- package/src/index.js +36 -14
- package/src/ui/index.js +3 -3
- package/src/ui/input.js +68 -11
- package/src/ui/next.js +30 -26
- package/src/ui/select.js +41 -31
- package/src/utils/parse.js +41 -0
- package/types/CLI.d.ts +44 -0
- package/types/Command.d.ts +72 -0
- package/types/CommandError.d.ts +19 -0
- package/types/CommandHelp.d.ts +85 -0
- package/types/CommandMessage.d.ts +65 -0
- package/types/CommandParser.d.ts +28 -0
- package/types/InputAdapter.d.ts +28 -85
- package/types/index.d.ts +12 -9
- package/types/ui/index.d.ts +2 -3
- package/types/ui/input.d.ts +50 -6
- package/types/ui/next.d.ts +11 -8
- package/types/ui/select.d.ts +50 -20
- package/types/utils/parse.d.ts +13 -0
- package/.editorconfig +0 -20
- package/CONTRIBUTING.md +0 -42
- package/docs/uk/README.md +0 -294
- package/playground/forms/addressForm.js +0 -37
- package/playground/forms/ageForm.js +0 -26
- package/playground/forms/profileForm.js +0 -33
- package/playground/forms/userForm.js +0 -36
- package/playground/main.js +0 -81
- package/playground/vocabs/en.js +0 -25
- package/playground/vocabs/index.js +0 -12
- package/playground/vocabs/uk.js +0 -25
- package/src/InputAdapter.test.js +0 -117
- package/src/ui/input.test.js +0 -27
- package/src/ui/select.test.js +0 -34
- package/system.md +0 -99
- package/tsconfig.json +0 -23
- package/types/test/ReadLine.d.ts +0 -1
- package/types/ui/errors.d.ts +0 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandMessage – generalized CLI message representation.
|
|
3
|
+
*
|
|
4
|
+
* @module CommandMessage
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Message } from "@nan0web/co"
|
|
8
|
+
import { str2argv } from "./utils/parse.js"
|
|
9
|
+
import CommandError from "./CommandError.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @class
|
|
13
|
+
* @extends Message
|
|
14
|
+
*/
|
|
15
|
+
export default class CommandMessage extends Message {
|
|
16
|
+
#name = ""
|
|
17
|
+
#argv = []
|
|
18
|
+
#opts = {}
|
|
19
|
+
#children = []
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} [input={}]
|
|
23
|
+
* @param {string} [input.name] - Command name.
|
|
24
|
+
* @param {string[]} [input.argv] - Positional arguments.
|
|
25
|
+
* @param {Object} [input.opts] - Options map.
|
|
26
|
+
* @param {Array<CommandMessage>} [input.children] - Nested messages.
|
|
27
|
+
* @param {Object} [input.body] - Message body payload.
|
|
28
|
+
*/
|
|
29
|
+
constructor(input = {}) {
|
|
30
|
+
super(input)
|
|
31
|
+
/** @type {any} */
|
|
32
|
+
const data = typeof input === "object" && !Array.isArray(input) ? input : {}
|
|
33
|
+
const {
|
|
34
|
+
name = "",
|
|
35
|
+
argv = [],
|
|
36
|
+
opts = {},
|
|
37
|
+
children = [],
|
|
38
|
+
body = {},
|
|
39
|
+
} = data
|
|
40
|
+
|
|
41
|
+
const fullBody = { ...body, ...opts }
|
|
42
|
+
this.body = fullBody
|
|
43
|
+
|
|
44
|
+
this.#name = String(name)
|
|
45
|
+
this.#argv = argv.map(String)
|
|
46
|
+
this.#opts = opts
|
|
47
|
+
this.#children = children.map(c => CommandMessage.from(c))
|
|
48
|
+
|
|
49
|
+
if (typeof input === "string" || Array.isArray(input)) {
|
|
50
|
+
const parsed = CommandMessage.parse(input)
|
|
51
|
+
this.#name = parsed.name
|
|
52
|
+
this.#argv = parsed.argv
|
|
53
|
+
this.#opts = parsed.opts
|
|
54
|
+
this.body = { ...this.body, ...parsed.opts }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @returns {string} */
|
|
59
|
+
get name() { return this.#name }
|
|
60
|
+
/** @param {string} v */
|
|
61
|
+
set name(v) { this.#name = String(v) }
|
|
62
|
+
|
|
63
|
+
/** @returns {string[]} */
|
|
64
|
+
get argv() { return this.#argv }
|
|
65
|
+
/** @param {string[]} v */
|
|
66
|
+
set argv(v) { this.#argv = v.map(String) }
|
|
67
|
+
|
|
68
|
+
/** @returns {Object} */
|
|
69
|
+
get opts() { return this.#opts }
|
|
70
|
+
/** @param {Object} v */
|
|
71
|
+
set opts(v) { this.#opts = v }
|
|
72
|
+
|
|
73
|
+
/** @returns {Array<CommandMessage>} */
|
|
74
|
+
get children() { return this.#children }
|
|
75
|
+
|
|
76
|
+
/** @returns {Array<string>} Full command line (name + args). */
|
|
77
|
+
get args() { return [this.name, ...this.argv].filter(Boolean) }
|
|
78
|
+
|
|
79
|
+
/** @returns {string} Sub‑command name of the first child, or empty string. */
|
|
80
|
+
get subCommand() { return this.children[0]?.name || "" }
|
|
81
|
+
|
|
82
|
+
/** @returns {CommandMessage|null} First child message, or null. */
|
|
83
|
+
get subCommandMessage() { return this.children[0] || null }
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Append a child {@link CommandMessage}.
|
|
87
|
+
*
|
|
88
|
+
* @param {CommandMessage|Object} msg
|
|
89
|
+
*/
|
|
90
|
+
add(msg) { this.#children.push(CommandMessage.from(msg)) }
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert the message back to a command‑line string.
|
|
94
|
+
*
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
toString() {
|
|
98
|
+
const optsStr = Object.entries(this.opts)
|
|
99
|
+
.map(([k, v]) => (v === true ? `--${k}` : `--${k} ${String(v)}`))
|
|
100
|
+
.join(" ")
|
|
101
|
+
const argsStr = this.argv.join(" ")
|
|
102
|
+
return [this.name, argsStr, optsStr].filter(Boolean).join(" ")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse raw CLI input into a {@link CommandMessage}.
|
|
107
|
+
*
|
|
108
|
+
* @param {string|string[]} argv - Input string or token array.
|
|
109
|
+
* @param {typeof Object} [BodyClass] - Optional class to instantiate the body.
|
|
110
|
+
* @returns {CommandMessage}
|
|
111
|
+
* @throws {CommandError} If no input is supplied.
|
|
112
|
+
*/
|
|
113
|
+
static parse(argv, BodyClass) {
|
|
114
|
+
if (typeof argv === "string") argv = str2argv(argv)
|
|
115
|
+
if (argv.length === 0) throw new CommandError("No input provided")
|
|
116
|
+
const result = { name: "", argv: [], opts: {} }
|
|
117
|
+
let i = 0
|
|
118
|
+
if (!argv[0].startsWith("-")) {
|
|
119
|
+
result.name = argv[0]
|
|
120
|
+
i = 1
|
|
121
|
+
}
|
|
122
|
+
while (i < argv.length) {
|
|
123
|
+
const cur = argv[i]
|
|
124
|
+
if (cur.startsWith("--")) {
|
|
125
|
+
const eq = cur.indexOf("=")
|
|
126
|
+
if (eq > -1) {
|
|
127
|
+
const k = cur.slice(2, eq)
|
|
128
|
+
const v = cur.slice(eq + 1)
|
|
129
|
+
result.opts[k] = v
|
|
130
|
+
} else {
|
|
131
|
+
const k = cur.slice(2)
|
|
132
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
133
|
+
result.opts[k] = argv[++i]
|
|
134
|
+
} else {
|
|
135
|
+
result.opts[k] = true
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else if (cur.startsWith("-") && cur.length > 1) {
|
|
139
|
+
const shorts = cur.slice(1)
|
|
140
|
+
if (shorts.length > 1) {
|
|
141
|
+
shorts.split("").forEach(s => (result.opts[s] = true))
|
|
142
|
+
} else {
|
|
143
|
+
const k = shorts
|
|
144
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
145
|
+
result.opts[k] = argv[++i]
|
|
146
|
+
} else {
|
|
147
|
+
result.opts[k] = true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
/** @ts-ignore */
|
|
152
|
+
result.argv.push(cur)
|
|
153
|
+
}
|
|
154
|
+
i++
|
|
155
|
+
}
|
|
156
|
+
const msg = new CommandMessage(result)
|
|
157
|
+
if (BodyClass) {
|
|
158
|
+
const body = new BodyClass(result.opts)
|
|
159
|
+
msg.body = body
|
|
160
|
+
/** @ts-ignore */
|
|
161
|
+
const errors = body.getErrors?.() || {}
|
|
162
|
+
if (Object.keys(errors).length) throw new CommandError("Validation failed", { errors })
|
|
163
|
+
}
|
|
164
|
+
return msg
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Convert a raw input into a {@link CommandMessage} instance.
|
|
169
|
+
*
|
|
170
|
+
* @param {CommandMessage|Message|Object|string|Array<string>} input
|
|
171
|
+
* @returns {CommandMessage}
|
|
172
|
+
*/
|
|
173
|
+
static from(input) {
|
|
174
|
+
if (input instanceof CommandMessage) return input
|
|
175
|
+
if (input instanceof Message) {
|
|
176
|
+
/** @ts-ignore */
|
|
177
|
+
return new CommandMessage({ body: input.body, name: input.name || "" })
|
|
178
|
+
}
|
|
179
|
+
return new CommandMessage(input)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandParser – parses argv into a hierarchical message tree supporting sub‑commands.
|
|
3
|
+
*
|
|
4
|
+
* @module CommandParser
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Message } from "@nan0web/co"
|
|
8
|
+
import CommandHelp from "./CommandHelp.js"
|
|
9
|
+
import CommandError from "./CommandError.js"
|
|
10
|
+
import { str2argv } from "./utils/parse.js"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @class
|
|
14
|
+
*/
|
|
15
|
+
export default class CommandParser {
|
|
16
|
+
/** @type {Array<Function>} */
|
|
17
|
+
Messages
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Array<Function>} [Messages=[]] - Root message classes.
|
|
21
|
+
*/
|
|
22
|
+
constructor(Messages = []) {
|
|
23
|
+
this.Messages = Array.isArray(Messages) ? Messages : [Messages]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse the provided input into a message hierarchy.
|
|
28
|
+
*
|
|
29
|
+
* @param {string|string[]} [input=process.argv.slice(2)] - CLI arguments.
|
|
30
|
+
* @returns {Message}
|
|
31
|
+
* @throws {Error} If no command is supplied or unknown root command.
|
|
32
|
+
*/
|
|
33
|
+
parse(input = process.argv.slice(2)) {
|
|
34
|
+
const argv = typeof input === "string" ? str2argv(input) : input
|
|
35
|
+
if (argv.length === 0) throw new Error("No command provided")
|
|
36
|
+
let rootName = null
|
|
37
|
+
let remaining = argv
|
|
38
|
+
|
|
39
|
+
if (!argv[0].startsWith("-")) {
|
|
40
|
+
rootName = argv[0]
|
|
41
|
+
remaining = argv.slice(1)
|
|
42
|
+
|
|
43
|
+
let RootClass = this.Messages.find(
|
|
44
|
+
cls => cls.name.toLowerCase() === rootName.toLowerCase(),
|
|
45
|
+
)
|
|
46
|
+
if (!RootClass) {
|
|
47
|
+
if (this.Messages.length === 1) RootClass = this.Messages[0]
|
|
48
|
+
else throw new Error(`Unknown root command: ${rootName}`)
|
|
49
|
+
}
|
|
50
|
+
// @ts-ignore – `RootClass` may not be a concrete `Message` subclass from TS view
|
|
51
|
+
const rootMessage = new RootClass({})
|
|
52
|
+
if (rootName) {
|
|
53
|
+
if (!rootMessage.head) rootMessage.head = {}
|
|
54
|
+
rootMessage.head.name = rootName
|
|
55
|
+
}
|
|
56
|
+
return this.#processMessageTree(rootMessage, remaining)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.Messages.length !== 1) throw new Error("Unable to infer root command from options")
|
|
60
|
+
// @ts-ignore – see comment above
|
|
61
|
+
const RootClass = this.Messages[0]
|
|
62
|
+
// @ts-ignore
|
|
63
|
+
const rootMessage = new RootClass({})
|
|
64
|
+
if (!rootMessage.head) rootMessage.head = {}
|
|
65
|
+
return this.#processMessageTree(rootMessage, remaining)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Walk the message tree and attach sub‑commands and leaf arguments.
|
|
70
|
+
*
|
|
71
|
+
* @param {Message} rootMessage - Root message instance.
|
|
72
|
+
* @param {string[]} remainingTokens - Tokens yet to be processed.
|
|
73
|
+
* @returns {Message}
|
|
74
|
+
*/
|
|
75
|
+
#processMessageTree(rootMessage, remainingTokens) {
|
|
76
|
+
let currentMessage = rootMessage
|
|
77
|
+
let remaining = remainingTokens
|
|
78
|
+
|
|
79
|
+
// @ts-ignore – `Children` is a static property on concrete message classes
|
|
80
|
+
while (currentMessage.constructor.Children && remaining.length) {
|
|
81
|
+
const subName = remaining[0]
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
const SubClass = currentMessage.constructor.Children.find(
|
|
84
|
+
cls => cls.name.toLowerCase() === subName.toLowerCase(),
|
|
85
|
+
)
|
|
86
|
+
if (!SubClass) break
|
|
87
|
+
|
|
88
|
+
// @ts-ignore
|
|
89
|
+
const subMessage = new SubClass({})
|
|
90
|
+
subMessage.name = subName
|
|
91
|
+
currentMessage.body.subCommand = subMessage
|
|
92
|
+
currentMessage = subMessage
|
|
93
|
+
remaining = remaining.slice(1)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (remaining.length) {
|
|
97
|
+
const parsedBody = this.#parseLeafBody(
|
|
98
|
+
remaining,
|
|
99
|
+
// @ts-ignore – `Body` may be undefined on some classes
|
|
100
|
+
currentMessage.constructor.Body,
|
|
101
|
+
)
|
|
102
|
+
currentMessage.body = { ...currentMessage.body, ...parsedBody }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
rootMessage.body.subCommand &&
|
|
107
|
+
typeof rootMessage.body.subCommand.assertValid === "function"
|
|
108
|
+
) {
|
|
109
|
+
rootMessage.body.subCommand.assertValid()
|
|
110
|
+
}
|
|
111
|
+
return rootMessage
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse leaf‑level arguments into the provided body class.
|
|
116
|
+
*
|
|
117
|
+
* @param {string[]} tokens - Remaining CLI tokens.
|
|
118
|
+
* @param {typeof Message} BodyClass - Class defining fields and validation.
|
|
119
|
+
* @returns {Object} Instance of BodyClass populated with parsed values.
|
|
120
|
+
*/
|
|
121
|
+
#parseLeafBody(tokens, BodyClass) {
|
|
122
|
+
const body = new BodyClass()
|
|
123
|
+
const props = Object.keys(body)
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
126
|
+
const token = tokens[i]
|
|
127
|
+
|
|
128
|
+
if (token.startsWith("--")) {
|
|
129
|
+
let key = token.slice(2)
|
|
130
|
+
/** @type {boolean | string} */
|
|
131
|
+
let value = true
|
|
132
|
+
const eqIdx = key.indexOf("=")
|
|
133
|
+
if (eqIdx > -1) {
|
|
134
|
+
value = key.slice(eqIdx + 1)
|
|
135
|
+
key = key.slice(0, eqIdx)
|
|
136
|
+
} else if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
|
|
137
|
+
value = tokens[++i]
|
|
138
|
+
}
|
|
139
|
+
const realKey = this.#resolveAlias(key, BodyClass) || key
|
|
140
|
+
if (props.includes(realKey)) body[realKey] = this.#convertType(body[realKey], value)
|
|
141
|
+
} else if (token.startsWith("-") && token.length > 1) {
|
|
142
|
+
const short = token.slice(1)
|
|
143
|
+
if (short.length > 1) {
|
|
144
|
+
short.split("").forEach(ch => {
|
|
145
|
+
const realKey = this.#resolveAlias(ch, BodyClass)
|
|
146
|
+
if (realKey && props.includes(realKey)) body[realKey] = true
|
|
147
|
+
})
|
|
148
|
+
} else {
|
|
149
|
+
const realKey = this.#resolveAlias(short, BodyClass) || short
|
|
150
|
+
if (props.includes(realKey)) {
|
|
151
|
+
/** @type {boolean | string} */
|
|
152
|
+
let value = true
|
|
153
|
+
if (i + 1 < tokens.length && !tokens[i + 1].startsWith("-")) {
|
|
154
|
+
value = tokens[++i]
|
|
155
|
+
}
|
|
156
|
+
body[realKey] = this.#convertType(body[realKey], value)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const first = props[0]
|
|
161
|
+
if (first) body[first] = token
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
props.forEach(prop => {
|
|
166
|
+
const schema = BodyClass[prop]
|
|
167
|
+
if (schema?.default !== undefined && body[prop] === undefined) {
|
|
168
|
+
body[prop] = schema.default
|
|
169
|
+
}
|
|
170
|
+
const err = schema?.validate?.(body[prop], body)
|
|
171
|
+
if (err !== undefined && err !== null && err !== true) {
|
|
172
|
+
throw new CommandError(`Invalid ${prop}: ${err}`, { [prop]: err })
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return body
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve an alias to its full property name.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} alias
|
|
183
|
+
* @param {Function} BodyClass
|
|
184
|
+
* @returns {string|null}
|
|
185
|
+
*/
|
|
186
|
+
#resolveAlias(alias, BodyClass) {
|
|
187
|
+
for (const [prop, schema] of Object.entries(BodyClass)) {
|
|
188
|
+
if (schema?.alias === alias) return prop
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Convert a raw CLI string to the appropriate JavaScript type.
|
|
195
|
+
*
|
|
196
|
+
* @param {*} defaultVal - The default value used for type inference.
|
|
197
|
+
* @param {*} value - Raw parsed value.
|
|
198
|
+
* @returns {*}
|
|
199
|
+
*/
|
|
200
|
+
#convertType(defaultVal, value) {
|
|
201
|
+
const type = typeof defaultVal
|
|
202
|
+
if (type === "boolean")
|
|
203
|
+
return Boolean(value !== "false" && value !== false && value !== "")
|
|
204
|
+
if (type === "number") return Number(value) || 0
|
|
205
|
+
return String(value)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate help text for a given message class.
|
|
210
|
+
*
|
|
211
|
+
* @param {typeof Message} MessageClass
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
generateHelp(MessageClass) {
|
|
215
|
+
return new CommandHelp(MessageClass).generate()
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/InputAdapter.js
CHANGED
|
@@ -1,219 +1,148 @@
|
|
|
1
|
-
import { Message } from '@nan0web/co'
|
|
2
|
-
import { UIForm, InputAdapter as BaseInputAdapter, InputMessage as BaseInputMessage } from '@nan0web/ui'
|
|
3
|
-
import { ask } from './ui/input.js'
|
|
4
|
-
import { select } from './ui/select.js'
|
|
5
|
-
|
|
6
|
-
/** @typedef {Partial<UIForm>} FormMessageValue */
|
|
7
|
-
/** @typedef {Partial<Message> | null} InputMessageValue */
|
|
8
|
-
|
|
9
1
|
/**
|
|
10
|
-
*
|
|
11
|
-
* instance alongside the usual input message payload.
|
|
2
|
+
* CLIInputAdapter – bridges UI‑CLI utilities with generic UI core.
|
|
12
3
|
*
|
|
13
|
-
*
|
|
14
|
-
* {@link InputMessageValue} (a {@link Message} payload). To remain
|
|
15
|
-
* compatible we keep `value` unchanged and store the form data in a
|
|
16
|
-
* separate `form` property.
|
|
4
|
+
* @module InputAdapter
|
|
17
5
|
*/
|
|
18
|
-
class FormMessage extends BaseInputMessage {
|
|
19
|
-
/** @type {UIForm} Form data associated with the message */
|
|
20
|
-
form
|
|
21
6
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* @param {boolean} [props.escaped=false] Escape flag.
|
|
31
|
-
*/
|
|
32
|
-
constructor(props = {}) {
|
|
33
|
-
const {
|
|
34
|
-
form = {},
|
|
35
|
-
...rest
|
|
36
|
-
} = props
|
|
37
|
-
// Initialise the parent with the remaining properties.
|
|
38
|
-
// Cast to `any` to avoid type‑mismatch between duplicated InputMessage
|
|
39
|
-
// definitions across packages.
|
|
40
|
-
super(/** @type {any} */ (rest))
|
|
41
|
-
|
|
42
|
-
// Store the UIForm; accept an object, UIForm or a plain payload.
|
|
43
|
-
this.form = UIForm.from(form)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Creates a {@link FormMessage} from an existing instance or plain data.
|
|
48
|
-
*
|
|
49
|
-
* @param {FormMessage|object} input – Existing message or raw data.
|
|
50
|
-
* @returns {FormMessage}
|
|
51
|
-
*/
|
|
52
|
-
static from(input) {
|
|
53
|
-
if (input instanceof FormMessage) return input
|
|
54
|
-
const {
|
|
55
|
-
form = {},
|
|
56
|
-
...rest
|
|
57
|
-
} = input ?? {}
|
|
58
|
-
return new FormMessage({ form, ...rest })
|
|
59
|
-
}
|
|
60
|
-
}
|
|
7
|
+
import { Message } from "@nan0web/co"
|
|
8
|
+
import {
|
|
9
|
+
UIForm,
|
|
10
|
+
InputAdapter as BaseInputAdapter,
|
|
11
|
+
} from "@nan0web/ui"
|
|
12
|
+
import { ask } from "./ui/input.js"
|
|
13
|
+
import { select } from "./ui/select.js"
|
|
14
|
+
import { CancelError } from "@nan0web/ui/core"
|
|
61
15
|
|
|
62
16
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
17
|
+
* Extends the generic {@link BaseInputAdapter} with CLI‑specific behaviour.
|
|
18
|
+
*
|
|
19
|
+
* @class
|
|
20
|
+
* @extends BaseInputAdapter
|
|
65
21
|
*/
|
|
66
22
|
export default class CLIInputAdapter extends BaseInputAdapter {
|
|
67
23
|
/**
|
|
68
|
-
*
|
|
24
|
+
* Prompt the user for a full form, handling navigation and validation.
|
|
69
25
|
*
|
|
70
|
-
* @param {UIForm} form
|
|
71
|
-
* @param {Object} options
|
|
72
|
-
* @param {boolean} [options.silent=true] Suppress
|
|
73
|
-
* @returns {Promise<
|
|
74
|
-
* otherwise `escaped` = false and the completed form attached as `form`.
|
|
26
|
+
* @param {UIForm} form - Form definition to present.
|
|
27
|
+
* @param {Object} [options={}]
|
|
28
|
+
* @param {boolean} [options.silent=true] - Suppress console output if `true`.
|
|
29
|
+
* @returns {Promise<Object>} Result object containing form data and meta‑information.
|
|
75
30
|
*/
|
|
76
31
|
async requestForm(form, options = {}) {
|
|
77
32
|
const { silent = true } = options
|
|
78
|
-
|
|
79
|
-
if (!silent) {
|
|
80
|
-
console.log(`\n${form.title}\n`)
|
|
81
|
-
}
|
|
33
|
+
if (!silent) console.log(`\n${form.title}\n`)
|
|
82
34
|
|
|
83
35
|
let formData = { ...form.state }
|
|
84
|
-
let
|
|
85
|
-
|
|
86
|
-
while (
|
|
87
|
-
const field = form.fields[
|
|
88
|
-
const prompt = field.label || field.name
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return
|
|
36
|
+
let idx = 0
|
|
37
|
+
|
|
38
|
+
while (idx < form.fields.length) {
|
|
39
|
+
const field = form.fields[idx]
|
|
40
|
+
const prompt = `${field.label || field.name}${field.required ? " *" : ""}: `
|
|
41
|
+
const answer = await this.ask(prompt)
|
|
42
|
+
|
|
43
|
+
if (["", "esc"].includes(answer)) {
|
|
44
|
+
// @ts-ignore – `UIForm` may not expose `id` in its TS definition
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
46
|
+
return {
|
|
47
|
+
// @ts-ignore
|
|
48
|
+
body: { action: "form-cancel", escaped: true, form: {}, id: form.id },
|
|
95
49
|
form: {},
|
|
96
50
|
escaped: true,
|
|
97
|
-
action:
|
|
51
|
+
action: "form-cancel",
|
|
52
|
+
// @ts-ignore
|
|
98
53
|
id: form.id,
|
|
99
|
-
}
|
|
54
|
+
}
|
|
100
55
|
}
|
|
101
56
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
currentFieldIndex = Math.max(0, currentFieldIndex - 1)
|
|
57
|
+
const trimmed = answer.trim()
|
|
58
|
+
if (trimmed === "::prev" || trimmed === "::back") {
|
|
59
|
+
idx = Math.max(0, idx - 1)
|
|
106
60
|
continue
|
|
107
61
|
}
|
|
108
|
-
if (trimmed ===
|
|
109
|
-
|
|
62
|
+
if (trimmed === "::next" || trimmed === "::skip") {
|
|
63
|
+
idx++
|
|
110
64
|
continue
|
|
111
65
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
66
|
+
if (trimmed === "" && !field.required) {
|
|
67
|
+
idx++
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (field.required && trimmed === "") {
|
|
71
|
+
console.log("\nField is required.")
|
|
116
72
|
continue
|
|
117
73
|
}
|
|
118
|
-
|
|
119
|
-
// Validate using the form's schema / field definition
|
|
120
74
|
const schema = field.constructor
|
|
121
75
|
const { isValid, errors } = form.validateValue(field.name, trimmed, schema)
|
|
122
76
|
if (!isValid) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
continue // stay on current field
|
|
77
|
+
console.log("\n" + Object.values(errors).join("\n"))
|
|
78
|
+
continue
|
|
126
79
|
}
|
|
127
|
-
|
|
128
|
-
// Store validated value
|
|
129
80
|
formData[field.name] = trimmed
|
|
130
|
-
|
|
81
|
+
idx++
|
|
131
82
|
}
|
|
132
83
|
|
|
133
|
-
// Final form validation
|
|
134
84
|
const finalForm = form.setData(formData)
|
|
135
85
|
const { isValid, errors } = finalForm.validate()
|
|
136
86
|
if (!isValid) {
|
|
137
|
-
console.log(
|
|
138
|
-
return await this.requestForm(form, options)
|
|
87
|
+
console.log("\n" + Object.values(errors).join("\n"))
|
|
88
|
+
return await this.requestForm(form, options)
|
|
139
89
|
}
|
|
140
90
|
|
|
141
|
-
|
|
91
|
+
// @ts-ignore – `UIForm` may not expose `id`
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
93
|
+
return {
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
body: { action: "form-submit", escaped: false, form: finalForm, id: form.id },
|
|
142
96
|
form: finalForm,
|
|
143
97
|
escaped: false,
|
|
144
|
-
action:
|
|
98
|
+
action: "form-submit",
|
|
99
|
+
// @ts-ignore
|
|
145
100
|
id: form.id,
|
|
146
|
-
}
|
|
101
|
+
}
|
|
147
102
|
}
|
|
148
103
|
|
|
149
104
|
/**
|
|
150
|
-
*
|
|
105
|
+
* Prompt the user to select an option from a list.
|
|
151
106
|
*
|
|
152
|
-
* @param {Object} config
|
|
153
|
-
* @
|
|
154
|
-
* @param {string} config.prompt – Prompt text.
|
|
155
|
-
* @param {Array<string>|Map<string,string>|Array<{label:string,value:string}>} config.options – Options to choose from.
|
|
156
|
-
* @param {string} config.id – Identifier for the resulting message.
|
|
157
|
-
* @returns {Promise<BaseInputMessage>} Message containing chosen value and metadata.
|
|
107
|
+
* @param {Object} config - Configuration passed to {@link select}.
|
|
108
|
+
* @returns {Promise<any>} Selected value, or empty string on cancellation.
|
|
158
109
|
*/
|
|
159
110
|
async requestSelect(config) {
|
|
160
111
|
try {
|
|
161
112
|
const result = await this.select({
|
|
162
|
-
title: config.title ??
|
|
163
|
-
prompt: config.prompt ??
|
|
113
|
+
title: config.title ?? "Select an option:",
|
|
114
|
+
prompt: config.prompt ?? "Choose (1‑N): ",
|
|
164
115
|
options: config.options,
|
|
165
116
|
console: console,
|
|
166
117
|
})
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
})
|
|
172
|
-
} catch (error) {
|
|
173
|
-
if (error instanceof this.CancelError) {
|
|
174
|
-
return BaseInputMessage.from({
|
|
175
|
-
value: '',
|
|
176
|
-
id: config.id,
|
|
177
|
-
})
|
|
178
|
-
}
|
|
179
|
-
throw error
|
|
118
|
+
return result.value
|
|
119
|
+
} catch (e) {
|
|
120
|
+
if (e instanceof CancelError) return ""
|
|
121
|
+
throw e
|
|
180
122
|
}
|
|
181
123
|
}
|
|
182
124
|
|
|
183
125
|
/**
|
|
184
|
-
*
|
|
126
|
+
* Prompt for a single string input.
|
|
185
127
|
*
|
|
186
|
-
* @param {Object} config
|
|
187
|
-
* @param {string} config.prompt Prompt text.
|
|
188
|
-
* @param {string} config.
|
|
189
|
-
* @param {string} [config.
|
|
190
|
-
* @
|
|
191
|
-
* @returns {Promise<BaseInputMessage>} Message containing the entered text.
|
|
128
|
+
* @param {Object} config - Prompt configuration.
|
|
129
|
+
* @param {string} [config.prompt] - Prompt text.
|
|
130
|
+
* @param {string} [config.label] - Optional label.
|
|
131
|
+
* @param {string} [config.name] - Optional identifier.
|
|
132
|
+
* @returns {Promise<string>} User response string.
|
|
192
133
|
*/
|
|
193
134
|
async requestInput(config) {
|
|
194
135
|
const prompt = config.prompt ?? `${config.label ?? config.name}: `
|
|
195
136
|
const input = await this.ask(prompt)
|
|
196
|
-
|
|
197
|
-
if (input === '') {
|
|
198
|
-
return BaseInputMessage.from({
|
|
199
|
-
value: '',
|
|
200
|
-
id: config.id,
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return BaseInputMessage.from({
|
|
205
|
-
value: input,
|
|
206
|
-
id: config.id,
|
|
207
|
-
})
|
|
137
|
+
return input
|
|
208
138
|
}
|
|
209
139
|
|
|
210
140
|
/** @inheritDoc */
|
|
211
141
|
async ask(question) {
|
|
212
142
|
return ask(question)
|
|
213
143
|
}
|
|
214
|
-
|
|
215
144
|
/** @inheritDoc */
|
|
216
|
-
async select(
|
|
217
|
-
return select(
|
|
145
|
+
async select(cfg) {
|
|
146
|
+
return select(cfg)
|
|
218
147
|
}
|
|
219
148
|
}
|