@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.
- package/.github/workflows/deploy.yml +34 -0
- package/LICENSE +201 -0
- package/index.ts +9 -0
- package/package.json +33 -0
- package/src/BaseCommand.ts +73 -0
- package/src/CLI.ts +316 -0
- package/src/CommandInterface.ts +22 -0
- package/src/CommandLoader.ts +83 -0
- package/src/commands/help.ts +159 -0
- package/src/utils/config.ts +43 -0
- package/src/utils/logger.ts +12 -0
- package/src/utils/shell.ts +21 -0
- package/test/e2e/basic.e2e.test.ts +16 -0
- package/test/integration/help.integration.test.ts +102 -0
- package/test/unit/commands/help.test.ts +345 -0
- package/test/unit/core/BaseCommand.test.ts +131 -0
- package/test/unit/core/CLI.config.test.ts +87 -0
- package/test/unit/core/CLI.test.ts +828 -0
- package/test/unit/core/CommandLoader.test.ts +200 -0
- package/test/unit/utils/config.test.ts +63 -0
- package/test/unit/utils/logger.test.ts +27 -0
- package/test/unit/utils/shell.test.ts +81 -0
- package/test/utils/integration-helpers.ts +22 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +18 -0
- package/vitest.config.ts +15 -0
- package/vitest.e2e.config.ts +10 -0
- package/vitest.integration.config.ts +17 -0
|
@@ -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
|
+
});
|