cli-forge 1.1.0 → 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.
@@ -1,4 +1,4 @@
1
- import { it, describe, expect, afterEach } from 'vitest';
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')
@@ -344,9 +363,39 @@ describe('cliForge', () => {
344
363
  ]);
345
364
  });
346
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
+
347
396
  it('should support strict mode', async () => {
348
397
  const mock = mockConsoleLog();
349
-
398
+
350
399
  try {
351
400
  await cli('test')
352
401
  .strict()
@@ -364,7 +413,7 @@ describe('cliForge', () => {
364
413
 
365
414
  it('should allow disabling strict mode via .strict(false)', async () => {
366
415
  let captured: any;
367
-
416
+
368
417
  await cli('test')
369
418
  .strict(false)
370
419
  .option('foo', { type: 'string' })
@@ -379,4 +428,638 @@ describe('cliForge', () => {
379
428
  expect(captured.foo).toBe('hello');
380
429
  expect(captured.unmatched).toEqual(['--unknown', 'arg']);
381
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
+ });
382
1065
  });