cli-forge 1.0.2 → 1.2.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/dist/bin/cli.d.ts +16 -1
- package/dist/bin/commands/generate-documentation.d.ts +19 -2
- package/dist/bin/commands/generate-documentation.js +135 -0
- package/dist/bin/commands/generate-documentation.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/lib/composable-builder.d.ts +5 -1
- package/dist/lib/composable-builder.js +24 -3
- package/dist/lib/composable-builder.js.map +1 -1
- package/dist/lib/documentation.d.ts +6 -1
- package/dist/lib/documentation.js +35 -1
- package/dist/lib/documentation.js.map +1 -1
- package/dist/lib/format-help.js +20 -6
- package/dist/lib/format-help.js.map +1 -1
- package/dist/lib/interactive-shell.js +2 -0
- package/dist/lib/interactive-shell.js.map +1 -1
- package/dist/lib/internal-cli.d.ts +26 -5
- package/dist/lib/internal-cli.js +166 -38
- package/dist/lib/internal-cli.js.map +1 -1
- package/dist/lib/public-api.d.ts +59 -3
- package/dist/lib/public-api.js.map +1 -1
- package/package.json +2 -2
- package/src/bin/commands/generate-documentation.spec.ts +17 -0
- package/src/bin/commands/generate-documentation.ts +165 -2
- package/src/index.ts +1 -0
- package/src/lib/cli-localization.spec.ts +197 -0
- package/src/lib/composable-builder.spec.ts +73 -0
- package/src/lib/composable-builder.ts +26 -5
- package/src/lib/documentation.ts +49 -2
- package/src/lib/format-help.ts +24 -8
- package/src/lib/interactive-shell.ts +2 -0
- package/src/lib/internal-cli.spec.ts +720 -1
- package/src/lib/internal-cli.ts +223 -52
- package/src/lib/public-api.ts +80 -9
- package/tsconfig.lib.json.tsbuildinfo +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import { InternalCLI } from './internal-cli';
|
|
3
3
|
import { cli } from './public-api';
|
|
4
4
|
|
|
@@ -234,6 +234,25 @@ describe('cliForge', () => {
|
|
|
234
234
|
expect(process.exitCode).toBe(1);
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
+
it('should support subcommands with positional args', async () => {
|
|
238
|
+
const args = await cli('test')
|
|
239
|
+
.command(
|
|
240
|
+
cli('sub', {
|
|
241
|
+
builder: (argv) => argv.positional('name', { type: 'string' }),
|
|
242
|
+
handler: (args) => args,
|
|
243
|
+
})
|
|
244
|
+
)
|
|
245
|
+
.forge(['sub', 'example', 'fred']);
|
|
246
|
+
expect(args).toMatchInlineSnapshot(`
|
|
247
|
+
{
|
|
248
|
+
"name": "example",
|
|
249
|
+
"unmatched": [
|
|
250
|
+
"fred",
|
|
251
|
+
],
|
|
252
|
+
}
|
|
253
|
+
`);
|
|
254
|
+
});
|
|
255
|
+
|
|
237
256
|
it('should support async handlers', async () => {
|
|
238
257
|
let ran = false;
|
|
239
258
|
await cli('test')
|
|
@@ -343,4 +362,704 @@ describe('cliForge', () => {
|
|
|
343
362
|
// - 'bar' handler
|
|
344
363
|
]);
|
|
345
364
|
});
|
|
365
|
+
|
|
366
|
+
it('should run root command builder before child command handlers', async () => {
|
|
367
|
+
let ran = false;
|
|
368
|
+
const parsed = await cli('test', {
|
|
369
|
+
builder: (argv) => {
|
|
370
|
+
ran = true;
|
|
371
|
+
return argv
|
|
372
|
+
.option('boo', {
|
|
373
|
+
type: 'boolean',
|
|
374
|
+
})
|
|
375
|
+
.middleware((args) => {
|
|
376
|
+
return { ...args, injected: 'from-root-builder' };
|
|
377
|
+
});
|
|
378
|
+
},
|
|
379
|
+
handler: () => {
|
|
380
|
+
// No-op
|
|
381
|
+
},
|
|
382
|
+
})
|
|
383
|
+
.command('foo', {
|
|
384
|
+
handler: (args) => {
|
|
385
|
+
// The root command's builder middleware should have run,
|
|
386
|
+
// injecting the 'injected' property.
|
|
387
|
+
expect(args.injected).toBe('from-root-builder');
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
.forge(['foo']);
|
|
391
|
+
|
|
392
|
+
expect(ran).toBe(true);
|
|
393
|
+
expect(parsed.injected).toBe('from-root-builder');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should support strict mode', async () => {
|
|
397
|
+
const mock = mockConsoleLog();
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
await cli('test')
|
|
401
|
+
.strict()
|
|
402
|
+
.option('foo', { type: 'string' })
|
|
403
|
+
.forge(['--foo', 'hello', '--unknown', 'arg']);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
// Expected to throw
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const output = mock.getOutput();
|
|
409
|
+
expect(output).toContain('Unknown argument: --unknown');
|
|
410
|
+
expect(output).toContain('Unknown argument: arg');
|
|
411
|
+
mock.restore();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should allow disabling strict mode via .strict(false)', async () => {
|
|
415
|
+
let captured: any;
|
|
416
|
+
|
|
417
|
+
await cli('test')
|
|
418
|
+
.strict(false)
|
|
419
|
+
.option('foo', { type: 'string' })
|
|
420
|
+
.command('$0', {
|
|
421
|
+
builder: (args) => args,
|
|
422
|
+
handler: (args) => {
|
|
423
|
+
captured = args;
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
.forge(['--foo', 'hello', '--unknown', 'arg']);
|
|
427
|
+
|
|
428
|
+
expect(captured.foo).toBe('hello');
|
|
429
|
+
expect(captured.unmatched).toEqual(['--unknown', 'arg']);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should run parent middleware before evaluating child command handler', async () => {
|
|
433
|
+
const executionOrder: string[] = [];
|
|
434
|
+
let handlerArgs: any;
|
|
435
|
+
|
|
436
|
+
await cli('test')
|
|
437
|
+
.option('count', { type: 'number' })
|
|
438
|
+
.middleware((args) => {
|
|
439
|
+
executionOrder.push('parent middleware');
|
|
440
|
+
return { ...args, injected: 'from-parent' };
|
|
441
|
+
})
|
|
442
|
+
.command('child', {
|
|
443
|
+
builder: (argv) =>
|
|
444
|
+
argv.option('name', { type: 'string' }).middleware((args) => {
|
|
445
|
+
executionOrder.push('child middleware');
|
|
446
|
+
return args;
|
|
447
|
+
}),
|
|
448
|
+
handler: (args) => {
|
|
449
|
+
executionOrder.push('child handler');
|
|
450
|
+
handlerArgs = args;
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
.forge(['child', '--name', 'test', '--count', '5']);
|
|
454
|
+
|
|
455
|
+
expect(executionOrder).toEqual([
|
|
456
|
+
'parent middleware',
|
|
457
|
+
'child middleware',
|
|
458
|
+
'child handler',
|
|
459
|
+
]);
|
|
460
|
+
// Parent middleware's injected value should be visible to the child handler
|
|
461
|
+
expect(handlerArgs.injected).toBe('from-parent');
|
|
462
|
+
expect(handlerArgs.name).toBe('test');
|
|
463
|
+
expect(handlerArgs.count).toBe(5);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should run parent middleware before deeply nested child commands', async () => {
|
|
467
|
+
const executionOrder: string[] = [];
|
|
468
|
+
|
|
469
|
+
await cli('test')
|
|
470
|
+
.middleware((args) => {
|
|
471
|
+
executionOrder.push('root middleware');
|
|
472
|
+
return args;
|
|
473
|
+
})
|
|
474
|
+
.command('parent', {
|
|
475
|
+
builder: (argv) =>
|
|
476
|
+
argv
|
|
477
|
+
.middleware((args) => {
|
|
478
|
+
executionOrder.push('parent middleware');
|
|
479
|
+
return args;
|
|
480
|
+
})
|
|
481
|
+
.command('child', {
|
|
482
|
+
builder: (argv) =>
|
|
483
|
+
argv.middleware((args) => {
|
|
484
|
+
executionOrder.push('child middleware');
|
|
485
|
+
return args;
|
|
486
|
+
}),
|
|
487
|
+
handler: () => {
|
|
488
|
+
executionOrder.push('child handler');
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
handler: () => {
|
|
492
|
+
executionOrder.push('parent handler');
|
|
493
|
+
},
|
|
494
|
+
})
|
|
495
|
+
.forge(['parent', 'child']);
|
|
496
|
+
|
|
497
|
+
expect(executionOrder).toEqual([
|
|
498
|
+
'root middleware',
|
|
499
|
+
'parent middleware',
|
|
500
|
+
'child middleware',
|
|
501
|
+
'child handler',
|
|
502
|
+
]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('should support async middleware and await it before proceeding', async () => {
|
|
506
|
+
const executionOrder: string[] = [];
|
|
507
|
+
let handlerArgs: any;
|
|
508
|
+
|
|
509
|
+
await cli('test')
|
|
510
|
+
.option('name', { type: 'string' })
|
|
511
|
+
.middleware(async (args) => {
|
|
512
|
+
// Simulate an async operation (e.g., fetching config, validating tokens)
|
|
513
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
514
|
+
executionOrder.push('async root middleware');
|
|
515
|
+
return { ...args, token: 'resolved-token' };
|
|
516
|
+
})
|
|
517
|
+
.command('run', {
|
|
518
|
+
builder: (argv) =>
|
|
519
|
+
argv.middleware(async (args) => {
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
521
|
+
executionOrder.push('async child middleware');
|
|
522
|
+
return { ...args, session: 'resolved-session' };
|
|
523
|
+
}),
|
|
524
|
+
handler: (args) => {
|
|
525
|
+
executionOrder.push('handler');
|
|
526
|
+
handlerArgs = args;
|
|
527
|
+
},
|
|
528
|
+
})
|
|
529
|
+
.forge(['run', '--name', 'test']);
|
|
530
|
+
|
|
531
|
+
expect(executionOrder).toEqual([
|
|
532
|
+
'async root middleware',
|
|
533
|
+
'async child middleware',
|
|
534
|
+
'handler',
|
|
535
|
+
]);
|
|
536
|
+
expect(handlerArgs.name).toBe('test');
|
|
537
|
+
expect(handlerArgs.token).toBe('resolved-token');
|
|
538
|
+
expect(handlerArgs.session).toBe('resolved-session');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should not run coerce for flags that are not passed', async () => {
|
|
542
|
+
const coerceCalls: string[] = [];
|
|
543
|
+
let handlerArgs: any;
|
|
544
|
+
|
|
545
|
+
await cli('test')
|
|
546
|
+
.command('$0', {
|
|
547
|
+
builder: (argv) =>
|
|
548
|
+
argv
|
|
549
|
+
.option('provided', {
|
|
550
|
+
type: 'string',
|
|
551
|
+
coerce: (val) => {
|
|
552
|
+
console.trace();
|
|
553
|
+
coerceCalls.push('provided');
|
|
554
|
+
return val.toUpperCase();
|
|
555
|
+
},
|
|
556
|
+
})
|
|
557
|
+
.option('omitted', {
|
|
558
|
+
type: 'string',
|
|
559
|
+
coerce: (val) => {
|
|
560
|
+
coerceCalls.push('omitted');
|
|
561
|
+
return val.toUpperCase();
|
|
562
|
+
},
|
|
563
|
+
}),
|
|
564
|
+
handler: (args) => {
|
|
565
|
+
handlerArgs = args;
|
|
566
|
+
},
|
|
567
|
+
})
|
|
568
|
+
.forge(['--provided', 'hello']);
|
|
569
|
+
|
|
570
|
+
// Only the provided flag should have its coerce function called
|
|
571
|
+
expect(coerceCalls).toEqual(['provided']);
|
|
572
|
+
expect(handlerArgs.provided).toBe('HELLO');
|
|
573
|
+
expect(handlerArgs.omitted).toBeUndefined();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('should not run coerce on default values for non-object options', async () => {
|
|
577
|
+
const coerceCalls: string[] = [];
|
|
578
|
+
let handlerArgs: any;
|
|
579
|
+
|
|
580
|
+
await cli('test')
|
|
581
|
+
.command('$0', {
|
|
582
|
+
builder: (argv) =>
|
|
583
|
+
argv.option('flag', {
|
|
584
|
+
type: 'string',
|
|
585
|
+
default: 'default-value',
|
|
586
|
+
coerce: (val) => {
|
|
587
|
+
coerceCalls.push(val);
|
|
588
|
+
return val.toUpperCase();
|
|
589
|
+
},
|
|
590
|
+
}),
|
|
591
|
+
handler: (args) => {
|
|
592
|
+
handlerArgs = args;
|
|
593
|
+
},
|
|
594
|
+
})
|
|
595
|
+
.forge([]);
|
|
596
|
+
|
|
597
|
+
// Coerce should not be called when the flag falls back to its default
|
|
598
|
+
expect(coerceCalls).toEqual([]);
|
|
599
|
+
// The default value should be used as-is, without coercion
|
|
600
|
+
expect(handlerArgs.flag).toBe('default-value');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe('init hooks', () => {
|
|
604
|
+
it('should run init hook before command resolution', async () => {
|
|
605
|
+
let handlerCalled = false;
|
|
606
|
+
await cli('test')
|
|
607
|
+
.option('config', { type: 'string' })
|
|
608
|
+
.init(async (app, args) => {
|
|
609
|
+
expect(args.config).toBe('test.json');
|
|
610
|
+
app.command('serve', {
|
|
611
|
+
handler: () => {
|
|
612
|
+
handlerCalled = true;
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
})
|
|
616
|
+
.forge(['--config', 'test.json', 'serve']);
|
|
617
|
+
expect(handlerCalled).toBe(true);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('should run multiple init hooks sequentially', async () => {
|
|
621
|
+
const order: number[] = [];
|
|
622
|
+
await cli('test')
|
|
623
|
+
.option('config', { type: 'string' })
|
|
624
|
+
.init(async (app) => {
|
|
625
|
+
order.push(1);
|
|
626
|
+
app.command('first', {
|
|
627
|
+
handler: () => {
|
|
628
|
+
/* noop */
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
})
|
|
632
|
+
.init(async (app) => {
|
|
633
|
+
order.push(2);
|
|
634
|
+
app.command('second', {
|
|
635
|
+
handler: () => {
|
|
636
|
+
/* noop */
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
})
|
|
640
|
+
.forge(['first']);
|
|
641
|
+
expect(order).toEqual([1, 2]);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('should skip init phase when no init hooks are registered', async () => {
|
|
645
|
+
let handlerCalled = false;
|
|
646
|
+
await cli('test')
|
|
647
|
+
.option('name', { type: 'string' })
|
|
648
|
+
.command('$0', {
|
|
649
|
+
handler: (args) => {
|
|
650
|
+
handlerCalled = true;
|
|
651
|
+
expect(args.name).toBe('world');
|
|
652
|
+
},
|
|
653
|
+
})
|
|
654
|
+
.forge(['--name', 'world']);
|
|
655
|
+
expect(handlerCalled).toBe(true);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should support async init hooks', async () => {
|
|
659
|
+
let resolved = false;
|
|
660
|
+
await cli('test')
|
|
661
|
+
.init(async (app) => {
|
|
662
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
663
|
+
resolved = true;
|
|
664
|
+
app.command('run', {
|
|
665
|
+
handler: () => {
|
|
666
|
+
/* noop */
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
})
|
|
670
|
+
.forge(['run']);
|
|
671
|
+
expect(resolved).toBe(true);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('should handle init hook errors through error handler', async () => {
|
|
675
|
+
let caughtError: any;
|
|
676
|
+
try {
|
|
677
|
+
await cli('test')
|
|
678
|
+
.errorHandler((e) => {
|
|
679
|
+
caughtError = e;
|
|
680
|
+
})
|
|
681
|
+
.init(async () => {
|
|
682
|
+
throw new Error('init failed');
|
|
683
|
+
})
|
|
684
|
+
.forge([]);
|
|
685
|
+
} catch {
|
|
686
|
+
// withErrorHandlers re-throws after invoking handlers
|
|
687
|
+
}
|
|
688
|
+
expect(caughtError).toBeDefined();
|
|
689
|
+
expect(caughtError.message).toBe('init failed');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('should be able to be specified in `builder`', async () => {
|
|
693
|
+
let initRan = false;
|
|
694
|
+
const app = cli('foo', {
|
|
695
|
+
builder: (cli) =>
|
|
696
|
+
cli.init(() => {
|
|
697
|
+
initRan = true;
|
|
698
|
+
}),
|
|
699
|
+
handler: () => {
|
|
700
|
+
/* noop */
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
await app.forge();
|
|
704
|
+
expect(initRan).toBeTruthy();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('should run middleware before init hooks', async () => {
|
|
708
|
+
let initReceivedArgs: any;
|
|
709
|
+
let handlerArgs: any;
|
|
710
|
+
await cli('app')
|
|
711
|
+
.option('env', { type: 'string' })
|
|
712
|
+
.middleware((args: any) => ({
|
|
713
|
+
...args,
|
|
714
|
+
computed: `${args.env}-computed`,
|
|
715
|
+
}))
|
|
716
|
+
.init((app, args: any) => {
|
|
717
|
+
initReceivedArgs = { ...args };
|
|
718
|
+
if (args.computed === 'prod-computed') {
|
|
719
|
+
app.command('deploy', {
|
|
720
|
+
handler: (a) => {
|
|
721
|
+
handlerArgs = a;
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
})
|
|
726
|
+
.forge(['--env', 'prod', 'deploy']);
|
|
727
|
+
|
|
728
|
+
expect(initReceivedArgs.computed).toBe('prod-computed');
|
|
729
|
+
expect(handlerArgs).toBeDefined();
|
|
730
|
+
expect(handlerArgs.env).toBe('prod');
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
describe('subcommand init hooks', () => {
|
|
735
|
+
it('should run init hooks registered in a subcommand builder', async () => {
|
|
736
|
+
let initRan = false;
|
|
737
|
+
const parsed = await cli('app')
|
|
738
|
+
.command('serve', {
|
|
739
|
+
builder: (cmd) =>
|
|
740
|
+
cmd.option('port', { type: 'number' }).init((subcli) => {
|
|
741
|
+
initRan = true;
|
|
742
|
+
subcli.option('dynamic', { type: 'string' });
|
|
743
|
+
}),
|
|
744
|
+
handler: () => {
|
|
745
|
+
// noop
|
|
746
|
+
},
|
|
747
|
+
})
|
|
748
|
+
.forge(['serve', '--port', '8080', '--dynamic', 'hello']);
|
|
749
|
+
|
|
750
|
+
expect(initRan).toBe(true);
|
|
751
|
+
// parsed's typing is missing these flags since they
|
|
752
|
+
// are not on the root path, but they should be returned nonetheless
|
|
753
|
+
expect((parsed as unknown as { port: number }).port).toBe(8080);
|
|
754
|
+
expect((parsed as unknown as { dynamic: string }).dynamic).toBe('hello');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('should pass current parsed args to subcommand init hooks', async () => {
|
|
758
|
+
let initArgs: any;
|
|
759
|
+
await cli('app')
|
|
760
|
+
.option('verbose', { type: 'boolean' })
|
|
761
|
+
.command('deploy', {
|
|
762
|
+
builder: (cmd) =>
|
|
763
|
+
cmd.option('target', { type: 'string' }).init((_cli, args) => {
|
|
764
|
+
initArgs = { ...args };
|
|
765
|
+
}),
|
|
766
|
+
handler: () => {
|
|
767
|
+
/* noop */
|
|
768
|
+
},
|
|
769
|
+
})
|
|
770
|
+
// Note: command name must come before boolean flags to avoid
|
|
771
|
+
// the boolean parser consuming it as a value
|
|
772
|
+
.forge(['deploy', '--verbose', '--target', 'aws']);
|
|
773
|
+
|
|
774
|
+
expect(initArgs.verbose).toBe(true);
|
|
775
|
+
expect(initArgs.target).toBe('aws');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('should support nested subcommand init hooks', async () => {
|
|
779
|
+
const hookOrder: string[] = [];
|
|
780
|
+
let handlerArgs: any;
|
|
781
|
+
await cli('app')
|
|
782
|
+
.command('db', {
|
|
783
|
+
builder: (cmd) =>
|
|
784
|
+
cmd
|
|
785
|
+
.init(() => {
|
|
786
|
+
hookOrder.push('db');
|
|
787
|
+
})
|
|
788
|
+
.command('migrate', {
|
|
789
|
+
builder: (sub) =>
|
|
790
|
+
sub.option('direction', { type: 'string' }).init((subcli) => {
|
|
791
|
+
hookOrder.push('migrate');
|
|
792
|
+
subcli.option('dry-run', { type: 'boolean' });
|
|
793
|
+
}),
|
|
794
|
+
handler: (args) => {
|
|
795
|
+
handlerArgs = args;
|
|
796
|
+
},
|
|
797
|
+
}),
|
|
798
|
+
handler: () => {
|
|
799
|
+
/* noop */
|
|
800
|
+
},
|
|
801
|
+
})
|
|
802
|
+
.forge(['db', 'migrate', '--direction', 'up', '--dry-run']);
|
|
803
|
+
|
|
804
|
+
expect(hookOrder).toEqual(['db', 'migrate']);
|
|
805
|
+
expect(handlerArgs.direction).toBe('up');
|
|
806
|
+
expect(handlerArgs['dry-run']).toBe(true);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('should work when both root and subcommand have init hooks', async () => {
|
|
810
|
+
const hookOrder: string[] = [];
|
|
811
|
+
let handlerArgs: any;
|
|
812
|
+
await cli('app')
|
|
813
|
+
.option('config', { type: 'string' })
|
|
814
|
+
.init((app) => {
|
|
815
|
+
hookOrder.push('root');
|
|
816
|
+
app.command('serve', {
|
|
817
|
+
builder: (cmd) =>
|
|
818
|
+
cmd.option('port', { type: 'number' }).init((subcli) => {
|
|
819
|
+
hookOrder.push('serve');
|
|
820
|
+
subcli.option('hot-reload', { type: 'boolean' });
|
|
821
|
+
}),
|
|
822
|
+
handler: (args) => {
|
|
823
|
+
handlerArgs = args;
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
})
|
|
827
|
+
.forge([
|
|
828
|
+
'--config',
|
|
829
|
+
'app.json',
|
|
830
|
+
'serve',
|
|
831
|
+
'--port',
|
|
832
|
+
'3000',
|
|
833
|
+
'--hot-reload',
|
|
834
|
+
]);
|
|
835
|
+
|
|
836
|
+
expect(hookOrder).toEqual(['root', 'serve']);
|
|
837
|
+
expect(handlerArgs.config).toBe('app.json');
|
|
838
|
+
expect(handlerArgs.port).toBe(3000);
|
|
839
|
+
expect(handlerArgs['hot-reload']).toBe(true);
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
describe('middleware deduplication', () => {
|
|
844
|
+
it('should not run the same middleware twice when registered with same reference', async () => {
|
|
845
|
+
let callCount = 0;
|
|
846
|
+
const mw = (args: any) => {
|
|
847
|
+
callCount++;
|
|
848
|
+
return args;
|
|
849
|
+
};
|
|
850
|
+
await cli('test')
|
|
851
|
+
.middleware(mw)
|
|
852
|
+
.middleware(mw)
|
|
853
|
+
.command('run', {
|
|
854
|
+
handler: () => {
|
|
855
|
+
/* noop */
|
|
856
|
+
},
|
|
857
|
+
})
|
|
858
|
+
.forge(['run']);
|
|
859
|
+
expect(callCount).toBe(1);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it('should run different middleware functions even with same body', async () => {
|
|
863
|
+
const calls: string[] = [];
|
|
864
|
+
const mw1 = (args: any) => {
|
|
865
|
+
calls.push('mw1');
|
|
866
|
+
return args;
|
|
867
|
+
};
|
|
868
|
+
const mw2 = (args: any) => {
|
|
869
|
+
calls.push('mw2');
|
|
870
|
+
return args;
|
|
871
|
+
};
|
|
872
|
+
await cli('test')
|
|
873
|
+
.middleware(mw1)
|
|
874
|
+
.middleware(mw2)
|
|
875
|
+
.command('run', {
|
|
876
|
+
handler: () => {
|
|
877
|
+
/* noop */
|
|
878
|
+
},
|
|
879
|
+
})
|
|
880
|
+
.forge(['run']);
|
|
881
|
+
expect(calls).toEqual(['mw1', 'mw2']);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should preserve middleware insertion order', async () => {
|
|
885
|
+
const order: number[] = [];
|
|
886
|
+
const mw1 = (args: any) => {
|
|
887
|
+
order.push(1);
|
|
888
|
+
return args;
|
|
889
|
+
};
|
|
890
|
+
const mw2 = (args: any) => {
|
|
891
|
+
order.push(2);
|
|
892
|
+
return args;
|
|
893
|
+
};
|
|
894
|
+
const mw3 = (args: any) => {
|
|
895
|
+
order.push(3);
|
|
896
|
+
return args;
|
|
897
|
+
};
|
|
898
|
+
await cli('test')
|
|
899
|
+
.middleware(mw1)
|
|
900
|
+
.middleware(mw2)
|
|
901
|
+
.middleware(mw3)
|
|
902
|
+
.middleware(mw1) // duplicate — should not change order
|
|
903
|
+
.command('run', {
|
|
904
|
+
handler: () => {
|
|
905
|
+
/* noop */
|
|
906
|
+
},
|
|
907
|
+
})
|
|
908
|
+
.forge(['run']);
|
|
909
|
+
expect(order).toEqual([1, 2, 3]);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it('should deduplicate middleware across parent and child commands', async () => {
|
|
913
|
+
let callCount = 0;
|
|
914
|
+
const sharedMw = (args: any) => {
|
|
915
|
+
callCount++;
|
|
916
|
+
return args;
|
|
917
|
+
};
|
|
918
|
+
await cli('test')
|
|
919
|
+
.middleware(sharedMw)
|
|
920
|
+
.command('child', {
|
|
921
|
+
builder: (cmd) => cmd.middleware(sharedMw),
|
|
922
|
+
handler: () => {
|
|
923
|
+
/* noop */
|
|
924
|
+
},
|
|
925
|
+
})
|
|
926
|
+
.forge(['child']);
|
|
927
|
+
expect(callCount).toBe(1);
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
describe('integration: init hooks + composable builders + middleware dedup', () => {
|
|
932
|
+
it('should support the full plugin loading pattern', async () => {
|
|
933
|
+
// Simulates: my-app --config with-plugins plugin-cmd --watch --verbose
|
|
934
|
+
// 1. Lenient parse matches --config and --verbose (registered on parent)
|
|
935
|
+
// 2. Init hook reads config, registers plugin-cmd
|
|
936
|
+
// 3. Re-parse resolves 'plugin-cmd --watch' from unmatched tokens
|
|
937
|
+
let handlerArgs: any;
|
|
938
|
+
let mwCallCount = 0;
|
|
939
|
+
const sharedMw = (args: any) => {
|
|
940
|
+
mwCallCount++;
|
|
941
|
+
return args;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
await cli('app')
|
|
945
|
+
.option('config', { type: 'string' })
|
|
946
|
+
.option('verbose', { type: 'boolean', alias: ['v'] })
|
|
947
|
+
.middleware(sharedMw)
|
|
948
|
+
.init(async (app, args) => {
|
|
949
|
+
expect(args.config).toBe('with-plugins');
|
|
950
|
+
expect(args.verbose).toBe(true);
|
|
951
|
+
// Plugin commands register options via builder so they work with
|
|
952
|
+
// the shared parser during command resolution
|
|
953
|
+
app.command('plugin-cmd', {
|
|
954
|
+
builder: (cmd) =>
|
|
955
|
+
cmd.middleware(sharedMw).option('watch', { type: 'boolean' }),
|
|
956
|
+
handler: (a) => {
|
|
957
|
+
handlerArgs = a;
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
})
|
|
961
|
+
.forge([
|
|
962
|
+
'--config',
|
|
963
|
+
'with-plugins',
|
|
964
|
+
'plugin-cmd',
|
|
965
|
+
'--watch',
|
|
966
|
+
'--verbose',
|
|
967
|
+
]);
|
|
968
|
+
|
|
969
|
+
expect(handlerArgs).toBeDefined();
|
|
970
|
+
expect(handlerArgs.config).toBe('with-plugins');
|
|
971
|
+
expect(handlerArgs.verbose).toBe(true);
|
|
972
|
+
expect(handlerArgs.watch).toBe(true);
|
|
973
|
+
// Shared middleware should only run once despite being on parent and plugin
|
|
974
|
+
expect(mwCallCount).toBe(1);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it('should handle init hooks that register options consumed in re-parse', async () => {
|
|
978
|
+
let handlerArgs: any;
|
|
979
|
+
await cli('app')
|
|
980
|
+
.option('config', { type: 'string' })
|
|
981
|
+
.init(async (app) => {
|
|
982
|
+
// Init hook adds a new option that was previously unmatched
|
|
983
|
+
app.option('extra', { type: 'string' });
|
|
984
|
+
})
|
|
985
|
+
.command('$0', {
|
|
986
|
+
handler: (a) => {
|
|
987
|
+
handlerArgs = a;
|
|
988
|
+
},
|
|
989
|
+
})
|
|
990
|
+
.forge(['--config', 'test.json', '--extra', 'bonus']);
|
|
991
|
+
|
|
992
|
+
expect(handlerArgs.config).toBe('test.json');
|
|
993
|
+
expect(handlerArgs.extra).toBe('bonus');
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it('should pass only matched args to init hooks, not unmatched tokens', async () => {
|
|
997
|
+
let initArgs: any;
|
|
998
|
+
await cli('app')
|
|
999
|
+
.option('config', { type: 'string' })
|
|
1000
|
+
.init(async (app, args) => {
|
|
1001
|
+
initArgs = { ...args };
|
|
1002
|
+
app.command('deploy', {
|
|
1003
|
+
builder: (cmd) => cmd.option('target', { type: 'string' }),
|
|
1004
|
+
handler: () => {
|
|
1005
|
+
/* noop */
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
})
|
|
1009
|
+
.forge(['--config', 'prod.json', 'deploy', '--target', 'aws']);
|
|
1010
|
+
|
|
1011
|
+
expect(initArgs.config).toBe('prod.json');
|
|
1012
|
+
// 'deploy' and '--target' should be in unmatched, not as parsed args
|
|
1013
|
+
expect(initArgs.target).toBeUndefined();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('should merge env/default values from lenient parse with re-parse results', async () => {
|
|
1017
|
+
let handlerArgs: any;
|
|
1018
|
+
process.env['APP_VERBOSE'] = 'true';
|
|
1019
|
+
process.env['APP_CONFIG'] = 'from-env.json';
|
|
1020
|
+
try {
|
|
1021
|
+
await cli('app')
|
|
1022
|
+
.env('APP')
|
|
1023
|
+
.option('verbose', { type: 'boolean' })
|
|
1024
|
+
.option('config', { type: 'string' })
|
|
1025
|
+
.init(async (app, args) => {
|
|
1026
|
+
// env vars should be populated even in lenient parse
|
|
1027
|
+
expect(args.verbose).toBe(true);
|
|
1028
|
+
expect(args.config).toBe('from-env.json');
|
|
1029
|
+
app.command('run', {
|
|
1030
|
+
handler: (a) => {
|
|
1031
|
+
handlerArgs = a;
|
|
1032
|
+
},
|
|
1033
|
+
});
|
|
1034
|
+
})
|
|
1035
|
+
.forge(['run']);
|
|
1036
|
+
} finally {
|
|
1037
|
+
delete process.env['APP_VERBOSE'];
|
|
1038
|
+
delete process.env['APP_CONFIG'];
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
expect(handlerArgs.verbose).toBe(true);
|
|
1042
|
+
expect(handlerArgs.config).toBe('from-env.json');
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('should merge default values from lenient parse with re-parse results', async () => {
|
|
1046
|
+
let handlerArgs: any;
|
|
1047
|
+
await cli('app')
|
|
1048
|
+
.option('verbose', { type: 'boolean', default: false })
|
|
1049
|
+
.option('config', { type: 'string', default: 'default.json' })
|
|
1050
|
+
.init(async (app, args) => {
|
|
1051
|
+
expect(args.verbose).toBe(false);
|
|
1052
|
+
expect(args.config).toBe('default.json');
|
|
1053
|
+
app.command('run', {
|
|
1054
|
+
handler: (a) => {
|
|
1055
|
+
handlerArgs = a;
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
})
|
|
1059
|
+
.forge(['run']);
|
|
1060
|
+
|
|
1061
|
+
expect(handlerArgs.verbose).toBe(false);
|
|
1062
|
+
expect(handlerArgs.config).toBe('default.json');
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
346
1065
|
});
|