spyne-cli 0.6.8 → 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 +80 -30
  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
@@ -5,10 +5,10 @@
5
5
 
6
6
  ## Features
7
7
 
8
- - Create a new SpyneJS application with a single command.
9
- - Generate `ViewStream`, `DomElement`, `Channel`, and `SpyneTrait` classes via interactive prompts.
10
- - Easily extend and customize applications.
11
- - 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.
12
12
 
13
13
  ## Installation
14
14
 
@@ -20,62 +20,112 @@ To install the `spyne-cli`, you need to have Node.js and npm installed on your s
20
20
  npm install -g spyne-cli
21
21
  ```
22
22
 
23
+ ## Requirements
24
+
25
+ Node.js 18 or newer.
26
+
23
27
  ## Usage
24
28
 
25
- ### Creating a New SpyneJS Application
29
+ ### Interactive
26
30
 
27
- To create a new SpyneJS application, run:
31
+ Run with no arguments for the picker:
28
32
 
29
33
  ```bash
30
- npx spyne-cli new <app-name>
34
+ npx spyne-cli
31
35
  ```
32
36
 
33
- This will generate a fully structured SpyneJS SPA 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
+ ```
34
45
 
35
- ---
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.
36
49
 
37
- ### Generating Components (Interactive CLI)
50
+ ### Creating an application
38
51
 
39
- To generate SpyneJS components, simply run:
52
+ ```bash
53
+ npx spyne-cli create-app my-app
54
+ ```
55
+
56
+ Prompts for a template when one is not supplied. To choose one directly:
40
57
 
41
58
  ```bash
42
- npx spyne-cli
59
+ npx spyne-cli create-app my-app --template shell
43
60
  ```
44
61
 
45
- You’ll be presented with an interactive menu where you can select the file type:
62
+ | Template | Contents |
63
+ | --- | --- |
64
+ | `starter` (default) | `app.js` and a hello-world view |
65
+ | `shell` | pages, navigation, and UI components |
66
+
67
+ Options: `-t, --template`, `--no-install` to skip dependency installation,
68
+ `--no-git` to skip git initialisation.
69
+
70
+ ### Generating modules
46
71
 
47
- - `ViewStream`
48
- - `DomElement`
49
- - `Channel`
50
- - `SpyneTrait`
72
+ Each module type is also a direct command, usable inside an existing project:
51
73
 
52
- Use the arrow keys to make a selection, and follow the prompts to name and configure your file.
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
+ ```
53
80
 
54
- This interface is ideal for developers who want fast, zero-config scaffolding during development.
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.
55
84
 
56
- ---
85
+ `create-channel` also registers the channel in your `src/index.js`.
57
86
 
58
- ### Example Workflow
87
+ ### Non-interactive use
59
88
 
60
- 1. Create a new application:
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:
61
91
 
62
92
  ```bash
63
- npx spyne-cli new my-spyne-app
93
+ npx spyne-cli create-app my-app -t starter --json
64
94
  ```
65
95
 
66
- 2. Navigate to the project and start the application:
96
+ Exit codes: `0` success, `1` the command ran and failed, `2` usage error.
67
97
 
68
- ```bash
69
- cd my-spyne-app && npm start
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 }, ...]
70
107
  ```
71
108
 
72
- 3. Run the interactive generator:
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:
73
113
 
74
114
  ```bash
75
- npx spyne-cli
115
+ SPYNE_CLI_STARTER_REPO=https://github.com/you/your-starter.git \
116
+ npx spyne-cli create-app my-app
76
117
  ```
77
118
 
78
- 4. Select the file type (e.g., `ViewStream`), and follow the prompts to generate a new component.
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.
79
129
 
80
130
  ## Contributing
81
131
 
@@ -83,4 +133,4 @@ Contributions are welcome! Please feel free to submit a Pull Request or open an
83
133
 
84
134
  ## License
85
135
 
86
- 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.8",
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
+ }