clap-ts 0.3.0 → 0.4.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 (43) hide show
  1. package/dist/parser.js +5 -5
  2. package/dist/types.d.ts +14 -1
  3. package/package.json +17 -1
  4. package/src/__tests__/arg-options.test.ts +687 -0
  5. package/src/__tests__/argfile.test.ts +127 -0
  6. package/src/__tests__/clap-parity.test.ts +682 -0
  7. package/src/__tests__/command-options.test.ts +713 -0
  8. package/src/__tests__/completions.test.ts +423 -0
  9. package/src/__tests__/config.test.ts +261 -0
  10. package/src/__tests__/deprecation.test.ts +104 -0
  11. package/src/__tests__/help.test.ts +312 -0
  12. package/src/__tests__/install.test.ts +120 -0
  13. package/src/__tests__/log.test.ts +189 -0
  14. package/src/__tests__/man.test.ts +135 -0
  15. package/src/__tests__/markdown.test.ts +114 -0
  16. package/src/__tests__/output.test.ts +249 -0
  17. package/src/__tests__/parser.test.ts +627 -0
  18. package/src/__tests__/plugins.test.ts +182 -0
  19. package/src/__tests__/progress.test.ts +221 -0
  20. package/src/__tests__/prompt.test.ts +265 -0
  21. package/src/__tests__/runner.test.ts +459 -0
  22. package/src/__tests__/spec.test.ts +107 -0
  23. package/src/__tests__/testing.test.ts +93 -0
  24. package/src/__tests__/validation.test.ts +267 -0
  25. package/src/argfile.ts +188 -0
  26. package/src/completions.ts +865 -0
  27. package/src/config.ts +184 -0
  28. package/src/help.ts +779 -0
  29. package/src/index.ts +58 -0
  30. package/src/install.ts +226 -0
  31. package/src/log.ts +225 -0
  32. package/src/man.ts +289 -0
  33. package/src/markdown.ts +210 -0
  34. package/src/output.ts +453 -0
  35. package/src/parser.ts +1240 -0
  36. package/src/plugins.ts +193 -0
  37. package/src/progress.ts +295 -0
  38. package/src/prompt.ts +388 -0
  39. package/src/runner.ts +769 -0
  40. package/src/spec.ts +197 -0
  41. package/src/testing.ts +159 -0
  42. package/src/types.ts +618 -0
  43. package/src/validation.ts +627 -0
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Tests for shell completion generation across every supported shell.
3
+ */
4
+
5
+ import { describe, test, expect } from 'bun:test';
6
+ import { generateCompletions, withCompletions } from '../completions.js';
7
+ import { defineCommand } from '../runner.js';
8
+ import { runCli } from '../testing.js';
9
+ import type { CommandDef } from '../types.js';
10
+
11
+ // ---- Test Command ----
12
+
13
+ const testCommand = defineCommand({
14
+ meta: { name: 'myapp', version: '1.0.0', description: 'Test app' },
15
+ args: {
16
+ verbose: { type: 'boolean', short: 'v', description: 'Verbose output' },
17
+ port: { type: 'number', short: 'p', description: 'Port number', valueName: 'PORT' },
18
+ env: {
19
+ type: 'string',
20
+ short: 'e',
21
+ description: 'Environment',
22
+ valueParser: ['dev', 'staging', 'prod'],
23
+ },
24
+ config: {
25
+ type: 'string',
26
+ short: 'c',
27
+ description: 'Config file',
28
+ valueHint: 'filePath',
29
+ },
30
+ outDir: {
31
+ type: 'string',
32
+ description: 'Output directory',
33
+ valueHint: 'dirPath',
34
+ },
35
+ secret: { type: 'string', description: 'Secret value', hidden: true },
36
+ },
37
+ subCommands: {
38
+ serve: defineCommand({
39
+ meta: { name: 'serve', description: 'Start server', aliases: ['s'] },
40
+ args: {
41
+ host: { type: 'string', description: 'Host to bind' },
42
+ },
43
+ }),
44
+ build: defineCommand({
45
+ meta: { name: 'build', description: 'Build project' },
46
+ args: {
47
+ target: {
48
+ type: 'string',
49
+ description: 'Build target',
50
+ valueParser: ['debug', 'release'],
51
+ },
52
+ },
53
+ }),
54
+ },
55
+ });
56
+
57
+ // ---- Bash ----
58
+
59
+ describe('bash completion', () => {
60
+ test('generates valid bash script', () => {
61
+ const script = generateCompletions(testCommand, 'bash');
62
+ expect(script).toContain('# bash completion for myapp');
63
+ expect(script).toContain('_myapp()');
64
+ expect(script).toContain('complete -o default -o bashdefault -F _myapp myapp');
65
+ });
66
+
67
+ test('includes flags', () => {
68
+ const script = generateCompletions(testCommand, 'bash');
69
+ expect(script).toContain('--verbose');
70
+ expect(script).toContain('-v');
71
+ expect(script).toContain('--port');
72
+ expect(script).toContain('--env');
73
+ });
74
+
75
+ test('includes subcommands', () => {
76
+ const script = generateCompletions(testCommand, 'bash');
77
+ expect(script).toContain('serve');
78
+ expect(script).toContain('build');
79
+ });
80
+
81
+ test('generates child functions for subcommands', () => {
82
+ const script = generateCompletions(testCommand, 'bash');
83
+ expect(script).toContain('_myapp_serve()');
84
+ expect(script).toContain('_myapp_build()');
85
+ });
86
+
87
+ test('includes possible values for enum args', () => {
88
+ const script = generateCompletions(testCommand, 'bash');
89
+ expect(script).toContain("compgen -W 'dev staging prod'");
90
+ });
91
+
92
+ test('includes file completion for valueHint filePath', () => {
93
+ const script = generateCompletions(testCommand, 'bash');
94
+ expect(script).toContain('compgen -f');
95
+ });
96
+
97
+ test('includes dir completion for valueHint dirPath', () => {
98
+ const script = generateCompletions(testCommand, 'bash');
99
+ expect(script).toContain('compgen -d');
100
+ });
101
+
102
+ test('excludes hidden args', () => {
103
+ const script = generateCompletions(testCommand, 'bash');
104
+ expect(script).not.toContain('--secret');
105
+ });
106
+
107
+ test('handles subcommand aliases in dispatch', () => {
108
+ const script = generateCompletions(testCommand, 'bash');
109
+ expect(script).toContain('serve|s)');
110
+ });
111
+
112
+ test('respects custom binary name', () => {
113
+ const script = generateCompletions(testCommand, 'bash', 'my-tool');
114
+ expect(script).toContain('_my_tool()');
115
+ expect(script).toContain('complete -o default -o bashdefault -F _my_tool my-tool');
116
+ });
117
+ });
118
+
119
+ // ---- Zsh ----
120
+
121
+ describe('zsh completion', () => {
122
+ test('generates valid zsh script', () => {
123
+ const script = generateCompletions(testCommand, 'zsh');
124
+ expect(script).toContain('#compdef myapp');
125
+ expect(script).toContain('_myapp()');
126
+ expect(script).toContain('compdef _myapp myapp');
127
+ });
128
+
129
+ test('includes _arguments specs for flags', () => {
130
+ const script = generateCompletions(testCommand, 'zsh');
131
+ expect(script).toContain('--verbose');
132
+ expect(script).toContain('--port');
133
+ expect(script).toContain('_arguments');
134
+ });
135
+
136
+ test('includes subcommand values', () => {
137
+ const script = generateCompletions(testCommand, 'zsh');
138
+ expect(script).toContain("'serve:Start server'");
139
+ expect(script).toContain("'build:Build project'");
140
+ });
141
+
142
+ test('includes possible values for enum args', () => {
143
+ const script = generateCompletions(testCommand, 'zsh');
144
+ expect(script).toContain('dev staging prod');
145
+ });
146
+
147
+ test('includes file completion for valueHint', () => {
148
+ const script = generateCompletions(testCommand, 'zsh');
149
+ expect(script).toContain('_files');
150
+ });
151
+
152
+ test('includes dir completion for valueHint', () => {
153
+ const script = generateCompletions(testCommand, 'zsh');
154
+ expect(script).toContain('_directories');
155
+ });
156
+
157
+ test('generates child functions', () => {
158
+ const script = generateCompletions(testCommand, 'zsh');
159
+ expect(script).toContain('_myapp_serve()');
160
+ expect(script).toContain('_myapp_build()');
161
+ });
162
+ });
163
+
164
+ // ---- Fish ----
165
+
166
+ describe('fish completion', () => {
167
+ test('generates valid fish script', () => {
168
+ const script = generateCompletions(testCommand, 'fish');
169
+ expect(script).toContain('# fish completion for myapp');
170
+ expect(script).toContain('complete -c myapp');
171
+ });
172
+
173
+ test('includes flags with descriptions', () => {
174
+ const script = generateCompletions(testCommand, 'fish');
175
+ expect(script).toContain("-l verbose -d 'Verbose output'");
176
+ expect(script).toContain('-s v');
177
+ expect(script).toContain('-l port');
178
+ });
179
+
180
+ test('includes subcommands', () => {
181
+ const script = generateCompletions(testCommand, 'fish');
182
+ expect(script).toContain("-a 'serve' -d 'Start server'");
183
+ expect(script).toContain("-a 'build' -d 'Build project'");
184
+ });
185
+
186
+ test('includes possible values', () => {
187
+ const script = generateCompletions(testCommand, 'fish');
188
+ expect(script).toContain("'dev staging prod'");
189
+ });
190
+
191
+ test('uses -F for filePath hint', () => {
192
+ const script = generateCompletions(testCommand, 'fish');
193
+ expect(script).toContain('-F');
194
+ });
195
+
196
+ test('uses condition for subcommand scoping', () => {
197
+ const script = generateCompletions(testCommand, 'fish');
198
+ expect(script).toContain('__fish_seen_subcommand_from');
199
+ });
200
+
201
+ test('excludes hidden args', () => {
202
+ const script = generateCompletions(testCommand, 'fish');
203
+ expect(script).not.toContain('secret');
204
+ });
205
+ });
206
+
207
+ // ---- PowerShell ----
208
+
209
+ describe('powershell completion', () => {
210
+ test('generates valid powershell script', () => {
211
+ const script = generateCompletions(testCommand, 'powershell');
212
+ expect(script).toContain("Register-ArgumentCompleter -CommandName 'myapp'");
213
+ expect(script).toContain('CompletionResult');
214
+ });
215
+
216
+ test('includes flags', () => {
217
+ const script = generateCompletions(testCommand, 'powershell');
218
+ expect(script).toContain("'--verbose'");
219
+ expect(script).toContain("'-v'");
220
+ expect(script).toContain("'--port'");
221
+ });
222
+
223
+ test('includes subcommands', () => {
224
+ const script = generateCompletions(testCommand, 'powershell');
225
+ expect(script).toContain("'serve'");
226
+ expect(script).toContain("'build'");
227
+ });
228
+ });
229
+
230
+ // ---- No subcommands command ----
231
+
232
+ describe('completion for simple command (no subcommands)', () => {
233
+ const simpleCmd = defineCommand({
234
+ meta: { name: 'simple' },
235
+ args: {
236
+ name: { type: 'string', short: 'n', description: 'Your name' },
237
+ count: { type: 'number', short: 'c', description: 'Count' },
238
+ },
239
+ });
240
+
241
+ test('bash generates without subcommand dispatch', () => {
242
+ const script = generateCompletions(simpleCmd, 'bash');
243
+ expect(script).toContain('_simple()');
244
+ expect(script).toContain('--name');
245
+ expect(script).not.toContain('subcmd');
246
+ });
247
+
248
+ test('fish generates without subcommand conditions', () => {
249
+ const script = generateCompletions(simpleCmd, 'fish');
250
+ expect(script).toContain('-l name');
251
+ expect(script).not.toContain('__fish_seen_subcommand_from');
252
+ });
253
+ });
254
+
255
+ // ---- withCompletions ----
256
+
257
+ describe('withCompletions', () => {
258
+ test('injects completions subcommand', () => {
259
+ const root = defineCommand({
260
+ meta: { name: 'myapp' },
261
+ subCommands: {
262
+ serve: defineCommand({ meta: { name: 'serve' }, run() {} }),
263
+ },
264
+ });
265
+
266
+ const wrapped = withCompletions(root);
267
+ expect(wrapped.subCommands).toBeDefined();
268
+ expect(wrapped.subCommands!['completions']).toBeDefined();
269
+ expect(wrapped.subCommands!['serve']).toBeDefined();
270
+ });
271
+
272
+ test('completions subcommand outputs bash script', async () => {
273
+ const root = defineCommand({
274
+ meta: { name: 'myapp', version: '1.0.0' },
275
+ args: { verbose: { type: 'boolean', description: 'Verbose' } },
276
+ });
277
+
278
+ const { stdout } = await runCli(withCompletions(root), ['completions', 'bash']);
279
+ expect(stdout).toContain('# bash completion for myapp');
280
+ expect(stdout).toContain('complete -o default');
281
+ expect(stdout).toContain('--verbose');
282
+ });
283
+
284
+ test('completions subcommand outputs zsh script', async () => {
285
+ const root = defineCommand({
286
+ meta: { name: 'myapp' },
287
+ args: { port: { type: 'number', short: 'p', description: 'Port' } },
288
+ });
289
+
290
+ const { stdout } = await runCli(withCompletions(root), ['completions', 'zsh']);
291
+ expect(stdout).toContain('#compdef myapp');
292
+ expect(stdout).toContain('--port');
293
+ });
294
+
295
+ test('completions subcommand errors on invalid shell', async () => {
296
+ const root = defineCommand({ meta: { name: 'myapp' } });
297
+
298
+ const { plainStderr, exitCode } = await runCli(withCompletions(root), [
299
+ 'completions',
300
+ 'invalid',
301
+ ]);
302
+ expect(plainStderr).toContain("invalid value 'invalid' for '<SHELL>'");
303
+ expect(plainStderr).toContain('[possible values: bash, zsh, fish, powershell, elvish, nushell]');
304
+ expect(exitCode).toBe(2);
305
+ });
306
+
307
+ test('preserves existing subcommands', () => {
308
+ const root = defineCommand({
309
+ meta: { name: 'myapp' },
310
+ subCommands: {
311
+ serve: defineCommand({ meta: { name: 'serve' } }),
312
+ build: defineCommand({ meta: { name: 'build' } }),
313
+ },
314
+ });
315
+
316
+ const wrapped = withCompletions(root);
317
+ expect(Object.keys(wrapped.subCommands!)).toContain('serve');
318
+ expect(Object.keys(wrapped.subCommands!)).toContain('build');
319
+ expect(Object.keys(wrapped.subCommands!)).toContain('completions');
320
+ });
321
+
322
+ test('works on command with no existing subcommands', () => {
323
+ const root = defineCommand({
324
+ meta: { name: 'myapp' },
325
+ args: { name: { type: 'string' } },
326
+ });
327
+
328
+ const wrapped = withCompletions(root);
329
+ expect(wrapped.subCommands!['completions']).toBeDefined();
330
+ });
331
+ });
332
+
333
+ // ---- Elvish and nushell ----
334
+
335
+ describe('elvish completions', () => {
336
+ const command: CommandDef = {
337
+ meta: { name: 'demo', version: '1.0', description: 'A demo' },
338
+ args: {
339
+ verbose: { type: 'boolean', short: 'v', description: 'Verbose' },
340
+ secret: { type: 'string', hidden: true },
341
+ },
342
+ subCommands: {
343
+ serve: { meta: { name: 'serve', description: 'Serve', aliases: ['s'] } },
344
+ ghost: { meta: { name: 'ghost', hidden: true } },
345
+ },
346
+ };
347
+ const script = generateCompletions(command, 'elvish');
348
+
349
+ test('registers an arg completer for the binary', () => {
350
+ expect(script).toContain('set edit:completion:arg-completer[demo] =');
351
+ });
352
+
353
+ test('keys each command path with semicolons', () => {
354
+ expect(script).toContain("&'demo'=");
355
+ expect(script).toContain("&'demo;serve'=");
356
+ });
357
+
358
+ test('emits both flag forms with their help', () => {
359
+ expect(script).toContain("cand -v 'Verbose'");
360
+ expect(script).toContain("cand --verbose 'Verbose'");
361
+ });
362
+
363
+ test('includes subcommand aliases', () => {
364
+ expect(script).toContain("cand s 'Serve'");
365
+ });
366
+
367
+ test('never suggests hidden args or commands', () => {
368
+ expect(script).not.toContain('--secret');
369
+ expect(script).not.toContain("cand ghost");
370
+ });
371
+
372
+ test('but a hidden command still completes its own flags once typed', () => {
373
+ // Matching clap: hidden only means unlisted, the command still works.
374
+ expect(script).toContain("&'demo;ghost'=");
375
+ });
376
+ });
377
+
378
+ describe('nushell completions', () => {
379
+ const command: CommandDef = {
380
+ meta: { name: 'demo', version: '1.0', description: 'A demo' },
381
+ args: {
382
+ verbose: { type: 'boolean', short: 'v', description: 'Verbose' },
383
+ out: { type: 'string', valueHint: 'filePath' },
384
+ dir: { type: 'string', valueHint: 'dirPath' },
385
+ file: { type: 'positional', required: true, description: 'Input' },
386
+ },
387
+ subCommands: {
388
+ serve: {
389
+ meta: { name: 'serve', description: 'Serve' },
390
+ args: { mode: { type: 'string', valueParser: ['fast', 'safe'] } },
391
+ },
392
+ },
393
+ };
394
+ const script = generateCompletions(command, 'nushell');
395
+
396
+ test('wraps the externs in a module', () => {
397
+ expect(script).toContain('module completions {');
398
+ expect(script).toContain('export use completions *');
399
+ });
400
+
401
+ test('declares one extern per command path', () => {
402
+ expect(script).toContain('export extern "demo" [');
403
+ expect(script).toContain('export extern "demo serve" [');
404
+ });
405
+
406
+ test('pairs short and long flag forms', () => {
407
+ expect(script).toContain('--verbose(-v)');
408
+ });
409
+
410
+ test('maps value hints to nushell types', () => {
411
+ expect(script).toContain('--out: path');
412
+ expect(script).toContain('--dir: directory');
413
+ });
414
+
415
+ test('includes positionals with their optionality', () => {
416
+ expect(script).toContain('file: string # Input');
417
+ });
418
+
419
+ test('scopes a value completer to its command path', () => {
420
+ expect(script).toContain('def "nu-complete demo serve mode" []');
421
+ expect(script).toContain('[ "fast" "safe" ]');
422
+ });
423
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Tests for config file loading and its place in the precedence chain.
3
+ */
4
+
5
+ import { describe, test, expect, afterEach, beforeEach } from 'bun:test';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { tmpdir } from 'node:os';
9
+ import { loadConfig, configOptions } from '../config.js';
10
+ import { defineCommand } from '../runner.js';
11
+ import { runCli, captureArgs } from '../testing.js';
12
+
13
+ let dir: string;
14
+
15
+ beforeEach(() => {
16
+ dir = mkdtempSync(join(tmpdir(), 'clap-ts-config-'));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ delete process.env['DEMO_PORT'];
22
+ });
23
+
24
+ describe('loadConfig', () => {
25
+ test('finds .<name>rc in the starting directory', () => {
26
+ writeFileSync(join(dir, '.demorc'), JSON.stringify({ port: 1234 }));
27
+ const loaded = loadConfig('demo', { cwd: dir, stopAt: dir });
28
+ expect(loaded?.values).toEqual({ port: 1234 });
29
+ expect(loaded?.path).toBe(join(dir, '.demorc'));
30
+ });
31
+
32
+ test('walks up to a parent directory', () => {
33
+ const nested = join(dir, 'a', 'b');
34
+ mkdirSync(nested, { recursive: true });
35
+ writeFileSync(join(dir, 'demo.config.json'), JSON.stringify({ port: 7 }));
36
+ expect(loadConfig('demo', { cwd: nested, stopAt: dir })?.values).toEqual({ port: 7 });
37
+ });
38
+
39
+ test('searchParents false stays in one directory', () => {
40
+ const nested = join(dir, 'a');
41
+ mkdirSync(nested, { recursive: true });
42
+ writeFileSync(join(dir, '.demorc'), JSON.stringify({ port: 7 }));
43
+ expect(loadConfig('demo', { cwd: nested, searchParents: false })).toBeUndefined();
44
+ });
45
+
46
+ test('reads a package.json section', () => {
47
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', demo: { port: 99 } }));
48
+ expect(loadConfig('demo', { cwd: dir, stopAt: dir })?.values).toEqual({ port: 99 });
49
+ });
50
+
51
+ test('ignores package.json when the key is null', () => {
52
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ demo: { port: 99 } }));
53
+ expect(loadConfig('demo', { cwd: dir, stopAt: dir, packageJsonKey: null })).toBeUndefined();
54
+ });
55
+
56
+ test('returns undefined when nothing is found', () => {
57
+ expect(loadConfig('demo', { cwd: dir, stopAt: dir })).toBeUndefined();
58
+ });
59
+
60
+ test('an explicit path skips the search', () => {
61
+ const file = join(dir, 'custom.json');
62
+ writeFileSync(file, JSON.stringify({ port: 5 }));
63
+ expect(loadConfig('demo', { path: file })?.values).toEqual({ port: 5 });
64
+ });
65
+
66
+ test('a broken file is loud, not silently skipped', () => {
67
+ writeFileSync(join(dir, '.demorc'), '{ not json');
68
+ expect(() => loadConfig('demo', { cwd: dir, stopAt: dir })).toThrow('cannot parse config');
69
+ });
70
+
71
+ test('a non-object config is rejected', () => {
72
+ writeFileSync(join(dir, '.demorc'), '[1,2,3]');
73
+ expect(() => loadConfig('demo', { cwd: dir, stopAt: dir })).toThrow('must be an object');
74
+ });
75
+
76
+ test('a custom parser handles other formats', () => {
77
+ writeFileSync(join(dir, '.demorc'), 'port = 8080');
78
+ const loaded = loadConfig('demo', {
79
+ cwd: dir,
80
+ stopAt: dir,
81
+ parse: (text) => {
82
+ const [key, value] = text.split('=').map((s) => s.trim());
83
+ return { [key!]: Number(value) };
84
+ },
85
+ });
86
+ expect(loaded?.values).toEqual({ port: 8080 });
87
+ });
88
+ });
89
+
90
+ describe('config in the precedence chain', () => {
91
+ const main = defineCommand({
92
+ meta: { name: 'demo' },
93
+ args: {
94
+ port: { type: 'number', default: 3000, env: 'DEMO_PORT' },
95
+ host: { type: 'string' },
96
+ tags: { type: 'string', action: 'append' },
97
+ },
98
+ run() {},
99
+ });
100
+
101
+ test('config beats a default', async () => {
102
+ const { args } = await captureArgs(main, [], { config: { port: 8080 } });
103
+ expect(args['port']).toBe(8080);
104
+ });
105
+
106
+ test('the command line beats config', async () => {
107
+ const { args } = await captureArgs(main, ['--port', '1'], { config: { port: 8080 } });
108
+ expect(args['port']).toBe(1);
109
+ });
110
+
111
+ test('an env variable beats config', async () => {
112
+ process.env['DEMO_PORT'] = '2';
113
+ const { args } = await captureArgs(main, [], { config: { port: 8080 } });
114
+ expect(args['port']).toBe(2);
115
+ });
116
+
117
+ test('the source is reported as config', async () => {
118
+ let source: string | undefined;
119
+ const probe = defineCommand({
120
+ meta: { name: 'demo' },
121
+ args: { port: { type: 'number', default: 3000 } },
122
+ run({ valueSources }) {
123
+ source = valueSources.get('port');
124
+ },
125
+ });
126
+ await runCli(probe, [], { config: { port: 8080 } });
127
+ expect(source).toBe('config');
128
+ });
129
+
130
+ test('config values are coerced to the arg type', async () => {
131
+ const { args } = await captureArgs(main, [], { config: { port: '8080', host: 'h' } });
132
+ expect(args['port']).toBe(8080);
133
+ expect(args['host']).toBe('h');
134
+ });
135
+
136
+ test('an array in config feeds an append arg', async () => {
137
+ const { args } = await captureArgs(main, [], { config: { tags: ['a', 'b'] } });
138
+ expect(args['tags']).toEqual(['a', 'b']);
139
+ });
140
+
141
+ test('an unknown config key is ignored', async () => {
142
+ const { args } = await captureArgs(main, [], { config: { nonsense: 1, port: 5 } });
143
+ expect(args['port']).toBe(5);
144
+ expect(args['nonsense']).toBeUndefined();
145
+ });
146
+
147
+ test('a bad config value reports where it came from', async () => {
148
+ const { plainStderr, exitCode } = await runCli(main, [], { config: { port: 'abc' } });
149
+ expect(plainStderr).toContain('config:--port');
150
+ expect(exitCode).toBe(2);
151
+ });
152
+ });
153
+
154
+ describe('config scoped by subcommand', () => {
155
+ const main = defineCommand({
156
+ meta: { name: 'demo' },
157
+ args: { verbose: { type: 'boolean', global: true } },
158
+ subCommands: {
159
+ serve: defineCommand({
160
+ meta: { name: 'serve' },
161
+ args: { port: { type: 'number', default: 3000 } },
162
+ run() {},
163
+ }),
164
+ },
165
+ });
166
+
167
+ test('a section named for the subcommand applies to it', async () => {
168
+ const { args } = await captureArgs(main, ['serve'], {
169
+ config: { serve: { port: 8080 } },
170
+ });
171
+ expect(args['port']).toBe(8080);
172
+ });
173
+
174
+ test('a scalar at the root stays in scope for subcommands', async () => {
175
+ const { args } = await captureArgs(main, ['serve'], { config: { verbose: true } });
176
+ expect(args['verbose']).toBe(true);
177
+ });
178
+
179
+ test('the deeper section wins over the root', async () => {
180
+ const { args } = await captureArgs(main, ['serve'], {
181
+ config: { port: 1, serve: { port: 2 } },
182
+ });
183
+ expect(args['port']).toBe(2);
184
+ });
185
+ });
186
+
187
+ describe('configOptions', () => {
188
+ test('hands back a thunk that reads the file', () => {
189
+ writeFileSync(join(dir, '.demorc'), JSON.stringify({ port: 1 }));
190
+ expect(configOptions('demo', { cwd: dir, stopAt: dir }).config()).toEqual({ port: 1 });
191
+ });
192
+
193
+ test('the thunk yields undefined when nothing is found', () => {
194
+ expect(configOptions('missing', { cwd: dir, stopAt: dir }).config()).toBeUndefined();
195
+ });
196
+
197
+ test('the thunk searches at most once', () => {
198
+ writeFileSync(join(dir, '.demorc'), JSON.stringify({ port: 1 }));
199
+ const { config } = configOptions('demo', { cwd: dir, stopAt: dir });
200
+ const first = config();
201
+ rmSync(join(dir, '.demorc'));
202
+ expect(config()).toBe(first);
203
+ });
204
+ });
205
+
206
+ describe('lazy config loading', () => {
207
+ const main = defineCommand({
208
+ meta: { name: 'demo' },
209
+ args: { port: { type: 'number', default: 3000 } },
210
+ run() {},
211
+ });
212
+
213
+ test('the thunk is skipped when the command line answered everything', async () => {
214
+ let loads = 0;
215
+ await runCli(main, ['--port', '9'], {
216
+ config: () => {
217
+ loads++;
218
+ return { port: 1 };
219
+ },
220
+ });
221
+ expect(loads).toBe(0);
222
+ });
223
+
224
+ test('the thunk runs when an argument is still on its default', async () => {
225
+ let loads = 0;
226
+ const { args } = await captureArgs(main, [], {
227
+ config: () => {
228
+ loads++;
229
+ return { port: 1 };
230
+ },
231
+ });
232
+ expect(loads).toBe(1);
233
+ expect(args['port']).toBe(1);
234
+ });
235
+ });
236
+
237
+ describe('stopAtProjectRoot', () => {
238
+ test('stops the walk at the directory holding package.json', () => {
239
+ const nested = join(dir, 'proj', 'src', 'deep');
240
+ mkdirSync(nested, { recursive: true });
241
+ writeFileSync(join(dir, '.demorc'), JSON.stringify({ port: 1 }));
242
+ writeFileSync(join(dir, 'proj', 'package.json'), JSON.stringify({ name: 'p' }));
243
+
244
+ // Without the option the walk reaches the outer .demorc.
245
+ expect(loadConfig('demo', { cwd: nested, stopAt: dir })?.values).toEqual({ port: 1 });
246
+ // With it, the walk stops at proj and finds nothing.
247
+ expect(
248
+ loadConfig('demo', { cwd: nested, stopAt: dir, stopAtProjectRoot: true }),
249
+ ).toBeUndefined();
250
+ });
251
+
252
+ test('stops at a .git directory too', () => {
253
+ const nested = join(dir, 'repo', 'src');
254
+ mkdirSync(nested, { recursive: true });
255
+ mkdirSync(join(dir, 'repo', '.git'), { recursive: true });
256
+ writeFileSync(join(dir, '.demorc'), JSON.stringify({ port: 1 }));
257
+ expect(
258
+ loadConfig('demo', { cwd: nested, stopAt: dir, stopAtProjectRoot: true }),
259
+ ).toBeUndefined();
260
+ });
261
+ });