@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
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
|
-
|
|
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
|
|
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.
|
|
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\"
|
|
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
|
-
"
|
|
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/
|
|
34
|
-
"@nan0web/
|
|
35
|
-
"@nan0web/
|
|
36
|
-
"@nan0web/
|
|
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
|
+
}
|