@hardfin/cli 0.0.2-dev.5 → 0.0.2-dev.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +65 -1
  2. package/dist/cli.js +1168 -6
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -7,7 +7,8 @@ It is built for people at a terminal and for agents that call Hardfin on their b
7
7
 
8
8
  ## Status
9
9
 
10
- This package is a placeholder that reserves the name. No commands work yet.
10
+ The CLI is early. `hardfin api` and the generated commands work against an API key, and
11
+ `hardfin login` is not built yet.
11
12
 
12
13
  ## Install
13
14
 
@@ -21,6 +22,69 @@ Previews of unreleased work are published from the `dev` branch.
21
22
  npm install -g @hardfin/cli@dev
22
23
  ```
23
24
 
25
+ ## The command surface
26
+
27
+ Most commands are generated from the published API document rather than written by hand.
28
+
29
+ | Piece | Holds |
30
+ | --- | --- |
31
+ | `hardfin api` | A call to any endpoint, written by hand, and the escape hatch when no generated command fits |
32
+ | `src/command/surface.generated.ts` | Every endpoint as a command, rewritten by the generator |
33
+ | `surface-overrides.json` | The operations whose generated name is wrong |
34
+ | `scripts/generate-surface.mjs` | The generator |
35
+
36
+ ### How an endpoint becomes a command
37
+
38
+ The first path segment is the noun. Every later literal segment nests under it. The method
39
+ and the shape of the last segment decide the verb.
40
+
41
+ | Endpoint | Command |
42
+ | --- | --- |
43
+ | `GET /asset` | `hardfin asset list` |
44
+ | `POST /asset` | `hardfin asset create` |
45
+ | `GET /asset/{assetKey}` | `hardfin asset get <assetKey>` |
46
+ | `PATCH /asset/{assetKey}` | `hardfin asset update <assetKey>` |
47
+ | `PATCH /asset/{assetKey}/accounting` | `hardfin asset accounting update <assetKey>` |
48
+
49
+ A path parameter becomes a positional argument. A query parameter becomes a flag, named in
50
+ kebab case, and an array parameter becomes a flag you repeat. An enum parameter carries its
51
+ values, so a wrong value fails locally with exit code 2 rather than at the API.
52
+
53
+ ### Overrides
54
+
55
+ Two endpoints sometimes want one name. `DELETE /asset/{assetKey}/ownership` and
56
+ `DELETE /asset/{assetKey}/ownership/{segmentKey}` both generate `asset ownership delete`, so
57
+ the generator stops and names the pair.
58
+
59
+ Settle it in `surface-overrides.json`, keyed by `operationId`:
60
+
61
+ ```json
62
+ {
63
+ "assetClearAssetActiveOwnership": {
64
+ "command": ["asset", "ownership", "clear"],
65
+ "summary": "Clear the ownership an asset holds today"
66
+ }
67
+ }
68
+ ```
69
+
70
+ An override for an `operationId` the document no longer publishes fails the generator. That
71
+ is deliberate, because a silently dropped override renames a command nobody meant to rename.
72
+
73
+ ### Regenerating
74
+
75
+ The Surface workflow runs each weekday, reads `reference/core.openapi.yaml` from the
76
+ `hardfinhq/api-spec` repository through a read-only deploy key, and opens a pull request when
77
+ the generated file changes. It needs the `API_SPEC_READ_DEPLOY_KEY` secret.
78
+
79
+ Run it by hand against a local document:
80
+
81
+ ```sh
82
+ npm run generate-surface -- ../api-spec/reference/core.openapi.yaml
83
+ ```
84
+
85
+ The document is bundled, meaning its external files are inlined, but `$ref` pointers within
86
+ it remain. The generator follows those pointers itself.
87
+
24
88
  ## Releasing
25
89
 
26
90
  This section is for anyone who merges a pull request in this repository. It tells you where
package/dist/cli.js CHANGED
@@ -282,14 +282,14 @@ async function runApi(input) {
282
282
  writeFailure("a path is required, such as /customer", input.isJSON);
283
283
  return ExitCode.USAGE;
284
284
  }
285
- const query = toQuery(input.flags["field"]);
285
+ const query = toQuery$1(input.flags["field"]);
286
286
  if (query === void 0) {
287
287
  writeFailure("each --field is key=value, such as -f limit=50", input.isJSON);
288
288
  return ExitCode.USAGE;
289
289
  }
290
290
  let body;
291
291
  if (typeof input.flags["input"] === "string") {
292
- body = toBody(input.flags["input"]);
292
+ body = toBody$1(input.flags["input"]);
293
293
  if (body === void 0) {
294
294
  writeFailure(`${input.flags["input"]} does not hold JSON`, input.isJSON);
295
295
  return ExitCode.USAGE;
@@ -315,7 +315,7 @@ async function runApi(input) {
315
315
  }
316
316
  }
317
317
  /** toQuery folds the repeated --field flags into query parameters. */
318
- function toQuery(fields) {
318
+ function toQuery$1(fields) {
319
319
  const query = new URLSearchParams();
320
320
  for (const field of Array.isArray(fields) ? fields : []) {
321
321
  const split = field.indexOf("=");
@@ -324,6 +324,100 @@ function toQuery(fields) {
324
324
  }
325
325
  return query;
326
326
  }
327
+ function toBody$1(source) {
328
+ const text = source === "-" ? readFileSync(0, "utf8") : readFileSync(source, "utf8");
329
+ try {
330
+ return JSON.parse(text);
331
+ } catch {
332
+ return;
333
+ }
334
+ }
335
+ //#endregion
336
+ //#region src/command/operation.ts
337
+ const INPUT_FLAG = {
338
+ name: "input",
339
+ description: "A file holding the JSON request body, or - for stdin",
340
+ valueName: "file",
341
+ schema: z.string()
342
+ };
343
+ const JSON_FLAG = {
344
+ name: "json",
345
+ description: "Print machine-readable output, which is the default when stdout is not a terminal",
346
+ schema: z.boolean()
347
+ };
348
+ /** defineOperation turns one endpoint into the command that calls it. */
349
+ function defineOperation(operation) {
350
+ const flags = [...operation.queryFlags, JSON_FLAG];
351
+ if (operation.takesBody) flags.splice(flags.length - 1, 0, INPUT_FLAG);
352
+ return {
353
+ name: operation.name,
354
+ summary: operation.summary,
355
+ description: operation.description ?? operation.summary,
356
+ arguments: operation.pathParameters,
357
+ flags,
358
+ examples: [],
359
+ run: (input) => runOperation(operation, input)
360
+ };
361
+ }
362
+ async function runOperation(operation, input) {
363
+ const apiKey = toApiKey();
364
+ if (!apiKey) {
365
+ writeFailure("not authenticated. Set HARDFIN_API_KEY to an API key for your organization", input.isJSON);
366
+ return ExitCode.NOT_AUTHENTICATED;
367
+ }
368
+ const path = toPath(operation, input.args);
369
+ if (path === void 0) {
370
+ writeFailure(`this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
371
+ return ExitCode.USAGE;
372
+ }
373
+ let body;
374
+ if (typeof input.flags["input"] === "string") {
375
+ body = toBody(input.flags["input"]);
376
+ if (body === void 0) {
377
+ writeFailure(`${input.flags["input"]} does not hold JSON`, input.isJSON);
378
+ return ExitCode.USAGE;
379
+ }
380
+ }
381
+ try {
382
+ writeData((await request({
383
+ apiUrl: toApiUrl(),
384
+ apiKey,
385
+ method: operation.method,
386
+ path,
387
+ query: toQuery(operation, input.flags),
388
+ body
389
+ })).data);
390
+ return ExitCode.OK;
391
+ } catch (error) {
392
+ if (error instanceof RequestFailure) {
393
+ writeFailure(error.message, input.isJSON, error.errors, error.requestId);
394
+ return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
395
+ }
396
+ writeFailure(error instanceof Error ? error.message : String(error), input.isJSON);
397
+ return ExitCode.ERROR;
398
+ }
399
+ }
400
+ /** toPath fills the path template from the positional arguments, in order. */
401
+ function toPath(operation, args) {
402
+ if (args.length !== operation.pathParameters.length) return;
403
+ let path = operation.path;
404
+ for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
405
+ return path;
406
+ }
407
+ /** toQuery carries only the flags this invocation actually set. */
408
+ function toQuery(operation, flags) {
409
+ const query = new URLSearchParams();
410
+ for (const flag of operation.queryFlags) {
411
+ const value = flags[toOptionKey(flag.name)];
412
+ if (value === void 0) continue;
413
+ for (const entry of Array.isArray(value) ? value : [value]) query.append(flag.queryName, String(entry));
414
+ }
415
+ return query;
416
+ }
417
+ /** toOptionKey names the parsed flag, which the parser reports in camel case. */
418
+ function toOptionKey(name) {
419
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
420
+ }
327
421
  function toBody(source) {
328
422
  const text = source === "-" ? readFileSync(0, "utf8") : readFileSync(source, "utf8");
329
423
  try {
@@ -335,7 +429,1068 @@ function toBody(source) {
335
429
  //#endregion
336
430
  //#region src/command/commands.ts
337
431
  /** commands is every command the CLI offers, and drives help and the agent guide. */
338
- const commands = [apiCommand, agentGuideCommand];
432
+ const commands = [
433
+ ...[
434
+ {
435
+ name: "asset",
436
+ summary: "Asset commands",
437
+ arguments: [],
438
+ flags: [],
439
+ examples: [],
440
+ subcommands: [
441
+ defineOperation({
442
+ name: "list",
443
+ summary: "Get asset listing",
444
+ method: "GET",
445
+ path: "/asset",
446
+ pathParameters: [],
447
+ queryFlags: [
448
+ {
449
+ name: "page",
450
+ queryName: "page",
451
+ description: "The page to return, starting at 1",
452
+ valueName: "number",
453
+ schema: z.coerce.number()
454
+ },
455
+ {
456
+ name: "limit",
457
+ queryName: "limit",
458
+ description: "The number of records per page, from 1 to 100",
459
+ valueName: "number",
460
+ schema: z.coerce.number()
461
+ },
462
+ {
463
+ name: "sort-by",
464
+ queryName: "sortBy",
465
+ description: "The field to sort by",
466
+ valueName: "value",
467
+ schema: z.enum([
468
+ "serial",
469
+ "project",
470
+ "item",
471
+ "location",
472
+ "owner",
473
+ "activity"
474
+ ])
475
+ },
476
+ {
477
+ name: "sort-order",
478
+ queryName: "sortOrder",
479
+ description: "The sort direction",
480
+ valueName: "value",
481
+ schema: z.enum(["ASC", "DESC"])
482
+ },
483
+ {
484
+ name: "archived",
485
+ queryName: "archived",
486
+ description: "Whether to return unarchived records, archived records, or all of them",
487
+ valueName: "value",
488
+ schema: z.enum([
489
+ "all",
490
+ "false",
491
+ "true"
492
+ ])
493
+ },
494
+ {
495
+ name: "search-query",
496
+ queryName: "searchQuery",
497
+ description: "Text to match against the serial, description, item, project, owner, and location, ignored when shorter than three characters",
498
+ valueName: "value",
499
+ schema: z.string()
500
+ },
501
+ {
502
+ name: "for-asset-id",
503
+ queryName: "forAssetId",
504
+ description: "The IDs of the assets to list",
505
+ valueName: "value",
506
+ repeatable: true,
507
+ schema: z.array(z.string())
508
+ },
509
+ {
510
+ name: "for-asset-key",
511
+ queryName: "forAssetKey",
512
+ description: "The keys of the assets to list",
513
+ valueName: "value",
514
+ repeatable: true,
515
+ schema: z.array(z.string())
516
+ },
517
+ {
518
+ name: "for-customer",
519
+ queryName: "forCustomer",
520
+ description: "The IDs of the customers whose assets to list",
521
+ valueName: "value",
522
+ repeatable: true,
523
+ schema: z.array(z.string())
524
+ },
525
+ {
526
+ name: "for-item",
527
+ queryName: "forItem",
528
+ description: "The IDs of the items whose assets to list",
529
+ valueName: "value",
530
+ repeatable: true,
531
+ schema: z.array(z.string())
532
+ },
533
+ {
534
+ name: "at-site",
535
+ queryName: "atSite",
536
+ description: "The IDs of the locations whose assets to list",
537
+ valueName: "value",
538
+ repeatable: true,
539
+ schema: z.array(z.string())
540
+ },
541
+ {
542
+ name: "at-customer-sites",
543
+ queryName: "atCustomerSites",
544
+ description: "The IDs of the customers whose sites to list assets at",
545
+ valueName: "value",
546
+ repeatable: true,
547
+ schema: z.array(z.string())
548
+ },
549
+ {
550
+ name: "with-functional-statuses",
551
+ queryName: "withFunctionalStatuses",
552
+ description: "The functional statuses to list, where SCRAPPED lists scrapped assets",
553
+ valueName: "value",
554
+ repeatable: true,
555
+ schema: z.array(z.enum([
556
+ "FUNCTIONAL",
557
+ "NEEDS_REVIEW",
558
+ "NON-FUNCTIONAL",
559
+ "SCRAPPED"
560
+ ]))
561
+ },
562
+ {
563
+ name: "with-transit-statuses",
564
+ queryName: "withTransitStatuses",
565
+ description: "The transit statuses to list",
566
+ valueName: "value",
567
+ repeatable: true,
568
+ schema: z.array(z.enum([
569
+ "IN_TRANSIT",
570
+ "IN_TRANSIT_TO_FIELD",
571
+ "IN_TRANSIT_TO_INVENTORY",
572
+ "NOT_IN_TRANSIT"
573
+ ]))
574
+ },
575
+ {
576
+ name: "with-inventory-statuses",
577
+ queryName: "withInventoryStatuses",
578
+ description: "In_inventory, not_in_inventory, or both, which filters only when one is sent",
579
+ valueName: "value",
580
+ repeatable: true,
581
+ schema: z.array(z.string())
582
+ },
583
+ {
584
+ name: "with-location-company-type",
585
+ queryName: "withLocationCompanyType",
586
+ description: "Customer, manufacturer, or both, for assets at customer sites or your own, which filters only when one is sent",
587
+ valueName: "value",
588
+ repeatable: true,
589
+ schema: z.array(z.string())
590
+ },
591
+ {
592
+ name: "with-project-status",
593
+ queryName: "withProjectStatus",
594
+ description: "The project assignments to list: upcoming, active, past, or none",
595
+ valueName: "value",
596
+ repeatable: true,
597
+ schema: z.array(z.string())
598
+ },
599
+ {
600
+ name: "with-owner",
601
+ queryName: "withOwner",
602
+ description: "Manufacturer for assets your organization owns, customer for assets customers own, or both",
603
+ valueName: "value",
604
+ repeatable: true,
605
+ schema: z.array(z.string())
606
+ },
607
+ {
608
+ name: "for-project",
609
+ queryName: "forProject",
610
+ description: "The IDs of the projects whose assets to list",
611
+ valueName: "value",
612
+ repeatable: true,
613
+ schema: z.array(z.string())
614
+ },
615
+ {
616
+ name: "scrapped",
617
+ queryName: "scrapped",
618
+ description: "Whether to list unscrapped assets, scrapped assets, or all of them",
619
+ valueName: "value",
620
+ schema: z.enum([
621
+ "all",
622
+ "false",
623
+ "true"
624
+ ])
625
+ }
626
+ ],
627
+ takesBody: false
628
+ }),
629
+ defineOperation({
630
+ name: "create",
631
+ summary: "Create asset",
632
+ method: "POST",
633
+ path: "/asset",
634
+ pathParameters: [],
635
+ queryFlags: [],
636
+ takesBody: true
637
+ }),
638
+ defineOperation({
639
+ name: "get",
640
+ summary: "Get asset",
641
+ method: "GET",
642
+ path: "/asset/{assetKey}",
643
+ pathParameters: [{
644
+ name: "assetKey",
645
+ description: "The asset's key",
646
+ required: true
647
+ }],
648
+ queryFlags: [],
649
+ takesBody: false
650
+ }),
651
+ defineOperation({
652
+ name: "update",
653
+ summary: "Patch asset",
654
+ method: "PATCH",
655
+ path: "/asset/{assetKey}",
656
+ pathParameters: [{
657
+ name: "assetKey",
658
+ description: "The asset's key",
659
+ required: true
660
+ }],
661
+ queryFlags: [],
662
+ takesBody: true
663
+ }),
664
+ {
665
+ name: "accounting",
666
+ summary: "Accounting commands",
667
+ arguments: [],
668
+ flags: [],
669
+ examples: [],
670
+ subcommands: [defineOperation({
671
+ name: "update",
672
+ summary: "Update asset accounting",
673
+ method: "PATCH",
674
+ path: "/asset/{assetKey}/accounting",
675
+ pathParameters: [{
676
+ name: "assetKey",
677
+ description: "The asset's key",
678
+ required: true
679
+ }],
680
+ queryFlags: [],
681
+ takesBody: true
682
+ }), {
683
+ name: "in-service-management",
684
+ summary: "In service management commands",
685
+ arguments: [],
686
+ flags: [],
687
+ examples: [],
688
+ subcommands: [defineOperation({
689
+ name: "update",
690
+ summary: "Toggle in service date management",
691
+ method: "PATCH",
692
+ path: "/asset/{assetKey}/accounting/in-service-management",
693
+ pathParameters: [{
694
+ name: "assetKey",
695
+ description: "The asset's key",
696
+ required: true
697
+ }],
698
+ queryFlags: [],
699
+ takesBody: true
700
+ })]
701
+ }]
702
+ },
703
+ {
704
+ name: "cost-adjustment",
705
+ summary: "Cost adjustment commands",
706
+ arguments: [],
707
+ flags: [],
708
+ examples: [],
709
+ subcommands: [defineOperation({
710
+ name: "create",
711
+ summary: "Create asset cost adjustment",
712
+ method: "POST",
713
+ path: "/asset/{assetKey}/cost-adjustment",
714
+ pathParameters: [{
715
+ name: "assetKey",
716
+ description: "The asset's key",
717
+ required: true
718
+ }],
719
+ queryFlags: [],
720
+ takesBody: true
721
+ })]
722
+ },
723
+ {
724
+ name: "file",
725
+ summary: "File commands",
726
+ arguments: [],
727
+ flags: [],
728
+ examples: [],
729
+ subcommands: [defineOperation({
730
+ name: "list",
731
+ summary: "Get asset files",
732
+ method: "GET",
733
+ path: "/asset/{assetKey}/file",
734
+ pathParameters: [{
735
+ name: "assetKey",
736
+ description: "The asset's key",
737
+ required: true
738
+ }],
739
+ queryFlags: [],
740
+ takesBody: false
741
+ }), defineOperation({
742
+ name: "delete",
743
+ summary: "Delete asset file",
744
+ method: "DELETE",
745
+ path: "/asset/{assetKey}/file/{fileKey}",
746
+ pathParameters: [{
747
+ name: "assetKey",
748
+ description: "The asset's key",
749
+ required: true
750
+ }, {
751
+ name: "fileKey",
752
+ description: "The file's key",
753
+ required: true
754
+ }],
755
+ queryFlags: [],
756
+ takesBody: false
757
+ })]
758
+ },
759
+ {
760
+ name: "ownership",
761
+ summary: "Ownership commands",
762
+ arguments: [],
763
+ flags: [],
764
+ examples: [],
765
+ subcommands: [
766
+ defineOperation({
767
+ name: "list",
768
+ summary: "Get asset ownership history",
769
+ method: "GET",
770
+ path: "/asset/{assetKey}/ownership",
771
+ pathParameters: [{
772
+ name: "assetKey",
773
+ description: "The asset's key",
774
+ required: true
775
+ }],
776
+ queryFlags: [],
777
+ takesBody: false
778
+ }),
779
+ defineOperation({
780
+ name: "create",
781
+ summary: "Create asset ownership",
782
+ method: "POST",
783
+ path: "/asset/{assetKey}/ownership",
784
+ pathParameters: [{
785
+ name: "assetKey",
786
+ description: "The asset's key",
787
+ required: true
788
+ }],
789
+ queryFlags: [],
790
+ takesBody: true
791
+ }),
792
+ defineOperation({
793
+ name: "clear",
794
+ summary: "Clear the ownership an asset holds today",
795
+ method: "DELETE",
796
+ path: "/asset/{assetKey}/ownership",
797
+ pathParameters: [{
798
+ name: "assetKey",
799
+ description: "The asset's key",
800
+ required: true
801
+ }],
802
+ queryFlags: [],
803
+ takesBody: true
804
+ }),
805
+ defineOperation({
806
+ name: "get",
807
+ summary: "Get asset ownership segment",
808
+ method: "GET",
809
+ path: "/asset/{assetKey}/ownership/{segmentKey}",
810
+ pathParameters: [{
811
+ name: "assetKey",
812
+ description: "The asset's key",
813
+ required: true
814
+ }, {
815
+ name: "segmentKey",
816
+ description: "The ownership segment's key",
817
+ required: true
818
+ }],
819
+ queryFlags: [],
820
+ takesBody: false
821
+ }),
822
+ defineOperation({
823
+ name: "update",
824
+ summary: "Patch asset ownership segment",
825
+ method: "PATCH",
826
+ path: "/asset/{assetKey}/ownership/{segmentKey}",
827
+ pathParameters: [{
828
+ name: "assetKey",
829
+ description: "The asset's key",
830
+ required: true
831
+ }, {
832
+ name: "segmentKey",
833
+ description: "The ownership segment's key",
834
+ required: true
835
+ }],
836
+ queryFlags: [],
837
+ takesBody: true
838
+ }),
839
+ defineOperation({
840
+ name: "delete",
841
+ summary: "Delete asset ownership segment",
842
+ method: "DELETE",
843
+ path: "/asset/{assetKey}/ownership/{segmentKey}",
844
+ pathParameters: [{
845
+ name: "assetKey",
846
+ description: "The asset's key",
847
+ required: true
848
+ }, {
849
+ name: "segmentKey",
850
+ description: "The ownership segment's key",
851
+ required: true
852
+ }],
853
+ queryFlags: [],
854
+ takesBody: false
855
+ })
856
+ ]
857
+ },
858
+ {
859
+ name: "url-link",
860
+ summary: "URL link commands",
861
+ arguments: [],
862
+ flags: [],
863
+ examples: [],
864
+ subcommands: [defineOperation({
865
+ name: "list",
866
+ summary: "Get asset URL links",
867
+ method: "GET",
868
+ path: "/asset/{assetKey}/url-link",
869
+ pathParameters: [{
870
+ name: "assetKey",
871
+ description: "The asset's key",
872
+ required: true
873
+ }],
874
+ queryFlags: [],
875
+ takesBody: false
876
+ }), defineOperation({
877
+ name: "create",
878
+ summary: "Create asset URL link",
879
+ method: "POST",
880
+ path: "/asset/{assetKey}/url-link",
881
+ pathParameters: [{
882
+ name: "assetKey",
883
+ description: "The asset's key",
884
+ required: true
885
+ }],
886
+ queryFlags: [],
887
+ takesBody: true
888
+ })]
889
+ },
890
+ {
891
+ name: "useful-life-revision",
892
+ summary: "Useful life revision commands",
893
+ arguments: [],
894
+ flags: [],
895
+ examples: [],
896
+ subcommands: [defineOperation({
897
+ name: "create",
898
+ summary: "Create asset useful life revision",
899
+ method: "POST",
900
+ path: "/asset/{assetKey}/useful-life-revision",
901
+ pathParameters: [{
902
+ name: "assetKey",
903
+ description: "The asset's key",
904
+ required: true
905
+ }],
906
+ queryFlags: [],
907
+ takesBody: true
908
+ })]
909
+ }
910
+ ]
911
+ },
912
+ {
913
+ name: "customer",
914
+ summary: "Customer commands",
915
+ arguments: [],
916
+ flags: [],
917
+ examples: [],
918
+ subcommands: [
919
+ defineOperation({
920
+ name: "list",
921
+ summary: "Get customers",
922
+ method: "GET",
923
+ path: "/customer",
924
+ pathParameters: [],
925
+ queryFlags: [
926
+ {
927
+ name: "page",
928
+ queryName: "page",
929
+ description: "The page to return, starting at 1",
930
+ valueName: "number",
931
+ schema: z.coerce.number()
932
+ },
933
+ {
934
+ name: "limit",
935
+ queryName: "limit",
936
+ description: "The number of records per page, from 1 to 100",
937
+ valueName: "number",
938
+ schema: z.coerce.number()
939
+ },
940
+ {
941
+ name: "sort-by",
942
+ queryName: "sortBy",
943
+ description: "The field to sort by",
944
+ valueName: "value",
945
+ schema: z.string()
946
+ },
947
+ {
948
+ name: "sort-order",
949
+ queryName: "sortOrder",
950
+ description: "The sort direction",
951
+ valueName: "value",
952
+ schema: z.enum(["ASC", "DESC"])
953
+ },
954
+ {
955
+ name: "archived",
956
+ queryName: "archived",
957
+ description: "Whether to return unarchived records, archived records, or all of them",
958
+ valueName: "value",
959
+ schema: z.enum([
960
+ "all",
961
+ "false",
962
+ "true"
963
+ ])
964
+ },
965
+ {
966
+ name: "is-customer",
967
+ queryName: "isCustomer",
968
+ description: "True to return only customers, or false to return only non-customers",
969
+ schema: z.boolean()
970
+ },
971
+ {
972
+ name: "is-supplier",
973
+ queryName: "isSupplier",
974
+ description: "True to return only suppliers, or false to return only non-suppliers",
975
+ schema: z.boolean()
976
+ },
977
+ {
978
+ name: "sync-statuses",
979
+ queryName: "syncStatuses",
980
+ description: "The CRM sync statuses to return: in-sync, out-of-sync, or not-linked",
981
+ valueName: "value",
982
+ repeatable: true,
983
+ schema: z.array(z.string())
984
+ },
985
+ {
986
+ name: "search",
987
+ queryName: "search",
988
+ description: "Text to match against customer names",
989
+ valueName: "value",
990
+ schema: z.string()
991
+ }
992
+ ],
993
+ takesBody: false
994
+ }),
995
+ defineOperation({
996
+ name: "create",
997
+ summary: "Create customer",
998
+ method: "POST",
999
+ path: "/customer",
1000
+ pathParameters: [],
1001
+ queryFlags: [],
1002
+ takesBody: true
1003
+ }),
1004
+ defineOperation({
1005
+ name: "get",
1006
+ summary: "Get customer",
1007
+ method: "GET",
1008
+ path: "/customer/{customerKey}",
1009
+ pathParameters: [{
1010
+ name: "customerKey",
1011
+ description: "The customer's key",
1012
+ required: true
1013
+ }],
1014
+ queryFlags: [],
1015
+ takesBody: false
1016
+ }),
1017
+ defineOperation({
1018
+ name: "update",
1019
+ summary: "Patch customer",
1020
+ method: "PATCH",
1021
+ path: "/customer/{customerKey}",
1022
+ pathParameters: [{
1023
+ name: "customerKey",
1024
+ description: "The customer's key",
1025
+ required: true
1026
+ }],
1027
+ queryFlags: [],
1028
+ takesBody: true
1029
+ })
1030
+ ]
1031
+ },
1032
+ {
1033
+ name: "file",
1034
+ summary: "File commands",
1035
+ arguments: [],
1036
+ flags: [],
1037
+ examples: [],
1038
+ subcommands: [defineOperation({
1039
+ name: "create",
1040
+ summary: "Upload file",
1041
+ method: "POST",
1042
+ path: "/file",
1043
+ pathParameters: [],
1044
+ queryFlags: [],
1045
+ takesBody: true
1046
+ }), defineOperation({
1047
+ name: "get",
1048
+ summary: "Get file",
1049
+ method: "GET",
1050
+ path: "/file/{fileKey}",
1051
+ pathParameters: [{
1052
+ name: "fileKey",
1053
+ description: "The file's key, and a public file is readable with any organization's API key while any other file is readable only with its own organization's",
1054
+ required: true
1055
+ }],
1056
+ queryFlags: [{
1057
+ name: "attachment",
1058
+ queryName: "attachment",
1059
+ description: "Present, with any value or none, when the file should download as an attachment rather than open inline",
1060
+ valueName: "value",
1061
+ schema: z.string()
1062
+ }],
1063
+ takesBody: false
1064
+ })]
1065
+ },
1066
+ {
1067
+ name: "item",
1068
+ summary: "Item commands",
1069
+ arguments: [],
1070
+ flags: [],
1071
+ examples: [],
1072
+ subcommands: [
1073
+ defineOperation({
1074
+ name: "list",
1075
+ summary: "Get items",
1076
+ method: "GET",
1077
+ path: "/item",
1078
+ pathParameters: [],
1079
+ queryFlags: [
1080
+ {
1081
+ name: "type",
1082
+ queryName: "type",
1083
+ description: "The item type to list, SERVICE, DEVICE, or BULK, or absent for every type",
1084
+ valueName: "value",
1085
+ schema: z.enum([
1086
+ "BULK",
1087
+ "DEVICE",
1088
+ "SERVICE"
1089
+ ])
1090
+ },
1091
+ {
1092
+ name: "search",
1093
+ queryName: "search",
1094
+ description: "Text to match against item names, SKUs, and descriptions, in any case",
1095
+ valueName: "value",
1096
+ schema: z.string()
1097
+ },
1098
+ {
1099
+ name: "page",
1100
+ queryName: "page",
1101
+ description: "The page to return, starting at 1",
1102
+ valueName: "number",
1103
+ schema: z.coerce.number()
1104
+ },
1105
+ {
1106
+ name: "limit",
1107
+ queryName: "limit",
1108
+ description: "The number of records per page, from 1 to 100",
1109
+ valueName: "number",
1110
+ schema: z.coerce.number()
1111
+ },
1112
+ {
1113
+ name: "sort-by",
1114
+ queryName: "sortBy",
1115
+ description: "The field to sort by",
1116
+ valueName: "value",
1117
+ schema: z.enum([
1118
+ "name",
1119
+ "sku",
1120
+ "type",
1121
+ "lastUpdatedAt"
1122
+ ])
1123
+ },
1124
+ {
1125
+ name: "sort-order",
1126
+ queryName: "sortOrder",
1127
+ description: "The sort direction",
1128
+ valueName: "value",
1129
+ schema: z.enum(["ASC", "DESC"])
1130
+ },
1131
+ {
1132
+ name: "archived",
1133
+ queryName: "archived",
1134
+ description: "Whether to return unarchived records, archived records, or all of them",
1135
+ valueName: "value",
1136
+ schema: z.enum([
1137
+ "all",
1138
+ "false",
1139
+ "true"
1140
+ ])
1141
+ },
1142
+ {
1143
+ name: "exclude-linked-integration",
1144
+ queryName: "excludeLinkedIntegration",
1145
+ description: "An integration whose already-linked items to leave out",
1146
+ valueName: "value",
1147
+ schema: z.string()
1148
+ }
1149
+ ],
1150
+ takesBody: false
1151
+ }),
1152
+ defineOperation({
1153
+ name: "create",
1154
+ summary: "Create item",
1155
+ method: "POST",
1156
+ path: "/item",
1157
+ pathParameters: [],
1158
+ queryFlags: [],
1159
+ takesBody: true
1160
+ }),
1161
+ defineOperation({
1162
+ name: "get",
1163
+ summary: "Get item",
1164
+ method: "GET",
1165
+ path: "/item/{itemKey}",
1166
+ pathParameters: [{
1167
+ name: "itemKey",
1168
+ description: "The item's key",
1169
+ required: true
1170
+ }],
1171
+ queryFlags: [],
1172
+ takesBody: false
1173
+ }),
1174
+ defineOperation({
1175
+ name: "update",
1176
+ summary: "Update item",
1177
+ method: "PATCH",
1178
+ path: "/item/{itemKey}",
1179
+ pathParameters: [{
1180
+ name: "itemKey",
1181
+ description: "The item's key",
1182
+ required: true
1183
+ }],
1184
+ queryFlags: [],
1185
+ takesBody: true
1186
+ }),
1187
+ {
1188
+ name: "accounting",
1189
+ summary: "Accounting commands",
1190
+ arguments: [],
1191
+ flags: [],
1192
+ examples: [],
1193
+ subcommands: [defineOperation({
1194
+ name: "update",
1195
+ summary: "Update item accounting",
1196
+ method: "PATCH",
1197
+ path: "/item/{itemKey}/accounting",
1198
+ pathParameters: [{
1199
+ name: "itemKey",
1200
+ description: "The item's key",
1201
+ required: true
1202
+ }],
1203
+ queryFlags: [],
1204
+ takesBody: true
1205
+ })]
1206
+ },
1207
+ {
1208
+ name: "field",
1209
+ summary: "Field commands",
1210
+ arguments: [],
1211
+ flags: [],
1212
+ examples: [],
1213
+ subcommands: [
1214
+ defineOperation({
1215
+ name: "create",
1216
+ summary: "Create item field",
1217
+ method: "POST",
1218
+ path: "/item/{itemKey}/field",
1219
+ pathParameters: [{
1220
+ name: "itemKey",
1221
+ description: "The item's key",
1222
+ required: true
1223
+ }],
1224
+ queryFlags: [],
1225
+ takesBody: true
1226
+ }),
1227
+ defineOperation({
1228
+ name: "update",
1229
+ summary: "Update item field",
1230
+ method: "PATCH",
1231
+ path: "/item/{itemKey}/field/{fieldKey}",
1232
+ pathParameters: [{
1233
+ name: "itemKey",
1234
+ description: "The item's key",
1235
+ required: true
1236
+ }, {
1237
+ name: "fieldKey",
1238
+ description: "The field's key",
1239
+ required: true
1240
+ }],
1241
+ queryFlags: [],
1242
+ takesBody: true
1243
+ }),
1244
+ defineOperation({
1245
+ name: "delete",
1246
+ summary: "Delete item field",
1247
+ method: "DELETE",
1248
+ path: "/item/{itemKey}/field/{fieldKey}",
1249
+ pathParameters: [{
1250
+ name: "itemKey",
1251
+ description: "The item's key",
1252
+ required: true
1253
+ }, {
1254
+ name: "fieldKey",
1255
+ description: "The field's key",
1256
+ required: true
1257
+ }],
1258
+ queryFlags: [],
1259
+ takesBody: false
1260
+ })
1261
+ ]
1262
+ }
1263
+ ]
1264
+ },
1265
+ {
1266
+ name: "location",
1267
+ summary: "Location commands",
1268
+ arguments: [],
1269
+ flags: [],
1270
+ examples: [],
1271
+ subcommands: [
1272
+ defineOperation({
1273
+ name: "list",
1274
+ summary: "Get location listing",
1275
+ method: "GET",
1276
+ path: "/location",
1277
+ pathParameters: [],
1278
+ queryFlags: [
1279
+ {
1280
+ name: "page",
1281
+ queryName: "page",
1282
+ description: "The page to return, starting at 1",
1283
+ valueName: "number",
1284
+ schema: z.coerce.number()
1285
+ },
1286
+ {
1287
+ name: "limit",
1288
+ queryName: "limit",
1289
+ description: "The number of records per page, from 1 to 100",
1290
+ valueName: "number",
1291
+ schema: z.coerce.number()
1292
+ },
1293
+ {
1294
+ name: "sort-by",
1295
+ queryName: "sortBy",
1296
+ description: "The field to sort by",
1297
+ valueName: "value",
1298
+ schema: z.enum([
1299
+ "name",
1300
+ "company",
1301
+ "assetCount"
1302
+ ])
1303
+ },
1304
+ {
1305
+ name: "sort-order",
1306
+ queryName: "sortOrder",
1307
+ description: "The sort direction",
1308
+ valueName: "value",
1309
+ schema: z.enum(["ASC", "DESC"])
1310
+ },
1311
+ {
1312
+ name: "archived",
1313
+ queryName: "archived",
1314
+ description: "Whether to return unarchived records, archived records, or all of them",
1315
+ valueName: "value",
1316
+ schema: z.enum([
1317
+ "all",
1318
+ "false",
1319
+ "true"
1320
+ ])
1321
+ },
1322
+ {
1323
+ name: "is-transient",
1324
+ queryName: "isTransient",
1325
+ description: "Whether to return permanent locations (false), transient locations (true), or both (all)",
1326
+ valueName: "value",
1327
+ schema: z.enum([
1328
+ "all",
1329
+ "false",
1330
+ "true"
1331
+ ])
1332
+ },
1333
+ {
1334
+ name: "search",
1335
+ queryName: "search",
1336
+ description: "Text to match against location and customer names",
1337
+ valueName: "value",
1338
+ schema: z.string()
1339
+ },
1340
+ {
1341
+ name: "for-customer-ids",
1342
+ queryName: "forCustomerIds",
1343
+ description: "The IDs of the customers whose locations to return",
1344
+ valueName: "value",
1345
+ repeatable: true,
1346
+ schema: z.array(z.string())
1347
+ },
1348
+ {
1349
+ name: "include-organization",
1350
+ queryName: "includeOrganization",
1351
+ description: "Whether the customer filter also matches your organization's own locations, which on its own returns only those",
1352
+ schema: z.boolean()
1353
+ },
1354
+ {
1355
+ name: "sync-statuses",
1356
+ queryName: "syncStatuses",
1357
+ description: "The CRM sync statuses to return: in-sync, out-of-sync, or not-linked",
1358
+ valueName: "value",
1359
+ repeatable: true,
1360
+ schema: z.array(z.string())
1361
+ }
1362
+ ],
1363
+ takesBody: false
1364
+ }),
1365
+ defineOperation({
1366
+ name: "create",
1367
+ summary: "Create location",
1368
+ method: "POST",
1369
+ path: "/location",
1370
+ pathParameters: [],
1371
+ queryFlags: [],
1372
+ takesBody: true
1373
+ }),
1374
+ defineOperation({
1375
+ name: "get",
1376
+ summary: "Get location",
1377
+ method: "GET",
1378
+ path: "/location/{locationKey}",
1379
+ pathParameters: [{
1380
+ name: "locationKey",
1381
+ description: "The location's key",
1382
+ required: true
1383
+ }],
1384
+ queryFlags: [],
1385
+ takesBody: false
1386
+ }),
1387
+ defineOperation({
1388
+ name: "update",
1389
+ summary: "Patch location",
1390
+ method: "PATCH",
1391
+ path: "/location/{locationKey}",
1392
+ pathParameters: [{
1393
+ name: "locationKey",
1394
+ description: "The location's key",
1395
+ required: true
1396
+ }],
1397
+ queryFlags: [],
1398
+ takesBody: true
1399
+ }),
1400
+ {
1401
+ name: "zones",
1402
+ summary: "Zones commands",
1403
+ arguments: [],
1404
+ flags: [],
1405
+ examples: [],
1406
+ subcommands: [defineOperation({
1407
+ name: "list",
1408
+ summary: "Get zones",
1409
+ method: "GET",
1410
+ path: "/location/{locationKey}/zones",
1411
+ pathParameters: [{
1412
+ name: "locationKey",
1413
+ description: "The key of the site whose zones to list",
1414
+ required: true
1415
+ }],
1416
+ queryFlags: [{
1417
+ name: "archived",
1418
+ queryName: "archived",
1419
+ description: "Whether to return unarchived zones, archived zones, or all of them",
1420
+ valueName: "value",
1421
+ schema: z.enum([
1422
+ "all",
1423
+ "false",
1424
+ "true"
1425
+ ])
1426
+ }],
1427
+ takesBody: false
1428
+ })]
1429
+ }
1430
+ ]
1431
+ },
1432
+ {
1433
+ name: "url-link",
1434
+ summary: "URL link commands",
1435
+ arguments: [],
1436
+ flags: [],
1437
+ examples: [],
1438
+ subcommands: [
1439
+ defineOperation({
1440
+ name: "get",
1441
+ summary: "Get URL link by key",
1442
+ method: "GET",
1443
+ path: "/url-link/{linkKey}",
1444
+ pathParameters: [{
1445
+ name: "linkKey",
1446
+ description: "The URL link's key",
1447
+ required: true
1448
+ }],
1449
+ queryFlags: [],
1450
+ takesBody: false
1451
+ }),
1452
+ defineOperation({
1453
+ name: "update",
1454
+ summary: "Update URL link",
1455
+ method: "PATCH",
1456
+ path: "/url-link/{linkKey}",
1457
+ pathParameters: [{
1458
+ name: "linkKey",
1459
+ description: "The URL link's key",
1460
+ required: true
1461
+ }],
1462
+ queryFlags: [],
1463
+ takesBody: true
1464
+ }),
1465
+ defineOperation({
1466
+ name: "delete",
1467
+ summary: "Delete URL link",
1468
+ method: "DELETE",
1469
+ path: "/url-link/{linkKey}",
1470
+ pathParameters: [{
1471
+ name: "linkKey",
1472
+ description: "The URL link's key",
1473
+ required: true
1474
+ }],
1475
+ queryFlags: [],
1476
+ takesBody: false
1477
+ })
1478
+ ]
1479
+ }
1480
+ ],
1481
+ apiCommand,
1482
+ agentGuideCommand
1483
+ ];
1484
+ //#endregion
1485
+ //#region src/command/validate.ts
1486
+ /** toRejectedFlag names the first flag whose value its schema refuses. */
1487
+ function toRejectedFlag(command, flags) {
1488
+ for (const flag of command.flags) {
1489
+ const value = flags[toOptionKey(flag.name)];
1490
+ if (value === void 0) continue;
1491
+ if (!flag.schema.safeParse(value).success) return `--${flag.name} does not accept ${JSON.stringify(value)}`;
1492
+ }
1493
+ }
339
1494
  //#endregion
340
1495
  //#region src/cli.ts
341
1496
  const program = new Command();
@@ -358,6 +1513,8 @@ function toProgram(command) {
358
1513
  program.addOption(option);
359
1514
  }
360
1515
  for (const example of command.examples) program.addHelpText("after", `\n${example.description}:\n $ ${example.command}`);
1516
+ for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(subcommand));
1517
+ if (!command.run) return program;
361
1518
  program.action(async (...parsed) => {
362
1519
  const flags = parsed[parsed.length - 2] ?? {};
363
1520
  const args = parsed.slice(0, parsed.length - 2).flatMap(toArgumentList);
@@ -367,13 +1524,18 @@ function toProgram(command) {
367
1524
  }
368
1525
  async function toExitCode(command, args, flags) {
369
1526
  const isJSON = isJSONOutput(flags);
1527
+ const rejected = toRejectedFlag(command, flags);
1528
+ if (rejected) {
1529
+ writeFailure(rejected, isJSON);
1530
+ return ExitCode.USAGE;
1531
+ }
370
1532
  try {
371
- return await command.run({
1533
+ return await command.run?.({
372
1534
  args,
373
1535
  flags,
374
1536
  isJSON,
375
1537
  commands
376
- });
1538
+ }) ?? ExitCode.OK;
377
1539
  } catch (error) {
378
1540
  writeFailure(error instanceof Error ? error.message : String(error), isJSON);
379
1541
  return ExitCode.ERROR;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.0.2-dev.5",
3
+ "version": "0.0.2-dev.6",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsdown",
36
+ "generate-surface": "node scripts/generate-surface.mjs",
36
37
  "check-types": "tsc --noEmit",
37
38
  "test": "vitest run",
38
39
  "test:watch": "vitest",
@@ -47,6 +48,7 @@
47
48
  "tsdown": "^0.23.0",
48
49
  "typescript": "^5.9.0",
49
50
  "unrun": "^0.3.1",
50
- "vitest": "^5.0.1"
51
+ "vitest": "^5.0.1",
52
+ "yaml": "^2.9.1"
51
53
  }
52
54
  }