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.
Files changed (93) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +90 -38
  3. package/index.js +123 -21
  4. package/package.json +24 -6
  5. package/src/cli/args.js +197 -0
  6. package/src/cli/render.js +184 -0
  7. package/src/core/create-app.js +232 -0
  8. package/src/core/generate-module.js +146 -0
  9. package/src/registry.js +174 -0
  10. package/src/spyne-file-prompt.js +77 -30
  11. package/src/templates/generate-prompt-input-fields.js +1 -1
  12. package/src/ui.js +5 -1
  13. package/lib/ansi.js +0 -116
  14. package/lib/combos.js +0 -75
  15. package/lib/completer.js +0 -52
  16. package/lib/interpolate.js +0 -266
  17. package/lib/keypress.js +0 -243
  18. package/lib/placeholder.js +0 -63
  19. package/lib/prompt.js +0 -485
  20. package/lib/prompts/autocomplete.js +0 -113
  21. package/lib/prompts/basicauth.js +0 -41
  22. package/lib/prompts/confirm.js +0 -13
  23. package/lib/prompts/editable.js +0 -136
  24. package/lib/prompts/form.js +0 -196
  25. package/lib/prompts/index.js +0 -28
  26. package/lib/prompts/input.js +0 -55
  27. package/lib/prompts/invisible.js +0 -11
  28. package/lib/prompts/list.js +0 -36
  29. package/lib/prompts/multiselect.js +0 -11
  30. package/lib/prompts/numeral.js +0 -1
  31. package/lib/prompts/password.js +0 -18
  32. package/lib/prompts/quiz.js +0 -37
  33. package/lib/prompts/scale.js +0 -237
  34. package/lib/prompts/select.js +0 -139
  35. package/lib/prompts/snippet.js +0 -185
  36. package/lib/prompts/sort.js +0 -37
  37. package/lib/prompts/survey.js +0 -163
  38. package/lib/prompts/text.js +0 -1
  39. package/lib/prompts/toggle.js +0 -109
  40. package/lib/render.js +0 -33
  41. package/lib/roles.js +0 -46
  42. package/lib/state.js +0 -69
  43. package/lib/styles.js +0 -144
  44. package/lib/symbols.js +0 -66
  45. package/lib/theme.js +0 -11
  46. package/lib/timer.js +0 -38
  47. package/lib/types/array.js +0 -658
  48. package/lib/types/auth.js +0 -29
  49. package/lib/types/boolean.js +0 -88
  50. package/lib/types/index.js +0 -7
  51. package/lib/types/number.js +0 -86
  52. package/lib/types/string.js +0 -185
  53. package/lib/utils.js +0 -268
  54. package/mocha.conf.cjs +0 -16
  55. package/src/app/channels/.gitkeep +0 -0
  56. package/src/app/channels/channel-custom.js +0 -1
  57. package/src/app/components/.gitkeep +0 -0
  58. package/src/app/traits/.gitkeep +0 -0
  59. package/src/hello-world-app-source/README.md +0 -15
  60. package/src/hello-world-app-source/apache-htaccess +0 -20
  61. package/src/hello-world-app-source/karma.conf.js +0 -75
  62. package/src/hello-world-app-source/package.json +0 -50
  63. package/src/hello-world-app-source/src/app/app-view.js +0 -38
  64. package/src/hello-world-app-source/src/app/channels/.gitkeep +0 -0
  65. package/src/hello-world-app-source/src/app/components/.gitkeep +0 -0
  66. package/src/hello-world-app-source/src/app/traits/.gitkeep +0 -0
  67. package/src/hello-world-app-source/src/index.js +0 -16
  68. package/src/hello-world-app-source/src/index.tmpl.html +0 -12
  69. package/src/hello-world-app-source/src/scss/01-vendors/normalize.css +0 -349
  70. package/src/hello-world-app-source/src/scss/02-variables/mixins.scss +0 -0
  71. package/src/hello-world-app-source/src/scss/03-components/.gitkeep +0 -0
  72. package/src/hello-world-app-source/src/scss/main.scss +0 -15
  73. package/src/hello-world-app-source/src/static/data/.gitkeep +0 -0
  74. package/src/hello-world-app-source/src/static/fonts/.gitkeep +0 -0
  75. package/src/hello-world-app-source/src/static/imgs/.gitkeep +0 -0
  76. package/src/hello-world-app-source/src/tests/unit-tests/index.test.js +0 -10
  77. package/src/hello-world-app-source/webpack.config.js +0 -178
  78. package/src/spyne-app-create.js +0 -209
  79. package/src/spyne-app-creator.js +0 -119
  80. package/src/spyne-starter-app-create.js +0 -147
  81. package/tests/create-spyne-app.test.js +0 -63
  82. package/tests/generate-file-prompt.test.js +0 -45
  83. package/tests/generate-file-string.test.js +0 -66
  84. package/tests/generate-prompt-input-fields-methods.test.js +0 -177
  85. package/tests/generate-prompt-input-fields.test.js +0 -179
  86. package/tests/generate-prompt-input-object.test.js +0 -39
  87. package/tests/generate-prompt-output.test.js +0 -58
  88. package/tests/index.test.js +0 -11
  89. package/tests/mocks/answers.js +0 -33
  90. package/tests/mocks/answers.json +0 -33
  91. package/tests/mocks/enquirer-data.js +0 -411
  92. package/tests/mocks/enquirer-data.json +0 -409
  93. 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 with a single command.
8
- - Generate `ViewStream`, `Channel`, and `SpyneTrait` classes based on user prompts.
9
- - Easily extend and customize applications.
10
- - Built-in support for channel-driven development.
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
- ### Creating a New SpyneJS Application
29
+ ### Interactive
25
30
 
26
- To create a new SpyneJS application, run:
31
+ Run with no arguments for the picker:
27
32
 
28
33
  ```bash
29
- npx spyne-cli new <app-name>
34
+ npx spyne-cli
30
35
  ```
31
36
 
32
- This will generate the necessary project files in the specified `<app-name>` directory.
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
- ### Generating Components
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
- You can use `spyne-cli` to generate new `ViewStream`, `Channel`, and `SpyneTrait` components:
50
+ ### Creating an application
37
51
 
38
- - **ViewStream**: To generate a new `ViewStream`, use:
52
+ ```bash
53
+ npx spyne-cli create-app my-app
54
+ ```
39
55
 
40
- ```bash
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
- - **Channel**: To generate a new `Channel`, use:
58
+ ```bash
59
+ npx spyne-cli create-app my-app --template shell
60
+ ```
45
61
 
46
- ```bash
47
- npx spyne-cli generate channel <channel-name>
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
- - **SpyneTrait**: To generate a new `SpyneTrait`, use:
67
+ Options: `-t, --template`, `--no-install` to skip dependency installation,
68
+ `--no-git` to skip git initialisation.
51
69
 
52
- ```bash
53
- npx spyne-cli generate spynetrait <trait-name>
54
- ```
70
+ ### Generating modules
55
71
 
56
- ### Example
72
+ Each module type is also a direct command, usable inside an existing project:
57
73
 
58
- To create a new application and add a `ViewStream` component:
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
- 1. Create a new application:
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
- ```bash
63
- npx spyne-cli new my-spyne-app
64
- ```
85
+ `create-channel` also registers the channel in your `src/index.js`.
65
86
 
66
- 2. Navigate to and start the application:
87
+ ### Non-interactive use
67
88
 
68
- ```bash
69
- cd my-spyne-app && npm start
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
- 3. Generate a `ViewStream` component:
92
+ ```bash
93
+ npx spyne-cli create-app my-app -t starter --json
94
+ ```
73
95
 
74
- ```bash
75
- npx spyne-cli generate viewstream MyView
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 MIT License.
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 { SpyneCliUI } from './src/ui.js';
3
+ import {readFileSync} from 'fs';
4
4
  import clear from 'clear';
5
- import { createNewApp } from './src/spyne-starter-app-create.js';
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
- const command = process.argv[2];
9
- const appName = process.argv[3];
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
- // Simple check if user included "-spa" or "--spa" anywhere in the process.argv
12
- const isSPA = process.argv.includes('-spa') || process.argv.includes('--spa');
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 startPromptFn = async () => {
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
- const spyneFilePrompt = new SpyneFilePrompt();
18
- await spyneFilePrompt.startPrompt();
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 (command === 'new' && appName) {
22
- (async () => {
23
- try {
24
- // Pass the isSPA boolean to createNewApp
25
- await createNewApp(appName, isSPA);
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
- } else {
32
- startPromptFn();
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.6.7",
7
+ "version": "0.7.0",
8
8
  "description": "Generates spyne objects and saves them to standard spyne.",
9
- "main": "index.js",
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
  },
@@ -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
+ }