@nexical/cli-core 0.1.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.
@@ -0,0 +1,828 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { CLI } from '../../../src/CLI.js';
3
+ import { CommandLoader } from '../../../src/CommandLoader.js';
4
+ import { BaseCommand } from '../../../src/BaseCommand.js';
5
+ import { cac } from 'cac';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ vi.mock('cac');
10
+ vi.mock('../../../src/CommandLoader.js');
11
+ vi.mock('node:fs');
12
+ vi.mock('../../../src/utils/logger.js', () => ({
13
+ logger: {
14
+ debug: vi.fn(),
15
+ error: vi.fn(),
16
+ info: vi.fn(),
17
+ warn: vi.fn(),
18
+ },
19
+ setDebugMode: vi.fn()
20
+ }));
21
+
22
+ import { setDebugMode, logger } from '../../../src/utils/logger.js';
23
+
24
+ class MockCommand extends BaseCommand {
25
+ static description = 'Mock Desc';
26
+ static args = {
27
+ args: [{ name: 'arg1', required: true }, { name: 'arg2', required: false }],
28
+ options: [{ name: '--opt', description: 'desc', default: 'val' }]
29
+ };
30
+ async run() { }
31
+ }
32
+
33
+ describe('CLI', () => {
34
+ let mockCac: any;
35
+ let mockCommand: any;
36
+ let mockLoad: any;
37
+ let mockGetCommands: any;
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+
42
+ mockCommand = {
43
+ option: vi.fn().mockReturnThis(),
44
+ action: vi.fn(),
45
+ allowUnknownOptions: vi.fn().mockReturnThis(), // Added this
46
+ };
47
+
48
+ mockCac = {
49
+ command: vi.fn().mockReturnValue(mockCommand),
50
+ help: vi.fn(),
51
+ version: vi.fn(),
52
+ parse: vi.fn(),
53
+ option: vi.fn().mockReturnThis(),
54
+ outputHelp: vi.fn(),
55
+ };
56
+ (cac as any).mockReturnValue(mockCac);
57
+
58
+ mockGetCommands = vi.fn().mockReturnValue([]);
59
+ mockLoad = vi.fn().mockImplementation(async () => mockGetCommands());
60
+
61
+ // Fix: mockImplementation must return a class or function that returns an object
62
+ (CommandLoader as any).mockImplementation(function () {
63
+ return {
64
+ load: mockLoad,
65
+ getCommands: mockGetCommands
66
+ };
67
+ });
68
+ });
69
+
70
+ it('should start and load commands', async () => {
71
+ const cli = new CLI();
72
+ (fs.existsSync as any).mockReturnValue(true);
73
+
74
+ await cli.start();
75
+
76
+ expect(mockLoad).toHaveBeenCalled();
77
+ // expect(mockCac.help).toHaveBeenCalled(); // Default help disabled
78
+ expect(mockCac.version).toHaveBeenCalled();
79
+ expect(mockCac.parse).toHaveBeenCalled();
80
+ });
81
+
82
+ it('should enable debug mode if --debug flag is present', async () => {
83
+ const cli = new CLI();
84
+ (fs.existsSync as any).mockReturnValue(true);
85
+
86
+ const originalArgv = process.argv;
87
+ process.argv = [...originalArgv, '--debug'];
88
+
89
+ await cli.start();
90
+
91
+ expect(setDebugMode).toHaveBeenCalledWith(true);
92
+ expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Debug mode enabled'));
93
+
94
+ process.argv = originalArgv;
95
+ });
96
+
97
+ it('should search for commands in multiple directories', async () => {
98
+ const cli = new CLI();
99
+ (fs.existsSync as any)
100
+ .mockReturnValueOnce(false)
101
+ .mockReturnValueOnce(true); // second path found
102
+
103
+ await cli.start();
104
+ expect(fs.existsSync).toHaveBeenCalledTimes(2);
105
+ expect(mockLoad).toHaveBeenCalled();
106
+ });
107
+
108
+ it('should register loaded commands', async () => {
109
+ const cli = new CLI();
110
+ mockGetCommands.mockReturnValue([
111
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
112
+ ]);
113
+ (fs.existsSync as any).mockReturnValue(true);
114
+
115
+ await cli.start();
116
+
117
+ expect(mockCac.command).toHaveBeenCalledWith(
118
+ expect.stringContaining('test [arg1] [arg2]'), // Changed to optional brackets
119
+ 'Mock Desc'
120
+ );
121
+ expect(mockCommand.option).toHaveBeenCalledWith('--opt', 'desc', { default: 'val' });
122
+ expect(mockCommand.action).toHaveBeenCalled();
123
+ });
124
+
125
+ it('should handle command execution', async () => {
126
+ const cli = new CLI();
127
+ mockGetCommands.mockReturnValue([
128
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
129
+ ]);
130
+ (fs.existsSync as any).mockReturnValue(true);
131
+
132
+ await cli.start();
133
+
134
+ const actionFn = mockCommand.action.mock.calls[0][0];
135
+
136
+ const initSpy = vi.spyOn(MockCommand.prototype, 'init');
137
+ const runSpy = vi.spyOn(MockCommand.prototype, 'run');
138
+
139
+ // simulate cac calling action
140
+ await actionFn('val1', 'val2', { opt: 'custom' });
141
+
142
+ expect(initSpy).toHaveBeenCalled();
143
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
144
+ arg1: 'val1',
145
+ arg2: 'val2',
146
+ opt: 'custom'
147
+ }));
148
+ });
149
+
150
+ it('should handle command execution errors', async () => {
151
+ const cli = new CLI();
152
+ mockGetCommands.mockReturnValue([
153
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
154
+ ]);
155
+ (fs.existsSync as any).mockReturnValue(true);
156
+
157
+ await cli.start();
158
+
159
+ const actionFn = mockCommand.action.mock.calls[0][0];
160
+
161
+ vi.spyOn(MockCommand.prototype, 'init').mockRejectedValue(new Error('Init failed'));
162
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
163
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
164
+
165
+ await actionFn('arg1', {}, {});
166
+
167
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Init failed'));
168
+ expect(exitSpy).toHaveBeenCalledWith(1);
169
+ });
170
+
171
+ it('should print stack trace in debug mode', async () => {
172
+ const cli = new CLI();
173
+ mockGetCommands.mockReturnValue([
174
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
175
+ ]);
176
+ (fs.existsSync as any).mockReturnValue(true);
177
+
178
+ await cli.start();
179
+
180
+ const actionFn = mockCommand.action.mock.calls[0][0];
181
+
182
+ vi.spyOn(MockCommand.prototype, 'init').mockRejectedValue(new Error('Init failed'));
183
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
184
+ vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
185
+
186
+ await actionFn('arg1', { debug: true });
187
+
188
+ expect(consoleSpy).toHaveBeenCalledTimes(2); // message + stack
189
+ });
190
+
191
+ it('should handle parse errors', async () => {
192
+ const cli = new CLI();
193
+ (fs.existsSync as any).mockReturnValue(true);
194
+ mockCac.parse.mockImplementation(() => { throw new Error('Parse error'); });
195
+
196
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
197
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
198
+
199
+ await cli.start();
200
+
201
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Parse error'));
202
+ expect(exitSpy).toHaveBeenCalledWith(1);
203
+ });
204
+
205
+ it('should show help for detected command on global error', async () => {
206
+ const cli = new CLI();
207
+ (fs.existsSync as any).mockReturnValue(true);
208
+
209
+ const mockHelpRun = vi.fn();
210
+ class MockHelpCommand extends BaseCommand {
211
+ async run(opts: any) { mockHelpRun(opts); }
212
+ }
213
+
214
+ mockGetCommands.mockReturnValue([
215
+ { command: 'help', class: MockHelpCommand, instance: new MockHelpCommand(cli) },
216
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
217
+ ]);
218
+
219
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
220
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
221
+
222
+ // Mock parse to throw
223
+ mockCac.parse.mockImplementation(() => { throw new Error('Global error'); });
224
+
225
+ // Mock process.argv to simulate 'test' command
226
+ const originalArgv = process.argv;
227
+ process.argv = ['node', 'cli', 'test', '--error'];
228
+
229
+ try {
230
+ await cli.start();
231
+ } finally {
232
+ process.argv = originalArgv;
233
+ }
234
+
235
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Global error'));
236
+ expect(mockHelpRun).toHaveBeenCalledWith({ command: ['test'] });
237
+ expect(exitSpy).toHaveBeenCalledWith(1);
238
+ });
239
+ it('should handle positional arguments mapping', async () => {
240
+ const cli = new CLI();
241
+ mockGetCommands.mockReturnValue([
242
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
243
+ ]);
244
+ (fs.existsSync as any).mockReturnValue(true);
245
+
246
+ await cli.start();
247
+
248
+ const actionFn = mockCommand.action.mock.calls[0][0];
249
+
250
+ // Mock init to prevent real execution issues
251
+ vi.spyOn(MockCommand.prototype, 'init').mockResolvedValue(undefined);
252
+ const runSpy = vi.spyOn(MockCommand.prototype, 'run');
253
+
254
+ // simulate cac calling action with positional args
255
+ await actionFn('val1', 'val2', { opt: 'custom' });
256
+
257
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
258
+ arg1: 'val1',
259
+ arg2: 'val2',
260
+ opt: 'custom'
261
+ }));
262
+ });
263
+
264
+ it('should map positional args correctly when fewer provided', async () => {
265
+ const cli = new CLI();
266
+ mockGetCommands.mockReturnValue([
267
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
268
+ ]);
269
+ (fs.existsSync as any).mockReturnValue(true);
270
+
271
+ await cli.start();
272
+
273
+ const actionFn = mockCommand.action.mock.calls[0][0];
274
+
275
+ // Mock init here too
276
+ vi.spyOn(MockCommand.prototype, 'init').mockResolvedValue(undefined);
277
+ const runSpy = vi.spyOn(MockCommand.prototype, 'run');
278
+
279
+ // Provide only 1 arg
280
+ await actionFn('val1', { opt: 'default' });
281
+
282
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
283
+ arg1: 'val1',
284
+ opt: 'default'
285
+ }));
286
+ // arg2 should be undefined in options if not provided
287
+ });
288
+
289
+ it('should handle missing commands directory gracefully', async () => {
290
+ (fs.existsSync as any).mockReturnValue(false);
291
+ const cli = new CLI();
292
+ // Should not throw
293
+ await cli.start();
294
+ expect(mockLoad).not.toHaveBeenCalled();
295
+ expect(logger.debug).toHaveBeenCalledWith('No commands directory found.');
296
+ });
297
+
298
+ it('should register command without args or options', async () => {
299
+ const cli = new CLI();
300
+ class SimpleCommand extends BaseCommand {
301
+ static description = undefined as unknown as string; // Cover missing description
302
+ async run() { }
303
+ }
304
+
305
+ mockGetCommands.mockReturnValue([
306
+ { command: 'simple', class: SimpleCommand, instance: new SimpleCommand(cli) }
307
+ ]);
308
+ (fs.existsSync as any).mockReturnValue(true);
309
+
310
+ await cli.start();
311
+
312
+ expect(mockCac.command).toHaveBeenCalledWith('simple', '');
313
+ });
314
+ it('should register command with options but no positional args', async () => {
315
+ const cli = new CLI();
316
+ class NoArgsCommand extends BaseCommand {
317
+ static args = {
318
+ options: [{ name: '--flag', description: 'flag', default: false }]
319
+ }; // No 'args' array
320
+ async run() { }
321
+ }
322
+
323
+ mockGetCommands.mockReturnValue([
324
+ { command: 'noargs', class: NoArgsCommand, instance: new NoArgsCommand(cli) }
325
+ ]);
326
+ (fs.existsSync as any).mockReturnValue(true);
327
+
328
+ await cli.start();
329
+
330
+ const actionFn = mockCommand.action.mock.calls[0][0];
331
+
332
+ // This should trigger the line 85 check (argsDef.args is undefined)
333
+ await actionFn({}, { flag: true });
334
+
335
+ expect(mockCommand.option).toHaveBeenCalledWith('--flag', 'flag', { default: false });
336
+ });
337
+ it('should register command with absolutely no metadata', async () => {
338
+ const cli = new CLI();
339
+ class NoMetadataCommand extends BaseCommand {
340
+ // No static args at all
341
+ async run() { }
342
+ }
343
+ // Force args to be undefined if it was inherited or defaulted
344
+ (NoMetadataCommand as any).args = undefined;
345
+
346
+ mockGetCommands.mockReturnValue([
347
+ { command: 'nometadata', class: NoMetadataCommand, instance: new NoMetadataCommand(cli) }
348
+ ]);
349
+ (fs.existsSync as any).mockReturnValue(true);
350
+
351
+ await cli.start();
352
+
353
+ // Should register with empty description and no options/args
354
+ expect(mockCac.command).toHaveBeenCalledWith(expect.stringContaining('nometadata'), '');
355
+ });
356
+
357
+ it('should map variadic arguments correctly', async () => {
358
+ // Test class with variadic args
359
+ class VariadicCommand extends BaseCommand {
360
+ static args = {
361
+ args: [{ name: 'items...', required: true }]
362
+ };
363
+ async run() { }
364
+ }
365
+ mockGetCommands.mockReturnValue([{
366
+ command: 'list',
367
+ class: VariadicCommand,
368
+ path: '/path/to/list.ts'
369
+ }]);
370
+
371
+ const cli = new CLI();
372
+ await cli.start();
373
+
374
+ // Simulate execution: list a b c
375
+ const action = mockCommand.action.mock.calls[0][0]; // First registered cmd action
376
+ // args: [['a', 'b', 'c'], options] - CAC passes variadic as array
377
+ const options: any = {};
378
+ await action(['a', 'b', 'c'], options);
379
+
380
+ // Expect options.items to be ['a', 'b', 'c']
381
+ expect(options.items).toEqual(['a', 'b', 'c']);
382
+
383
+ // Case 2: Empty variadic
384
+ const optionsEmpty: any = {};
385
+ await action(optionsEmpty);
386
+ expect(optionsEmpty.items).toBeUndefined();
387
+ });
388
+
389
+ it('should expose commands and raw CLI instance', async () => {
390
+ const cli = new CLI();
391
+ // Just verify they return what we expect (even if empty/mocked)
392
+ expect(cli.getRawCLI()).toBeDefined();
393
+ expect(cli.getCommands()).toEqual([]);
394
+
395
+ // After start, commands should be populated
396
+ mockGetCommands.mockReturnValue([]);
397
+ (fs.existsSync as any).mockReturnValue(false);
398
+ await cli.start();
399
+
400
+ expect(cli.getCommands()).toEqual([]);
401
+ });
402
+
403
+ it('should register optional variadic command', async () => {
404
+ const cli = new CLI();
405
+ class OpVarCommand extends BaseCommand {
406
+ static args = {
407
+ args: [{ name: 'files...', required: false }],
408
+ options: [{ name: '--verbose', description: 'Verbose' }] // No default
409
+ };
410
+ async run() { }
411
+ }
412
+
413
+ mockGetCommands.mockReturnValue([
414
+ { command: 'opvar', class: OpVarCommand, instance: new OpVarCommand(cli) }
415
+ ]);
416
+ (fs.existsSync as any).mockReturnValue(true);
417
+
418
+ await cli.start();
419
+
420
+ expect(mockCac.command).toHaveBeenCalledWith(
421
+ expect.stringContaining('opvar [...files]'),
422
+ expect.anything()
423
+ );
424
+ expect(mockCommand.option).toHaveBeenCalledWith('--verbose', 'Verbose', { default: undefined });
425
+ });
426
+ it('should register and run grouped subcommands', async () => {
427
+ const cli = new CLI();
428
+ class GroupCommand extends BaseCommand {
429
+ static args = {
430
+ args: [{ name: 'arg1', required: true }],
431
+ options: [{ name: '--force', description: 'Force' }]
432
+ };
433
+ async run() { }
434
+ }
435
+
436
+ mockGetCommands.mockReturnValue([
437
+ { command: 'group add', class: GroupCommand, instance: new GroupCommand(cli) },
438
+ { command: 'group remove', class: GroupCommand, instance: new GroupCommand(cli) }
439
+ ]);
440
+ (fs.existsSync as any).mockReturnValue(true);
441
+
442
+ await cli.start();
443
+
444
+ // Should register the group root
445
+ expect(mockCac.command).toHaveBeenCalledWith(
446
+ expect.stringContaining('group [subcommand] [...args]'),
447
+ expect.anything()
448
+ );
449
+
450
+ // Find the action for the group command
451
+ // Since we registered multiple commands, find the call for 'group ...'
452
+ const groupCall = mockCac.command.mock.calls.find((call: any) => call[0].startsWith('group'));
453
+ expect(groupCall).toBeDefined();
454
+
455
+ // The processed command object (mockCommand) was returned by mockCac.command
456
+ // So mockCommand.action was called. We need to know WHICH action call corresponds to this.
457
+ // But our mockCac.command always returns the SAME mockCommand object.
458
+ // So mockCommand.action has been called multiple times (for 'test', 'opvar', etc from other tests if we didn't clear mocks properly, but beforeEach does clear).
459
+ // In THIS test, it's called for 'group ...'.
460
+
461
+ const actionFn = mockCommand.action.mock.calls[0][0];
462
+
463
+ const runSpy = vi.spyOn(GroupCommand.prototype, 'run');
464
+ vi.spyOn(GroupCommand.prototype, 'init').mockResolvedValue(undefined);
465
+
466
+ // Simulate running: group add val1 --force
467
+ // Args passed to action: subcommand, ...args, options
468
+ // subcommand = 'add'
469
+ // args = [['val1']] (variadic)
470
+ // options = { force: true }
471
+ await actionFn('add', ['val1'], { force: true });
472
+
473
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
474
+ arg1: 'val1',
475
+ force: true
476
+ }));
477
+ });
478
+
479
+ it('should handle unknown subcommand', async () => {
480
+ const cli = new CLI();
481
+ mockGetCommands.mockReturnValue([
482
+ { command: 'group add', class: MockCommand, instance: new MockCommand(cli) }
483
+ ]);
484
+ (fs.existsSync as any).mockReturnValue(true);
485
+
486
+ await cli.start();
487
+
488
+ const actionFn = mockCommand.action.mock.calls[0][0];
489
+
490
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as any);
491
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
492
+
493
+ // Run unknown subcommand
494
+ await expect(actionFn('unknown', [], {})).rejects.toThrow('EXIT');
495
+
496
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown subcommand'));
497
+ expect(exitSpy).toHaveBeenCalledWith(1);
498
+ });
499
+
500
+ it('should map positional args in subcommand', async () => {
501
+ const cli = new CLI();
502
+ class SubArgsCommand extends BaseCommand {
503
+ static args = {
504
+ args: [{ name: 'p1', required: true }, { name: 'p2', required: false }]
505
+ };
506
+ async run() { }
507
+ }
508
+ mockGetCommands.mockReturnValue([
509
+ { command: 'sys config', class: SubArgsCommand, instance: new SubArgsCommand(cli) }
510
+ ]);
511
+ (fs.existsSync as any).mockReturnValue(true);
512
+
513
+ await cli.start();
514
+
515
+ const actionFn = mockCommand.action.mock.calls[0][0];
516
+ const runSpy = vi.spyOn(SubArgsCommand.prototype, 'run');
517
+ vi.spyOn(SubArgsCommand.prototype, 'init').mockResolvedValue(undefined);
518
+
519
+ // sys config a b
520
+ await actionFn('config', ['a', 'b'], {});
521
+
522
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
523
+ p1: 'a',
524
+ p2: 'b'
525
+ }));
526
+ });
527
+
528
+ it('should map variadic args in subcommand', async () => {
529
+ const cli = new CLI();
530
+ class SubVarCommand extends BaseCommand {
531
+ static args = {
532
+ args: [{ name: 'files...', required: true }]
533
+ };
534
+ async run() { }
535
+ }
536
+ mockGetCommands.mockReturnValue([
537
+ { command: 'sys add', class: SubVarCommand, instance: new SubVarCommand(cli) }
538
+ ]);
539
+ (fs.existsSync as any).mockReturnValue(true);
540
+
541
+ await cli.start();
542
+
543
+ const actionFn = mockCommand.action.mock.calls[0][0];
544
+ const runSpy = vi.spyOn(SubVarCommand.prototype, 'run');
545
+ vi.spyOn(SubVarCommand.prototype, 'init').mockResolvedValue(undefined);
546
+
547
+ await actionFn('add', ['f1', 'f2'], {});
548
+
549
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
550
+ files: ['f1', 'f2']
551
+ }));
552
+ });
553
+
554
+ it('should handle subcommand without metadata', async () => {
555
+ const cli = new CLI();
556
+ class NoMetaSubCommand extends BaseCommand {
557
+ async run() { }
558
+ }
559
+ (NoMetaSubCommand as any).args = undefined;
560
+
561
+ mockGetCommands.mockReturnValue([
562
+ { command: 'sys plain', class: NoMetaSubCommand, instance: new NoMetaSubCommand(cli) }
563
+ ]);
564
+ (fs.existsSync as any).mockReturnValue(true);
565
+
566
+ await cli.start();
567
+
568
+ const actionFn = mockCommand.action.mock.calls[0][0];
569
+ const runSpy = vi.spyOn(NoMetaSubCommand.prototype, 'run');
570
+ vi.spyOn(NoMetaSubCommand.prototype, 'init').mockResolvedValue(undefined);
571
+
572
+ await actionFn('plain', {});
573
+
574
+ expect(runSpy).toHaveBeenCalled();
575
+ });
576
+
577
+ it('should map positional args in subcommand when fewer provided', async () => {
578
+ const cli = new CLI();
579
+ class SubOptCommand extends BaseCommand {
580
+ static args = {
581
+ args: [{ name: 'r1', required: true }, { name: 'o1', required: false }]
582
+ };
583
+ async run() { }
584
+ }
585
+ mockGetCommands.mockReturnValue([
586
+ { command: 'sys opt', class: SubOptCommand, instance: new SubOptCommand(cli) }
587
+ ]);
588
+ (fs.existsSync as any).mockReturnValue(true);
589
+
590
+ await cli.start();
591
+
592
+ const actionFn = mockCommand.action.mock.calls[0][0];
593
+ const runSpy = vi.spyOn(SubOptCommand.prototype, 'run');
594
+ vi.spyOn(SubOptCommand.prototype, 'init').mockResolvedValue(undefined);
595
+
596
+ // Provides 1 arg, expects 2 slots
597
+ await actionFn('opt', ['val1'], {});
598
+
599
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
600
+ r1: 'val1'
601
+ }));
602
+ expect(runSpy).toHaveBeenCalledWith(expect.objectContaining({
603
+ r1: 'val1'
604
+ }));
605
+ // o1 should be undefined
606
+ });
607
+
608
+ it('should intercept --help flag and run HelpCommand', async () => {
609
+ const cli = new CLI();
610
+ (fs.existsSync as any).mockReturnValue(true);
611
+
612
+ const mockHelpRun = vi.fn();
613
+ class MockHelpCommand extends BaseCommand {
614
+ async run(opts: any) { mockHelpRun(opts); }
615
+ }
616
+
617
+ mockGetCommands.mockReturnValue([
618
+ { command: 'help', class: MockHelpCommand, instance: new MockHelpCommand(cli) },
619
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
620
+ ]);
621
+
622
+ await cli.start();
623
+
624
+
625
+ // 'help' is registered first (index 0), 'test' is second (index 1)
626
+ const actionFn = mockCommand.action.mock.calls[1][0]; // test command action
627
+
628
+ // Call action with help: true options AND NO positional args (args=[{help:true}])
629
+ // This ensures validation (which requires arg1) is skipped
630
+ await actionFn({ help: true });
631
+
632
+ expect(mockHelpRun).toHaveBeenCalledWith({ command: ['test'] });
633
+ });
634
+
635
+ it('should handle help for valid subcommand without crashing', async () => {
636
+ const cli = new CLI();
637
+ (fs.existsSync as any).mockReturnValue(true);
638
+
639
+ const mockHelpRun = vi.fn();
640
+ class MockHelpCommand extends BaseCommand {
641
+ async run(opts: any) { mockHelpRun(opts); }
642
+ }
643
+
644
+ class SubCmd extends BaseCommand { async run() { } }
645
+
646
+ mockGetCommands.mockReturnValue([
647
+ { command: 'help', class: MockHelpCommand, instance: new MockHelpCommand(cli) },
648
+ { command: 'mod sub', class: SubCmd, instance: new SubCmd(cli) }
649
+ ]);
650
+
651
+ await cli.start();
652
+
653
+ // Find action for 'mod <subcommand>'
654
+ // It should be the second registered command after help
655
+ const actionFn = mockCommand.action.mock.calls[1][0];
656
+
657
+ // Call action with subcommand='sub' and help: true
658
+ await actionFn('sub', [], { help: true });
659
+
660
+ expect(mockHelpRun).toHaveBeenCalledWith({ command: ['mod', 'sub'] });
661
+ });
662
+
663
+ it('should handle help for module root (subcommand undefined)', async () => {
664
+ const cli = new CLI();
665
+ (fs.existsSync as any).mockReturnValue(true);
666
+
667
+ const mockHelpRun = vi.fn();
668
+ class MockHelpCommand extends BaseCommand {
669
+ async run(opts: any) { mockHelpRun(opts); }
670
+ }
671
+
672
+ mockGetCommands.mockReturnValue([
673
+ { command: 'help', class: MockHelpCommand, instance: new MockHelpCommand(cli) },
674
+ { command: 'mod sub', class: MockCommand, instance: new MockCommand(cli) }
675
+ ]);
676
+
677
+ await cli.start();
678
+ const actionFn = mockCommand.action.mock.calls[1][0];
679
+
680
+ // Call action with subcommand=undefined (simulating 'module --help')
681
+ await actionFn(undefined, [], { help: true });
682
+
683
+ // Should call with just ['mod']
684
+ // Should call with just ['mod']
685
+ expect(mockHelpRun).toHaveBeenCalledWith({ command: ['mod'] });
686
+ });
687
+
688
+ it('should validate required arguments for subcommands manually', async () => {
689
+ // Setup a subcommand with a required argument
690
+ const MockSubCommandClass = class {
691
+ static args = {
692
+ args: [{ name: 'reqArg', required: true }]
693
+ };
694
+ static description = 'Subcommand Desc';
695
+ setCli() { }
696
+ init() { return Promise.resolve(); }
697
+ run() { return Promise.resolve(); }
698
+ };
699
+
700
+ mockGetCommands.mockReturnValue([
701
+ { command: 'module sub', class: MockSubCommandClass, instance: new MockSubCommandClass() }
702
+ ]);
703
+ (fs.existsSync as any).mockReturnValue(true);
704
+
705
+ const cli = new CLI();
706
+ await cli.start();
707
+
708
+ // The action handler for 'module sub'
709
+ // 'module' is root, 'sub' is subcommand.
710
+ // We find the action handler registered for 'module [subcommand] [...args]'
711
+ // It should be the first one since we only loaded one command group
712
+ const actionFn = mockCac.command.mock.results[0].value.action.mock.calls[0][0];
713
+
714
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { }) as any);
715
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
716
+ const helpSpy = vi.spyOn(cli as any, 'runHelp').mockResolvedValue(undefined as any);
717
+
718
+ // Call action validation failure: subcommand='sub', options={}
719
+ // Missing 'reqArg' which is the first arg after subcommand
720
+ await actionFn('sub', {});
721
+
722
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Missing required argument: reqArg'));
723
+ expect(helpSpy).toHaveBeenCalledWith(['module', 'sub']);
724
+ expect(exitSpy).toHaveBeenCalledWith(1);
725
+ });
726
+
727
+ it('should show help when subcommand is missing (no help flag)', async () => {
728
+ const cli = new CLI();
729
+ (fs.existsSync as any).mockReturnValue(true);
730
+
731
+ const mockHelpRun = vi.fn();
732
+ class MockHelpCommand extends BaseCommand {
733
+ async run(opts: any) { mockHelpRun(opts); }
734
+ }
735
+
736
+ mockGetCommands.mockReturnValue([
737
+ { command: 'help', class: MockHelpCommand, instance: new MockHelpCommand(cli) },
738
+ { command: 'sys info', class: MockCommand, instance: new MockCommand(cli) }
739
+ ]);
740
+
741
+ await cli.start();
742
+ const actionFn = mockCommand.action.mock.calls[1][0];
743
+
744
+ // Call action with subcommand=undefined and NO help flag
745
+ await actionFn(undefined, [], {});
746
+
747
+ expect(mockHelpRun).toHaveBeenCalledWith({ command: ['sys'] });
748
+ });
749
+
750
+ it('should handle global --help flag with no command', async () => {
751
+ const cli = new CLI();
752
+ (fs.existsSync as any).mockReturnValue(true);
753
+
754
+ const mockHelpRun = vi.fn();
755
+ class MockHelpCommand extends BaseCommand {
756
+ async run(opts: any) { mockHelpRun(opts); }
757
+ }
758
+
759
+ mockGetCommands.mockReturnValue([
760
+ { command: 'help', class: MockHelpCommand, instance: new MockHelpCommand(cli) }
761
+ ]);
762
+
763
+ const originalArgv = process.argv;
764
+ process.argv = ['node', 'app', '--help'];
765
+
766
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code: any) => {
767
+ // throw to stop execution flow if needed, but the code calls it at the end
768
+ }) as any);
769
+
770
+ await cli.start();
771
+
772
+ expect(mockHelpRun).toHaveBeenCalledWith({ command: [] });
773
+ expect(exitSpy).toHaveBeenCalledWith(0);
774
+
775
+ process.argv = originalArgv;
776
+ });
777
+
778
+ it('should fallback to native help output if HelpCommand is missing', async () => {
779
+ const cli = new CLI();
780
+ (fs.existsSync as any).mockReturnValue(true);
781
+
782
+ // Load commands but NO help command
783
+ mockGetCommands.mockReturnValue([
784
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
785
+ ]);
786
+
787
+ await cli.start();
788
+
789
+ const actionFn = mockCommand.action.mock.calls[0][0];
790
+
791
+ // Access private method to force the fallback path?
792
+ // Or just trigger help via action options
793
+
794
+ await actionFn({}, { help: true });
795
+
796
+
797
+ expect(mockCac.outputHelp).toHaveBeenCalled();
798
+ });
799
+
800
+ it('should NOT run global help if command args are present with --help', async () => {
801
+ const cli = new CLI();
802
+ (fs.existsSync as any).mockReturnValue(true);
803
+
804
+ mockGetCommands.mockReturnValue([
805
+ { command: 'test', class: MockCommand, instance: new MockCommand(cli) }
806
+ ]);
807
+
808
+ const mockHelpRun = vi.fn();
809
+ class MockHelpCommand extends BaseCommand {
810
+ async run() { mockHelpRun(); }
811
+ }
812
+ (cli as any).HelpCommandClass = MockHelpCommand;
813
+
814
+ const originalArgv = process.argv;
815
+ // Simulate: app test --help
816
+ process.argv = ['node', 'app', 'test', '--help'];
817
+
818
+ await cli.start();
819
+
820
+ // Should NOT call global help run
821
+ expect(mockHelpRun).not.toHaveBeenCalled();
822
+
823
+ // Should fall through to parse
824
+ expect(mockCac.parse).toHaveBeenCalled();
825
+
826
+ process.argv = originalArgv;
827
+ });
828
+ });