spyne-cli 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +90 -38
- package/index.js +123 -21
- package/package.json +24 -6
- package/src/cli/args.js +197 -0
- package/src/cli/render.js +184 -0
- package/src/core/create-app.js +232 -0
- package/src/core/generate-module.js +146 -0
- package/src/registry.js +174 -0
- package/src/spyne-file-prompt.js +77 -30
- package/src/templates/generate-prompt-input-fields.js +1 -1
- package/src/ui.js +5 -1
- package/lib/ansi.js +0 -116
- package/lib/combos.js +0 -75
- package/lib/completer.js +0 -52
- package/lib/interpolate.js +0 -266
- package/lib/keypress.js +0 -243
- package/lib/placeholder.js +0 -63
- package/lib/prompt.js +0 -485
- package/lib/prompts/autocomplete.js +0 -113
- package/lib/prompts/basicauth.js +0 -41
- package/lib/prompts/confirm.js +0 -13
- package/lib/prompts/editable.js +0 -136
- package/lib/prompts/form.js +0 -196
- package/lib/prompts/index.js +0 -28
- package/lib/prompts/input.js +0 -55
- package/lib/prompts/invisible.js +0 -11
- package/lib/prompts/list.js +0 -36
- package/lib/prompts/multiselect.js +0 -11
- package/lib/prompts/numeral.js +0 -1
- package/lib/prompts/password.js +0 -18
- package/lib/prompts/quiz.js +0 -37
- package/lib/prompts/scale.js +0 -237
- package/lib/prompts/select.js +0 -139
- package/lib/prompts/snippet.js +0 -185
- package/lib/prompts/sort.js +0 -37
- package/lib/prompts/survey.js +0 -163
- package/lib/prompts/text.js +0 -1
- package/lib/prompts/toggle.js +0 -109
- package/lib/render.js +0 -33
- package/lib/roles.js +0 -46
- package/lib/state.js +0 -69
- package/lib/styles.js +0 -144
- package/lib/symbols.js +0 -66
- package/lib/theme.js +0 -11
- package/lib/timer.js +0 -38
- package/lib/types/array.js +0 -658
- package/lib/types/auth.js +0 -29
- package/lib/types/boolean.js +0 -88
- package/lib/types/index.js +0 -7
- package/lib/types/number.js +0 -86
- package/lib/types/string.js +0 -185
- package/lib/utils.js +0 -268
- package/mocha.conf.cjs +0 -16
- package/src/app/channels/.gitkeep +0 -0
- package/src/app/channels/channel-custom.js +0 -1
- package/src/app/components/.gitkeep +0 -0
- package/src/app/traits/.gitkeep +0 -0
- package/src/hello-world-app-source/README.md +0 -15
- package/src/hello-world-app-source/apache-htaccess +0 -20
- package/src/hello-world-app-source/karma.conf.js +0 -75
- package/src/hello-world-app-source/package.json +0 -50
- package/src/hello-world-app-source/src/app/app-view.js +0 -38
- package/src/hello-world-app-source/src/app/channels/.gitkeep +0 -0
- package/src/hello-world-app-source/src/app/components/.gitkeep +0 -0
- package/src/hello-world-app-source/src/app/traits/.gitkeep +0 -0
- package/src/hello-world-app-source/src/index.js +0 -16
- package/src/hello-world-app-source/src/index.tmpl.html +0 -12
- package/src/hello-world-app-source/src/scss/01-vendors/normalize.css +0 -349
- package/src/hello-world-app-source/src/scss/02-variables/mixins.scss +0 -0
- package/src/hello-world-app-source/src/scss/03-components/.gitkeep +0 -0
- package/src/hello-world-app-source/src/scss/main.scss +0 -15
- package/src/hello-world-app-source/src/static/data/.gitkeep +0 -0
- package/src/hello-world-app-source/src/static/fonts/.gitkeep +0 -0
- package/src/hello-world-app-source/src/static/imgs/.gitkeep +0 -0
- package/src/hello-world-app-source/src/tests/unit-tests/index.test.js +0 -10
- package/src/hello-world-app-source/webpack.config.js +0 -178
- package/src/spyne-app-create.js +0 -209
- package/src/spyne-app-creator.js +0 -119
- package/src/spyne-starter-app-create.js +0 -147
- package/tests/create-spyne-app.test.js +0 -63
- package/tests/generate-file-prompt.test.js +0 -45
- package/tests/generate-file-string.test.js +0 -66
- package/tests/generate-prompt-input-fields-methods.test.js +0 -177
- package/tests/generate-prompt-input-fields.test.js +0 -179
- package/tests/generate-prompt-input-object.test.js +0 -39
- package/tests/generate-prompt-output.test.js +0 -58
- package/tests/index.test.js +0 -11
- package/tests/mocks/answers.js +0 -33
- package/tests/mocks/answers.json +0 -33
- package/tests/mocks/enquirer-data.js +0 -411
- package/tests/mocks/enquirer-data.json +0 -409
- package/tests/utils/file-utils.test.js +0 -84
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
+
|
|
1
2
|
# Spyne CLI
|
|
2
3
|
|
|
3
|
-
`spyne-cli` is a command-line utility designed to streamline the process of generating and managing applications built using the [SpyneJS](https://github.com/spynejs/spynejs) framework. It simplifies the creation of `ViewStream`, `Channel`, and `SpyneTrait` classes, making it easier to build scalable and modular single-page applications.
|
|
4
|
+
`spyne-cli` is a command-line utility designed to streamline the process of generating and managing applications built using the [SpyneJS](https://github.com/spynejs/spynejs) framework. It simplifies the creation of `ViewStream`, `DomElement`, `Channel`, and `SpyneTrait` classes, making it easier to build scalable and modular single-page applications.
|
|
4
5
|
|
|
5
6
|
## Features
|
|
6
7
|
|
|
7
|
-
- Create a new SpyneJS application
|
|
8
|
-
- Generate `ViewStream`, `Channel`, and `SpyneTrait` classes
|
|
9
|
-
-
|
|
10
|
-
-
|
|
8
|
+
- Create a new SpyneJS application from either of two templates.
|
|
9
|
+
- Generate `ViewStream`, `DomElement`, `Channel`, and `SpyneTrait` classes, interactively or from flags.
|
|
10
|
+
- Register generated channels in your application's `src/index.js`.
|
|
11
|
+
- Run every command non-interactively, with `--json` output for tooling.
|
|
11
12
|
|
|
12
13
|
## Installation
|
|
13
14
|
|
|
@@ -19,61 +20,112 @@ To install the `spyne-cli`, you need to have Node.js and npm installed on your s
|
|
|
19
20
|
npm install -g spyne-cli
|
|
20
21
|
```
|
|
21
22
|
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
Node.js 18 or newer.
|
|
26
|
+
|
|
22
27
|
## Usage
|
|
23
28
|
|
|
24
|
-
###
|
|
29
|
+
### Interactive
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
Run with no arguments for the picker:
|
|
27
32
|
|
|
28
33
|
```bash
|
|
29
|
-
npx spyne-cli
|
|
34
|
+
npx spyne-cli
|
|
30
35
|
```
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
```
|
|
38
|
+
? What would you like to create?
|
|
39
|
+
> App - Create a new SpyneJS application
|
|
40
|
+
ViewStream
|
|
41
|
+
DomElement
|
|
42
|
+
Channel
|
|
43
|
+
SpyneTrait
|
|
44
|
+
```
|
|
33
45
|
|
|
34
|
-
|
|
46
|
+
Selecting **App** prompts for a template and an application name. Selecting any
|
|
47
|
+
of the four module types prompts for a file name, class name, and output
|
|
48
|
+
directory, then writes the file into your project.
|
|
35
49
|
|
|
36
|
-
|
|
50
|
+
### Creating an application
|
|
37
51
|
|
|
38
|
-
|
|
52
|
+
```bash
|
|
53
|
+
npx spyne-cli create-app my-app
|
|
54
|
+
```
|
|
39
55
|
|
|
40
|
-
|
|
41
|
-
npx spyne-cli generate viewstream <view-name>
|
|
42
|
-
```
|
|
56
|
+
Prompts for a template when one is not supplied. To choose one directly:
|
|
43
57
|
|
|
44
|
-
|
|
58
|
+
```bash
|
|
59
|
+
npx spyne-cli create-app my-app --template shell
|
|
60
|
+
```
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
| Template | Contents |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `starter` (default) | `app.js` and a hello-world view |
|
|
65
|
+
| `shell` | pages, navigation, and UI components |
|
|
49
66
|
|
|
50
|
-
|
|
67
|
+
Options: `-t, --template`, `--no-install` to skip dependency installation,
|
|
68
|
+
`--no-git` to skip git initialisation.
|
|
51
69
|
|
|
52
|
-
|
|
53
|
-
npx spyne-cli generate spynetrait <trait-name>
|
|
54
|
-
```
|
|
70
|
+
### Generating modules
|
|
55
71
|
|
|
56
|
-
|
|
72
|
+
Each module type is also a direct command, usable inside an existing project:
|
|
57
73
|
|
|
58
|
-
|
|
74
|
+
```bash
|
|
75
|
+
npx spyne-cli create-viewstream my-widget-view
|
|
76
|
+
npx spyne-cli create-domelement my-element
|
|
77
|
+
npx spyne-cli create-channel channel-cart
|
|
78
|
+
npx spyne-cli create-trait my-form-trait
|
|
79
|
+
```
|
|
59
80
|
|
|
60
|
-
|
|
81
|
+
Options: `--className`, `-d, --fileDirectory`. `create-channel` also accepts
|
|
82
|
+
`--channelName` and `--replayLastPayload`; `create-trait` accepts
|
|
83
|
+
`--methodPrefix`. Anything not supplied is derived from the file name.
|
|
61
84
|
|
|
62
|
-
|
|
63
|
-
npx spyne-cli new my-spyne-app
|
|
64
|
-
```
|
|
85
|
+
`create-channel` also registers the channel in your `src/index.js`.
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
### Non-interactive use
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```
|
|
89
|
+
Every command runs from flags alone and never prompts when stdin is not a TTY,
|
|
90
|
+
so invocations are safe in CI. Add `--json` for machine-readable output:
|
|
71
91
|
|
|
72
|
-
|
|
92
|
+
```bash
|
|
93
|
+
npx spyne-cli create-app my-app -t starter --json
|
|
94
|
+
```
|
|
73
95
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
Exit codes: `0` success, `1` the command ran and failed, `2` usage error.
|
|
97
|
+
|
|
98
|
+
### Programmatic use
|
|
99
|
+
|
|
100
|
+
The command registry is exported as data, so tooling can enumerate every
|
|
101
|
+
generation target and its arguments:
|
|
102
|
+
|
|
103
|
+
```javascript
|
|
104
|
+
import { describeCommands } from 'spyne-cli/registry';
|
|
105
|
+
|
|
106
|
+
describeCommands(); // [{ name, summary, kind, args }, ...]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Scaffolding from a fork
|
|
110
|
+
|
|
111
|
+
`create-app` clones from the published SpyneJS templates. To point it somewhere
|
|
112
|
+
else — a fork, or a local mirror — set the matching environment variable:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
SPYNE_CLI_STARTER_REPO=https://github.com/you/your-starter.git \
|
|
116
|
+
npx spyne-cli create-app my-app
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`SPYNE_CLI_STARTER_REPO` overrides the `starter` template;
|
|
120
|
+
`SPYNE_CLI_SHELL_REPO` overrides `shell`.
|
|
121
|
+
|
|
122
|
+
### Deprecated and removed
|
|
123
|
+
|
|
124
|
+
`spyne-cli new <app-name>` still works and now runs `create-app`. It prints a
|
|
125
|
+
deprecation notice and will be removed in a future release.
|
|
126
|
+
|
|
127
|
+
The `--spa` flag was removed in 0.7.0. Choose a template with
|
|
128
|
+
`--template starter` or `--template shell` instead.
|
|
77
129
|
|
|
78
130
|
## Contributing
|
|
79
131
|
|
|
@@ -81,4 +133,4 @@ Contributions are welcome! Please feel free to submit a Pull Request or open an
|
|
|
81
133
|
|
|
82
134
|
## License
|
|
83
135
|
|
|
84
|
-
This project is licensed under the
|
|
136
|
+
This project is licensed under the AGPL-3.0-or-later License.
|
package/index.js
CHANGED
|
@@ -1,33 +1,135 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {readFileSync} from 'fs';
|
|
4
4
|
import clear from 'clear';
|
|
5
|
-
import
|
|
5
|
+
import c from 'ansi-colors';
|
|
6
|
+
import enquirer from 'enquirer';
|
|
7
|
+
|
|
8
|
+
import {SpyneCliUI} from './src/ui.js';
|
|
6
9
|
import SpyneFilePrompt from './src/spyne-file-prompt.js';
|
|
10
|
+
import {parseArgs, validateArgs} from './src/cli/args.js';
|
|
11
|
+
import {
|
|
12
|
+
appNamePrompt,
|
|
13
|
+
createProgressReporter,
|
|
14
|
+
renderAppResult,
|
|
15
|
+
renderCommandHelp,
|
|
16
|
+
renderError,
|
|
17
|
+
renderHelp,
|
|
18
|
+
renderModuleResult,
|
|
19
|
+
templatePrompt,
|
|
20
|
+
} from './src/cli/render.js';
|
|
21
|
+
|
|
22
|
+
const {prompt} = enquirer;
|
|
23
|
+
const {version} = JSON.parse(
|
|
24
|
+
readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
|
25
|
+
|
|
26
|
+
const EXIT = {ok: 0, usage: 2, failed: 1};
|
|
27
|
+
|
|
28
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
29
|
+
const {command, commandName, alias, args, provided, globals, errors} = parsed;
|
|
7
30
|
|
|
8
|
-
|
|
9
|
-
|
|
31
|
+
// --json implies machine consumption; a non-TTY stdin means nothing can answer
|
|
32
|
+
// a prompt. Either way we must never block.
|
|
33
|
+
const isInteractive = !globals.json && process.stdin.isTTY === true;
|
|
10
34
|
|
|
11
|
-
|
|
12
|
-
|
|
35
|
+
const emitJson = (payload, exitCode) => {
|
|
36
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
37
|
+
process.exit(exitCode);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const fail = (error, exitCode = EXIT.failed) => {
|
|
41
|
+
if (globals.json) emitJson({ok: false, error}, exitCode);
|
|
42
|
+
console.error(renderError(error));
|
|
43
|
+
process.exit(exitCode);
|
|
44
|
+
};
|
|
13
45
|
|
|
14
|
-
const
|
|
46
|
+
const succeed = (result, render) => {
|
|
47
|
+
if (globals.json) emitJson({ok: true, ...result}, EXIT.ok);
|
|
48
|
+
console.log(render(result));
|
|
49
|
+
process.exit(EXIT.ok);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (globals.version) {
|
|
53
|
+
if (globals.json) emitJson({ok: true, version}, EXIT.ok);
|
|
54
|
+
console.log(version);
|
|
55
|
+
process.exit(EXIT.ok);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (globals.help || (commandName && !command)) {
|
|
59
|
+
const unknown = commandName && !command;
|
|
60
|
+
if (unknown && globals.json) {
|
|
61
|
+
emitJson({
|
|
62
|
+
ok: false,
|
|
63
|
+
error: {code: 'UNKNOWN_COMMAND', message: `Unknown command "${commandName}".`},
|
|
64
|
+
}, EXIT.usage);
|
|
65
|
+
}
|
|
66
|
+
if (unknown) console.error(renderError({message: `Unknown command "${commandName}".`}));
|
|
67
|
+
console.log(command ? renderCommandHelp(command) : renderHelp(version));
|
|
68
|
+
process.exit(unknown ? EXIT.usage : EXIT.ok);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Bare invocation keeps the 0.6.8 front door: banner, then the picker.
|
|
72
|
+
if (!commandName) {
|
|
15
73
|
clear();
|
|
16
74
|
SpyneCliUI.title();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
75
|
+
new SpyneFilePrompt().startPrompt().catch((err) => {
|
|
76
|
+
console.error(renderError({message: err.message}));
|
|
77
|
+
process.exit(EXIT.failed);
|
|
78
|
+
});
|
|
79
|
+
} else {
|
|
80
|
+
run().catch((err) => fail({code: 'UNEXPECTED', message: err.message}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function run() {
|
|
84
|
+
if (errors.length) fail(errors[0], EXIT.usage);
|
|
85
|
+
|
|
86
|
+
if (alias && alias.deprecated) {
|
|
87
|
+
console.error(c.yellow(
|
|
88
|
+
`"${commandName}" is deprecated and will be removed in a future release. Use "${alias.command}" instead.`));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fill required args interactively; in non-interactive mode a missing
|
|
92
|
+
// required arg is a usage error rather than a hang.
|
|
93
|
+
if (command.kind === 'app') await resolveAppArgs();
|
|
94
|
+
|
|
95
|
+
const missing = validateArgs(command, args);
|
|
96
|
+
if (missing.length) fail(missing[0], EXIT.usage);
|
|
97
|
+
|
|
98
|
+
const handlerArgs = command.kind === 'app'
|
|
99
|
+
? {...args, onProgress: globals.json ? () => {} : createProgressReporter()}
|
|
100
|
+
: args;
|
|
101
|
+
|
|
102
|
+
const result = await command.handler(handlerArgs);
|
|
20
103
|
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} catch (err) {
|
|
27
|
-
console.error('Failed to create new application:', err.message);
|
|
28
|
-
process.exit(1);
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
// Module write failures still print the generated source for copy-paste.
|
|
106
|
+
if (command.kind === 'module' && result.fileString && !globals.json) {
|
|
107
|
+
console.log(renderModuleResult(result));
|
|
108
|
+
process.exit(EXIT.failed);
|
|
29
109
|
}
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
110
|
+
fail(result.error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
succeed(result,
|
|
114
|
+
command.kind === 'app' ? renderAppResult : renderModuleResult);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function resolveAppArgs() {
|
|
118
|
+
if (!isInteractive) {
|
|
119
|
+
// Never prompt in CI. Announce the default rather than failing, but only
|
|
120
|
+
// once the invocation is otherwise viable.
|
|
121
|
+
if (args.appName && !provided.has('template')) {
|
|
122
|
+
console.error(c.dim(`Using template: ${args.template}`));
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Template first: the general choice before the specific one.
|
|
128
|
+
if (!provided.has('template')) {
|
|
129
|
+
({template: args.template} = await prompt(templatePrompt()));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!args.appName) {
|
|
133
|
+
({appName: args.appName} = await prompt(appNamePrompt()));
|
|
134
|
+
}
|
|
33
135
|
}
|
package/package.json
CHANGED
|
@@ -4,9 +4,31 @@
|
|
|
4
4
|
"spyne-cli": "index.js"
|
|
5
5
|
},
|
|
6
6
|
"type": "module",
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.7.0",
|
|
8
8
|
"description": "Generates spyne objects and saves them to standard spyne.",
|
|
9
|
-
"main": "
|
|
9
|
+
"main": "src/registry.js",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"src/cli/",
|
|
16
|
+
"src/core/",
|
|
17
|
+
"src/templates/",
|
|
18
|
+
"src/utils/",
|
|
19
|
+
"src/registry.js",
|
|
20
|
+
"src/spyne-file-prompt.js",
|
|
21
|
+
"src/spyne-template-prompts.js",
|
|
22
|
+
"src/ui.js",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/registry.js",
|
|
28
|
+
"./registry": "./src/registry.js",
|
|
29
|
+
"./core/create-app": "./src/core/create-app.js",
|
|
30
|
+
"./core/generate-module": "./src/core/generate-module.js"
|
|
31
|
+
},
|
|
10
32
|
"scripts": {
|
|
11
33
|
"debug": "nodemon --no-stdin index.js",
|
|
12
34
|
"start": "node index.js",
|
|
@@ -30,13 +52,9 @@
|
|
|
30
52
|
"clear": "^0.1.0",
|
|
31
53
|
"enquirer": "^2.4.1",
|
|
32
54
|
"figlet": "^1.8.0",
|
|
33
|
-
"fs-extra": "^11.2.0",
|
|
34
55
|
"json-stringify-safe": "^5.0.1",
|
|
35
56
|
"ora": "^8.1.1",
|
|
36
|
-
"package-up": "^5.0.0",
|
|
37
|
-
"pkg-dir": "^8.0.0",
|
|
38
57
|
"ramda": "^0.30.1",
|
|
39
|
-
"read-pkg": "^9.0.1",
|
|
40
58
|
"recast": "^0.23.9",
|
|
41
59
|
"simple-git": "^3.27.0"
|
|
42
60
|
},
|
package/src/cli/args.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Registry-driven argv parsing. The registry defines what a command accepts;
|
|
2
|
+
// this file only knows how to read argv against a schema.
|
|
3
|
+
|
|
4
|
+
import {getAlias, getCommand, removedFlags} from '../registry.js';
|
|
5
|
+
|
|
6
|
+
const removedFlagError = (name) => {
|
|
7
|
+
const removed = removedFlags[name];
|
|
8
|
+
return removed
|
|
9
|
+
? {code: 'REMOVED_FLAG', flag: name, message: removed.message}
|
|
10
|
+
: undefined;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const GLOBAL_FLAGS = ['json', 'help', 'version'];
|
|
14
|
+
|
|
15
|
+
const findByAlias = (schema, alias) => Object.entries(schema).
|
|
16
|
+
find(([, def]) => def.alias === alias);
|
|
17
|
+
|
|
18
|
+
const coerce = (def, raw) => {
|
|
19
|
+
if (def.type === 'boolean') {
|
|
20
|
+
if (raw === undefined) return true;
|
|
21
|
+
return raw !== 'false' && raw !== '0';
|
|
22
|
+
}
|
|
23
|
+
if (def.type === 'number') {
|
|
24
|
+
const n = Number(raw);
|
|
25
|
+
return Number.isNaN(n) ? raw : n;
|
|
26
|
+
}
|
|
27
|
+
return raw;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} argv typically process.argv.slice(2)
|
|
32
|
+
*/
|
|
33
|
+
export function parseArgs(argv = []) {
|
|
34
|
+
const tokens = [...argv];
|
|
35
|
+
const globals = {json: false, help: false, version: false};
|
|
36
|
+
const errors = [];
|
|
37
|
+
|
|
38
|
+
const commandName = tokens[0] && !tokens[0].startsWith('-')
|
|
39
|
+
? tokens.shift()
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
const command = getCommand(commandName);
|
|
43
|
+
const alias = getAlias(commandName);
|
|
44
|
+
const schema = command ? command.args : {};
|
|
45
|
+
const args = {};
|
|
46
|
+
const positionals = [];
|
|
47
|
+
|
|
48
|
+
while (tokens.length) {
|
|
49
|
+
const token = tokens.shift();
|
|
50
|
+
|
|
51
|
+
if (token === '--') {
|
|
52
|
+
positionals.push(...tokens);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (token.startsWith('--')) {
|
|
57
|
+
let [flag, inlineValue] = token.slice(2).split(/=(.*)/s);
|
|
58
|
+
|
|
59
|
+
if (GLOBAL_FLAGS.includes(flag)) {
|
|
60
|
+
globals[flag] = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --no-install style negation for negatable booleans
|
|
65
|
+
if (flag.startsWith('no-')) {
|
|
66
|
+
const positive = flag.slice(3);
|
|
67
|
+
if (schema[positive] && schema[positive].type === 'boolean') {
|
|
68
|
+
args[positive] = false;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const def = schema[flag];
|
|
74
|
+
if (!def) {
|
|
75
|
+
errors.push(removedFlagError(flag) || {
|
|
76
|
+
code: 'UNKNOWN_FLAG',
|
|
77
|
+
message: `Unknown option "--${flag}".`,
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (def.type === 'boolean' && inlineValue === undefined) {
|
|
83
|
+
args[flag] = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const value = inlineValue !== undefined
|
|
88
|
+
? inlineValue
|
|
89
|
+
: (tokens[0] && !tokens[0].startsWith('-') ? tokens.shift() : undefined);
|
|
90
|
+
|
|
91
|
+
if (value === undefined && def.type !== 'boolean') {
|
|
92
|
+
errors.push({
|
|
93
|
+
code: 'MISSING_VALUE',
|
|
94
|
+
message: `Option "--${flag}" expects a value.`,
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
args[flag] = coerce(def, value);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (token.startsWith('-') && token.length > 1) {
|
|
104
|
+
const short = token.slice(1);
|
|
105
|
+
|
|
106
|
+
if (short === 'h') { globals.help = true; continue; }
|
|
107
|
+
if (short === 'v') { globals.version = true; continue; }
|
|
108
|
+
|
|
109
|
+
const entry = findByAlias(schema, short);
|
|
110
|
+
if (!entry) {
|
|
111
|
+
// 0.6.x accepted both -spa and --spa, so catch the short form too.
|
|
112
|
+
errors.push(removedFlagError(short) ||
|
|
113
|
+
{code: 'UNKNOWN_FLAG', message: `Unknown option "-${short}".`});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [flagName, def] = entry;
|
|
118
|
+
if (def.type === 'boolean') {
|
|
119
|
+
args[flagName] = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const value = tokens[0] && !tokens[0].startsWith('-')
|
|
124
|
+
? tokens.shift()
|
|
125
|
+
: undefined;
|
|
126
|
+
if (value === undefined) {
|
|
127
|
+
errors.push({
|
|
128
|
+
code: 'MISSING_VALUE',
|
|
129
|
+
message: `Option "-${short}" expects a value.`,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
args[flagName] = coerce(def, value);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
positionals.push(token);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Map remaining bare tokens onto positional slots.
|
|
141
|
+
Object.entries(schema).
|
|
142
|
+
filter(([, def]) => typeof def.positional === 'number').
|
|
143
|
+
sort((a, b) => a[1].positional - b[1].positional).
|
|
144
|
+
forEach(([name, def]) => {
|
|
145
|
+
if (args[name] === undefined && positionals.length) {
|
|
146
|
+
args[name] = coerce(def, positionals.shift());
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Everything set so far came from argv; record it before defaults land, so
|
|
151
|
+
// callers can tell "user chose starter" from "starter is the default".
|
|
152
|
+
const provided = new Set(Object.keys(args));
|
|
153
|
+
|
|
154
|
+
// Defaults last, so an explicit flag always wins.
|
|
155
|
+
Object.entries(schema).forEach(([name, def]) => {
|
|
156
|
+
if (args[name] === undefined && def.default !== undefined) {
|
|
157
|
+
args[name] = def.default;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
commandName,
|
|
163
|
+
command,
|
|
164
|
+
alias,
|
|
165
|
+
args,
|
|
166
|
+
provided,
|
|
167
|
+
globals,
|
|
168
|
+
errors,
|
|
169
|
+
extraPositionals: positionals,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate parsed args against the schema. Returns an array of errors so a
|
|
175
|
+
* non-interactive caller can fail deterministically instead of prompting.
|
|
176
|
+
*/
|
|
177
|
+
export function validateArgs(command, args) {
|
|
178
|
+
if (!command) return [];
|
|
179
|
+
return Object.entries(command.args).flatMap(([name, def]) => {
|
|
180
|
+
const value = args[name];
|
|
181
|
+
if (def.required && (value === undefined || value === '')) {
|
|
182
|
+
return [{
|
|
183
|
+
code: 'MISSING_REQUIRED_ARG',
|
|
184
|
+
arg: name,
|
|
185
|
+
message: `"${name}" is required.`,
|
|
186
|
+
}];
|
|
187
|
+
}
|
|
188
|
+
if (def.enum && value !== undefined && !def.enum.includes(value)) {
|
|
189
|
+
return [{
|
|
190
|
+
code: 'INVALID_ARG_VALUE',
|
|
191
|
+
arg: name,
|
|
192
|
+
message: `"${name}" must be one of: ${def.enum.join(', ')}.`,
|
|
193
|
+
}];
|
|
194
|
+
}
|
|
195
|
+
return [];
|
|
196
|
+
});
|
|
197
|
+
}
|