@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.
Files changed (47) hide show
  1. package/README.md +2 -11
  2. package/package.json +17 -7
  3. package/src/CLI.js +141 -0
  4. package/src/Command.js +210 -0
  5. package/src/CommandError.js +35 -0
  6. package/src/CommandHelp.js +204 -0
  7. package/src/CommandMessage.js +181 -0
  8. package/src/CommandParser.js +217 -0
  9. package/src/InputAdapter.js +78 -149
  10. package/src/README.md.js +7 -6
  11. package/src/index.js +36 -14
  12. package/src/ui/index.js +3 -3
  13. package/src/ui/input.js +68 -11
  14. package/src/ui/next.js +30 -26
  15. package/src/ui/select.js +41 -31
  16. package/src/utils/parse.js +41 -0
  17. package/types/CLI.d.ts +44 -0
  18. package/types/Command.d.ts +72 -0
  19. package/types/CommandError.d.ts +19 -0
  20. package/types/CommandHelp.d.ts +85 -0
  21. package/types/CommandMessage.d.ts +65 -0
  22. package/types/CommandParser.d.ts +28 -0
  23. package/types/InputAdapter.d.ts +28 -85
  24. package/types/index.d.ts +12 -9
  25. package/types/ui/index.d.ts +2 -3
  26. package/types/ui/input.d.ts +50 -6
  27. package/types/ui/next.d.ts +11 -8
  28. package/types/ui/select.d.ts +50 -20
  29. package/types/utils/parse.d.ts +13 -0
  30. package/.editorconfig +0 -20
  31. package/CONTRIBUTING.md +0 -42
  32. package/docs/uk/README.md +0 -294
  33. package/playground/forms/addressForm.js +0 -37
  34. package/playground/forms/ageForm.js +0 -26
  35. package/playground/forms/profileForm.js +0 -33
  36. package/playground/forms/userForm.js +0 -36
  37. package/playground/main.js +0 -81
  38. package/playground/vocabs/en.js +0 -25
  39. package/playground/vocabs/index.js +0 -12
  40. package/playground/vocabs/uk.js +0 -25
  41. package/src/InputAdapter.test.js +0 -117
  42. package/src/ui/input.test.js +0 -27
  43. package/src/ui/select.test.js +0 -34
  44. package/system.md +0 -99
  45. package/tsconfig.json +0 -23
  46. package/types/test/ReadLine.d.ts +0 -1
  47. package/types/ui/errors.d.ts +0 -3
package/README.md CHANGED
@@ -4,9 +4,7 @@ A tiny, zero‑dependency UI input adapter for Java•Script projects.
4
4
  It provides a CLI implementation that can be easily integrated
5
5
  with application logic.
6
6
 
7
- |[Status](https://github.com/nan0web/monorepo/blob/main/system.md#написання-сценаріїв)|Documentation|Test coverage|Features|Npm version|
8
- |---|---|---|---|---|
9
- |🟢 `96.1%` |🧪 [English 🏴󠁧󠁢󠁥󠁮󠁧󠁿](https://github.com/nan0web/ui-cli/blob/main/README.md)<br />[Українською 🇺🇦](https://github.com/nan0web/ui-cli/blob/main/docs/uk/README.md) |🟡 `77.9%` |✅ d.ts 📜 system.md 🕹️ playground |— |
7
+ <!-- %PACKAGE_STATUS% -->
10
8
 
11
9
  ## Description
12
10
 
@@ -78,9 +76,7 @@ const form = UIForm.from({
78
76
  state: {},
79
77
  validate: () => ({ isValid: true, errors: {} }),
80
78
  })
81
-
82
79
  const result = await adapter.requestForm(form, { silent: true })
83
-
84
80
  console.info(result.form.state) // ← { name: "John Doe", email: "John.Doe@example.com" }
85
81
  ```
86
82
 
@@ -97,9 +93,8 @@ const config = {
97
93
  ["uk", "Ukrainian"],
98
94
  ]),
99
95
  }
100
-
101
96
  const result = await adapter.requestSelect(config)
102
- console.info(result.value) // ← Message { body: "en", head: {} }
97
+ console.info(result) // ← en
103
98
  ```
104
99
  ### Input Utilities
105
100
 
@@ -114,7 +109,6 @@ const input = new Input({ value: "test", stops: ["quit"] })
114
109
  console.info(String(input)) // ← test
115
110
  console.info(input.value) // ← test
116
111
  console.info(input.cancelled) // ← false
117
-
118
112
  input.value = "quit"
119
113
  console.info(input.cancelled) // ← true
120
114
  ```
@@ -125,7 +119,6 @@ Prompts the user with a question and returns a promise with the answer.
125
119
  How to ask a question with ask()?
126
120
  ```js
127
121
  import { ask } from "@nan0web/ui-cli"
128
-
129
122
  const result = await ask("What is your name?")
130
123
  console.info(result)
131
124
  ```
@@ -152,7 +145,6 @@ const config = {
152
145
  options: ["Option A", "Option B", "Option C"],
153
146
  console: console,
154
147
  }
155
-
156
148
  const result = await select(config)
157
149
  console.info(result.value)
158
150
  ```
@@ -163,7 +155,6 @@ Waits for a keypress to continue the process.
163
155
  How to pause and wait for keypress with next()?
164
156
  ```js
165
157
  import { next } from '@nan0web/ui-cli'
166
-
167
158
  const result = await next()
168
159
  console.info(typeof result === "string")
169
160
  ```
package/package.json CHANGED
@@ -1,19 +1,25 @@
1
1
  {
2
2
  "name": "@nan0web/ui-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "NaN•Web UI CLI. Command line interface for One application logic (algorithm) and many UI.",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
7
7
  "type": "module",
8
+ "files": [
9
+ "src/**/*.js",
10
+ "!src/**/*.test.js",
11
+ "types/**/*.d.ts"
12
+ ],
8
13
  "scripts": {
9
14
  "build": "tsc",
10
- "test": "node --test --test-timeout=3333 \"src/**/*.test.js\" | nan0test parse --fail",
15
+ "test": "node --test --test-timeout=3333 \"src/**/*.test.js\"",
16
+ "test:nan0test": "node --test --test-timeout=3333 \"src/**/*.test.js\" | nan0test parse --fail",
11
17
  "test:coverage": "node --experimental-test-coverage --test-coverage-include=\"src/**/*.js\" --test-coverage-exclude=\"src/**/*.test.js\" --test \"src/**/*.test.js\"",
12
18
  "test:coverage:collect": "nan0test coverage",
13
19
  "test:docs": "node --test --test-timeout=3333 src/README.md.js",
14
20
  "test:release": "node --test \"releases/**/*.test.js\"",
15
21
  "test:status": "nan0test status --hide-name --debug",
16
- "playground": "node playground/main.js",
22
+ "play": "node play/main.js",
17
23
  "precommit": "npm test",
18
24
  "prepush": "npm test",
19
25
  "prepare": "husky",
@@ -30,9 +36,13 @@
30
36
  "author": "ЯRаСлав (YaRaSLove) <support@yaro.page>",
31
37
  "license": "ISC",
32
38
  "devDependencies": {
33
- "@nan0web/log": "workspace:*",
34
- "@nan0web/test": "workspace:*",
35
- "@nan0web/ui": "workspace:*",
36
- "@nan0web/db-fs": "workspace:*"
39
+ "@nan0web/db-fs": "1.0.0",
40
+ "@nan0web/i18n": "^1.0.1",
41
+ "@nan0web/log": "1.0.1",
42
+ "@nan0web/test": "1.1.0",
43
+ "@nan0web/ui": "1.0.3"
44
+ },
45
+ "dependencies": {
46
+ "@nan0web/co": "^1.1.3"
37
47
  }
38
48
  }
package/src/CLI.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * CLI – top‑level runner that orchestrates command execution and help generation.
3
+ *
4
+ * @module CLI
5
+ */
6
+
7
+ import { Message, InputMessage, OutputMessage } from "@nan0web/co"
8
+ import Logger from "@nan0web/log"
9
+ import CommandParser from "./CommandParser.js"
10
+ import CommandHelp from "./CommandHelp.js"
11
+
12
+ /**
13
+ * Main CLI class.
14
+ */
15
+ export default class CLI {
16
+ /** @type {string[]} */
17
+ argv = []
18
+ #commands = new Map()
19
+ /** @type {Logger} */
20
+ logger
21
+ /** @type {Array<Function>} */
22
+ Messages = []
23
+
24
+ /**
25
+ * @param {Object} [input={}]
26
+ * @param {string[]} [input.argv] - Command‑line arguments (defaults to `process.argv.slice(2)`).
27
+ * @param {Object} [input.commands] - Map of command names to handlers.
28
+ * @param {Logger} [input.logger] - Optional logger instance.
29
+ * @param {Array<Function>} [input.Messages] - Message classes for root commands.
30
+ */
31
+ constructor(input = {}) {
32
+ const {
33
+ argv = process.argv.slice(2),
34
+ commands = {},
35
+ logger,
36
+ Messages = [],
37
+ } = input
38
+ this.argv = argv.map(String).filter(Boolean)
39
+ this.logger = logger ?? new Logger({ level: Logger.detectLevel(this.argv) })
40
+ this.Messages = Messages
41
+ this.#commands = new Map(Object.entries(commands))
42
+ this.#commands.set("help", () => this.#help())
43
+ if (Messages.length > 0) this.#registerMessageCommands(Messages)
44
+ }
45
+
46
+ /** @returns {Map<string,Function>} The command map. */
47
+ get commands() {
48
+ return this.#commands
49
+ }
50
+
51
+ /**
52
+ * Register message‑based commands derived from classes.
53
+ *
54
+ * @param {any} cmdClasses - Array of Message classes exposing a `run` generator.
55
+ */
56
+ #registerMessageCommands(cmdClasses) {
57
+ cmdClasses.forEach(Class => {
58
+ const cmd = Class.name.toLowerCase()
59
+ this.#commands.set(cmd, async function* (msg) {
60
+ const validated = new Class(msg.body)
61
+ /** @ts-ignore – only content needed for tests */
62
+ yield new OutputMessage({ content: [`Executed ${cmd} with body: ${JSON.stringify(validated.body)}`] })
63
+ if (typeof Class.run === "function") yield* Class.run(validated)
64
+ })
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Execute the CLI workflow.
70
+ *
71
+ * @param {Message} [msg] - Optional pre‑built message.
72
+ * @yields {OutputMessage|InputMessage}
73
+ */
74
+ async * run(msg) {
75
+ // @ts-ignore – `Message` may carry a `value` wrapper in some contexts
76
+ const command = msg?.value?.body?.command ?? this.#parseCommandName()
77
+ const fn = this.#commands.get(command)
78
+
79
+ if (!fn) {
80
+ yield { content: `Unknown command: ${command}` }
81
+ return
82
+ }
83
+
84
+ let fullMsg
85
+ if (this.Messages.length > 0) {
86
+ const parser = new CommandParser(this.Messages)
87
+ fullMsg = parser.parse(this.argv)
88
+ yield new InputMessage({ value: { body: { command } } })
89
+ } else {
90
+ fullMsg = msg ?? new InputMessage({ value: { body: { command } } })
91
+ }
92
+
93
+ for await (const out of fn(fullMsg)) {
94
+ if (out.isError) this.logger.error(out.content)
95
+ else this.logger.info(out.content)
96
+ yield out
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Determine the command name from the positional arguments.
102
+ *
103
+ * @returns {string}
104
+ */
105
+ #parseCommandName() {
106
+ return this.argv.find(arg => !arg.startsWith("-")) || "help"
107
+ }
108
+
109
+ /**
110
+ * Generate help output for all registered commands.
111
+ *
112
+ * @yields {OutputMessage}
113
+ */
114
+ async * #help() {
115
+ const lines = ["Available commands:"]
116
+ for (const [name] of this.#commands) lines.push(` ${name}`)
117
+ if (this.Messages.length > 0) {
118
+ lines.push("\nMessage‑based commands:")
119
+ this.Messages.forEach(Class => {
120
+ // @ts-ignore – `CommandHelp` expects a class extending `Message`; casting to any silences TS
121
+ const help = new CommandHelp(Class).generate().split("\n")[0]
122
+ lines.push(` ${Class.name.toLowerCase()}: ${help}`)
123
+ })
124
+ }
125
+ /** @ts-ignore – output only needs `content` */
126
+ yield new OutputMessage({ content: lines })
127
+ }
128
+
129
+ /**
130
+ * Factory to create a CLI instance from various inputs.
131
+ *
132
+ * @param {CLI|Object} input - Existing CLI instance or configuration object.
133
+ * @returns {CLI}
134
+ * @throws {TypeError} If input is neither a CLI nor an object.
135
+ */
136
+ static from(input) {
137
+ if (input instanceof CLI) return input
138
+ if (input && typeof input === "object") return new CLI(input)
139
+ throw new TypeError("CLI.from expects an object or CLI instance")
140
+ }
141
+ }
package/src/Command.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Command – defines a CLI command with options, sub‑commands and execution logic.
3
+ *
4
+ * @module Command
5
+ */
6
+
7
+ import { Message } from "@nan0web/co"
8
+ import CommandMessage from "./CommandMessage.js"
9
+ import CommandError from "./CommandError.js"
10
+
11
+ /**
12
+ * Represents a command definition.
13
+ *
14
+ * @class
15
+ * @deprecated Use CLI instead
16
+ */
17
+ export default class Command {
18
+ /** @type {string} */ name = ""
19
+ /** @type {string} */ help = ""
20
+ /** @type {Object} */ options = {}
21
+ /** @type {Function} */ run = async function* () {}
22
+ /** @type {Command[]} */ children = []
23
+
24
+ /**
25
+ * @param {Object} config - Command configuration.
26
+ * @param {string} [config.name] - Command name.
27
+ * @param {string} [config.help] - Help description.
28
+ * @param {Object} [config.options] - Options map (`{ flag: [type, default, help] }`).
29
+ * @param {Function} [config.run] - Async generator handling execution.
30
+ * @param {Command[]} [config.children] - Sub‑commands.
31
+ */
32
+ constructor(config) {
33
+ this.name = config.name || ""
34
+ this.help = config.help || ""
35
+ this.options = config.options || {}
36
+ this.run = config.run || (async function* () {})
37
+ this.children = config.children || []
38
+ }
39
+
40
+ /**
41
+ * Add a sub‑command.
42
+ *
43
+ * @param {Command} command - Sub‑command instance.
44
+ * @returns {this}
45
+ */
46
+ addSubcommand(command) {
47
+ this.children.push(command)
48
+ return this
49
+ }
50
+
51
+ /**
52
+ * Find a sub‑command by name.
53
+ *
54
+ * @param {string} name - Sub‑command name.
55
+ * @returns {Command|null}
56
+ */
57
+ findSubcommand(name) {
58
+ return this.children.find(c => c.name === name) || null
59
+ }
60
+
61
+ /**
62
+ * Parse argv into a {@link CommandMessage}.
63
+ *
64
+ * @param {string[]|string} argv - Arguments array or string.
65
+ * @returns {CommandMessage}
66
+ */
67
+ parse(argv) {
68
+ const args = Array.isArray(argv) ? argv : [argv]
69
+ const msg = new CommandMessage({ name: "", argv: [], opts: {} })
70
+ let i = 0
71
+ while (i < args.length) {
72
+ const cur = args[i]
73
+ if (cur.startsWith("--")) {
74
+ handleLongOption(msg, args, i)
75
+ i = updateIndexAfterOption(args, i)
76
+ } else if (cur.startsWith("-")) {
77
+ handleShortOption(msg, args, i)
78
+ i = updateIndexAfterOption(args, i)
79
+ } else {
80
+ if (!msg.name) msg.name = cur
81
+ else msg.argv.push(cur)
82
+ i++
83
+ }
84
+ }
85
+ if (msg.name === this.name) {
86
+ msg.argv = [...msg.argv]
87
+ msg.name = ""
88
+ }
89
+ if (msg.argv.length > 0) {
90
+ const subName = msg.argv[0]
91
+ const sub = this.findSubcommand(subName)
92
+ if (sub) {
93
+ const subMsg = sub.parse(msg.argv.slice(1))
94
+ msg.add(subMsg)
95
+ msg.name = subName
96
+ msg.argv = []
97
+ }
98
+ }
99
+ this._applyDefaults(msg)
100
+ return msg
101
+ }
102
+
103
+ /**
104
+ * Generate a short help string.
105
+ *
106
+ * @returns {string}
107
+ */
108
+ generateHelp() {
109
+ const parts = []
110
+ if (this.help) parts.push(this.help)
111
+ const optFlags = Object.keys(this.options).map(k => `--${k}`).join(" ")
112
+ parts.push(optFlags ? `Usage: ${this.name} ${optFlags}` : `Usage: ${this.name}`)
113
+ return parts.join("\n")
114
+ }
115
+
116
+ /**
117
+ * Execute the command's run generator.
118
+ *
119
+ * @param {Message} message - Message passed to the runner.
120
+ * @yields {any}
121
+ * @throws {CommandError}
122
+ */
123
+ async * execute(message) {
124
+ try {
125
+ if (typeof this.run === "function") yield* this.run(message)
126
+ } catch (e) {
127
+ if (e instanceof CommandError) throw e
128
+ /** @ts-ignore */
129
+ throw new CommandError("Command execution failed", { message: e.message, stack: e.stack })
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Apply default values from the options definition to the parsed message.
135
+ *
136
+ * @param {CommandMessage} msg
137
+ * @private
138
+ */
139
+ _applyDefaults(msg) {
140
+ for (const [opt, [type, def]] of Object.entries(this.options)) {
141
+ if (!(opt in msg.opts)) {
142
+ msg.opts[opt] = def !== undefined ? def : type === Boolean ? false : ""
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ /* ---------- helpers ---------- */
149
+
150
+ /**
151
+ * Process a long option (`--flag` or `--flag=value`).
152
+ *
153
+ * @param {CommandMessage} msg
154
+ * @param {string[]} argv
155
+ * @param {number} index
156
+ * @private
157
+ */
158
+ function handleLongOption(msg, argv, index) {
159
+ const cur = argv[index]
160
+ const eq = cur.indexOf("=")
161
+ if (eq > -1) {
162
+ const k = cur.slice(2, eq)
163
+ const v = cur.slice(eq + 1)
164
+ msg.opts[k] = v
165
+ } else {
166
+ const k = cur.slice(2)
167
+ if (index + 1 < argv.length && !argv[index + 1].startsWith("-")) {
168
+ msg.opts[k] = argv[index + 1]
169
+ } else {
170
+ msg.opts[k] = true
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Process a short option (`-f` or combined `-abc`).
177
+ *
178
+ * @param {CommandMessage} msg
179
+ * @param {string[]} argv
180
+ * @param {number} index
181
+ * @private
182
+ */
183
+ function handleShortOption(msg, argv, index) {
184
+ const cur = argv[index].slice(1)
185
+ if (cur.length > 1) {
186
+ for (const ch of cur) msg.opts[ch] = true
187
+ } else {
188
+ const k = cur
189
+ if (index + 1 < argv.length && !argv[index + 1].startsWith("-")) {
190
+ msg.opts[k] = argv[index + 1]
191
+ } else {
192
+ msg.opts[k] = true
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Compute the next index after an option token.
199
+ *
200
+ * @param {string[]} argv
201
+ * @param {number} index
202
+ * @returns {number}
203
+ * @private
204
+ */
205
+ function updateIndexAfterOption(argv, index) {
206
+ const cur = argv[index]
207
+ if (cur.includes("=")) return index + 1
208
+ const hasVal = index + 1 < argv.length && !argv[index + 1].startsWith("-")
209
+ return hasVal ? index + 2 : index + 1
210
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CommandError – error class representing a failure during command execution.
3
+ *
4
+ * @module CommandError
5
+ */
6
+
7
+ /**
8
+ * @class
9
+ * @extends Error
10
+ */
11
+ export default class CommandError extends Error {
12
+ /**
13
+ * Creates a command execution error.
14
+ *
15
+ * @param {string} message - Message that opens the path.
16
+ * @param {Object} [data=null] - Data to help find correct resonance.
17
+ */
18
+ constructor(message, data = null) {
19
+ super(message)
20
+ this.name = "CommandError"
21
+ this.data = data
22
+ Error.captureStackTrace(this, CommandError)
23
+ }
24
+
25
+ /**
26
+ * Render the error as a string, optionally including attached data.
27
+ *
28
+ * @returns {string}
29
+ */
30
+ toString() {
31
+ return this.data
32
+ ? `${this.message}\n${JSON.stringify(this.data, null, 2)}`
33
+ : this.message
34
+ }
35
+ }
@@ -0,0 +1,204 @@
1
+ import { Message } from "@nan0web/co"
2
+ import Logger from "@nan0web/log"
3
+
4
+ /**
5
+ * @typedef {Object} CommandHelpField MessageBodySchema
6
+ * @property {string} [help] - Human readable description.
7
+ * @property {string} [placeholder] - Placeholder for usage (e.g. "<user>").
8
+ * @property {string} [alias] - Short alias (single‑letter).
9
+ * @property {any} [defaultValue] - Default value.
10
+ * @property {any} [type] - Data type.
11
+ * @property {boolean} [required] - Is field required or not.
12
+ * @property {RegExp} [pattern] - Regular expression pattern for validation.
13
+ */
14
+
15
+ /**
16
+ * CommandHelp – generates CLI help from a Message body schema.
17
+ * Supports nesting via static `Children`; message‑centric.
18
+ *
19
+ * @example
20
+ * const help = new CommandHelp(AuthMessage)
21
+ * console.log(help.generate()) // → formatted help string
22
+ * help.print() // → logs to console
23
+ */
24
+ export default class CommandHelp {
25
+ /** @type {typeof Message} Message class the help is built for */
26
+ MessageClass
27
+ /** @type {Logger} Logger used for printing */
28
+ logger
29
+ /** @type {typeof Message.Body} Body class reference */
30
+ BodyClass
31
+
32
+ /**
33
+ * @param {typeof Message} MessageClass - Message class with a schema.
34
+ * @param {Logger} [logger=new Logger()] - Optional logger.
35
+ */
36
+ constructor(MessageClass, logger = new Logger()) {
37
+ this.MessageClass = MessageClass
38
+ this.logger = logger
39
+ this.BodyClass = MessageClass.Body
40
+ }
41
+
42
+ /** @returns {typeof Logger} */
43
+ get Logger() {
44
+ return /** @type {typeof Logger} */ (this.logger.constructor)
45
+ }
46
+
47
+ /**
48
+ * Generates the full help text.
49
+ *
50
+ * @returns {string} Formatted help text.
51
+ */
52
+ generate() {
53
+ const lines = []
54
+ this.#header(lines)
55
+ this.#usage(lines)
56
+ this.#options(lines)
57
+ this.#subcommands(lines)
58
+ return lines.join("\n")
59
+ }
60
+
61
+ /**
62
+ * Prints the generated help to the logger.
63
+ */
64
+ print() {
65
+ this.logger.info(this.generate())
66
+ }
67
+
68
+ /**
69
+ * Adds a coloured header.
70
+ *
71
+ * @param {string[]} lines - Accumulator array.
72
+ */
73
+ #header(lines) {
74
+ const name = this.MessageClass.name.toLowerCase()
75
+ const help = this.MessageClass['help'] || ""
76
+ lines.push([
77
+ `${this.Logger.style(name, { color: this.Logger.MAGENTA })}`,
78
+ help
79
+ ].filter(Boolean).join(" • "))
80
+ lines.push("")
81
+ }
82
+
83
+ /**
84
+ * Constructs the usage line.
85
+ *
86
+ * @param {string[]} lines - Accumulator array.
87
+ */
88
+ #usage(lines) {
89
+ const name = this.MessageClass.name.toLowerCase()
90
+ const bodyInstance = new this.BodyClass()
91
+ const bodyProps = Object.keys(bodyInstance)
92
+
93
+ if (bodyProps.length === 0) {
94
+ lines.push(`Usage: ${name}`)
95
+ lines.push("")
96
+ return
97
+ }
98
+
99
+ const placeholderParts = []
100
+ const flagParts = []
101
+
102
+ bodyProps.forEach(prop => {
103
+ /** @type {CommandHelpField} */
104
+ const schema = this.BodyClass[prop] || {}
105
+ const alias = schema.alias ? `-${schema.alias}, ` : ""
106
+ const placeholder = schema.placeholder || schema.defaultValue
107
+ if (placeholder) {
108
+ placeholderParts.push(`[${alias}--${prop}=${placeholder}]`)
109
+ } else {
110
+ flagParts.push(`[${alias}--${prop}]`)
111
+ }
112
+ })
113
+
114
+ // Build usage string respecting spacing rules:
115
+ // * when only flags exist → separate with " , "
116
+ // * when placeholders exist:
117
+ // – if exactly ONE flag part → prepend with ", "
118
+ // – otherwise just space‑separate all parts.
119
+ let usage = ""
120
+ if (placeholderParts.length) {
121
+ usage = placeholderParts.join(" ")
122
+ if (flagParts.length) {
123
+ if (flagParts.length === 1) {
124
+ usage = `${usage}, ${flagParts[0]}`
125
+ } else {
126
+ usage = `${usage} ${flagParts.join(" ")}`
127
+ }
128
+ }
129
+ } else {
130
+ // only flag parts
131
+ usage = flagParts.join(" , ")
132
+ }
133
+ lines.push(`Usage: ${name} ${usage}`)
134
+ lines.push("")
135
+ }
136
+
137
+ /**
138
+ * Renders the Options section.
139
+ *
140
+ * @param {string[]} lines - Accumulator array.
141
+ */
142
+ #options(lines) {
143
+ const bodyInstance = new this.BodyClass()
144
+ const bodyProps = Object.keys(bodyInstance)
145
+ if (bodyProps.length === 0) return
146
+
147
+ lines.push("Options:")
148
+ bodyProps.forEach(prop => {
149
+ /** @type {CommandHelpField} */
150
+ const schema = this.BodyClass[prop] || {}
151
+ if (typeof schema !== "object") return
152
+
153
+ const flags = schema.alias
154
+ ? `--${prop}, -${schema.alias}`
155
+ : `--${prop}`
156
+
157
+ const type = undefined !== schema.type ? String(schema.type)
158
+ : undefined !== schema.defaultValue ? typeof schema.defaultValue
159
+ : undefined !== schema.placeholder ? typeof schema.placeholder
160
+ : "any"
161
+ const required = schema.required || schema.pattern || schema.defaultValue === undefined ? " *" : " "
162
+ const description = schema.help || "No description"
163
+
164
+ // Pad flags to align the type column with the expectations.
165
+ lines.push(` ${flags.padEnd(30)} ${type.padEnd(9)}${required} ${description}`)
166
+ })
167
+ lines.push("")
168
+ }
169
+
170
+ /**
171
+ * @param {object} body
172
+ * @returns {Map<string, any>} A map of errors, empty map if no errors.
173
+ */
174
+ validate(body) {
175
+ const Class = /** @type {typeof this.BodyClass} */ (body.constructor)
176
+ const result = new Map()
177
+ for (const [name, schema] of Object.entries(Class)) {
178
+ const fn = schema?.validate
179
+ if ("function" !== typeof fn) continue
180
+ const ok = fn.apply(body, [body[name]])
181
+ if (true === ok) continue
182
+ result.set(name, ok)
183
+ }
184
+ return result
185
+ }
186
+
187
+ /**
188
+ * Renders Subcommands, if any.
189
+ *
190
+ * @param {string[]} lines - Accumulator array.
191
+ */
192
+ #subcommands(lines) {
193
+ const children = this.MessageClass['Children'] || []
194
+ if (children.length === 0) return
195
+
196
+ lines.push("Subcommands:")
197
+ children.forEach(ChildClass => {
198
+ const childName = ChildClass.name.toLowerCase()
199
+ const childHelp = ChildClass.help || "No description"
200
+ lines.push(` ${childName.padEnd(20)} ${childHelp}`)
201
+ })
202
+ lines.push("")
203
+ }
204
+ }