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

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 +68 -8
  2. package/dist/cli.js +315 -17
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,6 +31,7 @@ Most commands are generated from the published API document rather than written
31
31
  | `hardfin api` | A call to any endpoint, written by hand, and the escape hatch when no generated command fits |
32
32
  | `src/command/surface.generated.ts` | Every endpoint as a command, rewritten by the generator |
33
33
  | `surface-overrides.json` | The operations whose generated name is wrong |
34
+ | `spec/core.openapi.yaml` | The API document the generator reads, vendored here |
34
35
  | `scripts/generate-surface.mjs` | The generator |
35
36
 
36
37
  ### How an endpoint becomes a command
@@ -70,20 +71,79 @@ Settle it in `surface-overrides.json`, keyed by `operationId`:
70
71
  An override for an `operationId` the document no longer publishes fails the generator. That
71
72
  is deliberate, because a silently dropped override renames a command nobody meant to rename.
72
73
 
73
- ### Regenerating
74
+ ### The vendored document
74
75
 
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.
76
+ `spec/core.openapi.yaml` is the API document this repository carries, and the generator
77
+ reads it. Nothing fetches a document during a build, a release, or CI.
78
78
 
79
- Run it by hand against a local document:
79
+ Refresh it with the script, which then rewrites the generated commands:
80
80
 
81
81
  ```sh
82
- npm run generate-surface -- ../api-spec/reference/core.openapi.yaml
82
+ scripts/update-spec.sh # reads hardfinhq/api-spec over your own git access
83
+ scripts/update-spec.sh ../hardfin # bundles the fragmented source in a monorepo checkout
83
84
  ```
84
85
 
85
- The document is bundled, meaning its external files are inlined, but `$ref` pointers within
86
- it remain. The generator follows those pointers itself.
86
+ The monorepo form runs the same bundler at the same version CI uses, so the result matches
87
+ what the api-spec bridge publishes. Commit the document and the generated commands together.
88
+
89
+ Two checks keep the pair honest.
90
+
91
+ | Check | Refuses |
92
+ | --- | --- |
93
+ | The generator | a document whose `info.version` is not a date, which means an earlier release |
94
+ | CI | a vendored document that was updated without regenerating the commands |
95
+
96
+ The first one matters because `hardfinhq/api-spec` can sit a release behind the monorepo
97
+ while its bridge pull request is open. Generating from that document would replace the
98
+ current commands with an earlier API's.
99
+
100
+ ## Local configuration
101
+
102
+ A local build reaches a local server without editing code. Four layers supply the same
103
+ settings, and the one nearest the top wins.
104
+
105
+ | Layer | Where | Beats |
106
+ | --- | --- | --- |
107
+ | Flag | `--api-url` | everything below |
108
+ | Environment | an exported `HARDFIN_*` variable | the files below |
109
+ | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
110
+ | Config file | `config.local.json` in the working directory | the defaults |
111
+ | Default | the published API | nothing |
112
+
113
+ An exported variable beats `.env` because Node leaves a variable that is already set alone.
114
+
115
+ ### What a local build writes
116
+
117
+ ```json
118
+ {
119
+ "apiUrl": "http://localhost:8080/v2",
120
+ "auth": { "tokenUrl": "http://localhost:9000/oauth/token" }
121
+ }
122
+ ```
123
+
124
+ The authentication endpoints follow `apiUrl`, so pointing at a local server moves the whole
125
+ flow. Name one under `auth` to move only that one. The keys are `apiUrl`, `apiKey`,
126
+ `clientId`, and `auth` holding `authorizeUrl`, `tokenUrl`, `deviceUrl`, and `revokeUrl`.
127
+
128
+ A key the file does not define fails the command with exit code 2. A typo that was silently
129
+ ignored would look like a setting that never applied.
130
+
131
+ `config.local.json` and `.env` are both gitignored.
132
+
133
+ ### Seeing what won
134
+
135
+ ```sh
136
+ hardfin config
137
+ ```
138
+
139
+ It prints each setting, its value, and the layer that supplied it. The API key is reported
140
+ as set or not set, never printed.
141
+
142
+ ### Both files are read from the working directory
143
+
144
+ The CLI reads whatever `config.local.json` and `.env` sit in the directory you run it from.
145
+ A directory you do not control can therefore point the CLI at a server you do not expect, so
146
+ run `hardfin config` when a command reaches somewhere surprising.
87
147
 
88
148
  ## Releasing
89
149
 
package/dist/cli.js CHANGED
@@ -2,7 +2,8 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { Command, Option } from "commander";
4
4
  import { z } from "zod";
5
- import { readFileSync } from "node:fs";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
6
7
  //#region src/command/registry.ts
7
8
  /** ExitCode is what the process returns, and what an agent branches on. */
8
9
  const ExitCode = {
@@ -19,14 +20,93 @@ function defineCommand(command) {
19
20
  //#region src/config/settings.ts
20
21
  /** The API version this build was written against, sent on every request. */
21
22
  const API_VERSION = "2026-09-17";
23
+ /** The file a local build reads its overrides from, in the working directory. */
24
+ const CONFIG_FILE = "config.local.json";
22
25
  const DEFAULT_API_URL = "https://api.hardfin.com/v2";
23
- /** toApiUrl picks the API this invocation talks to. */
24
- function toApiUrl(override) {
25
- return (override ?? process.env["HARDFIN_API_URL"] ?? DEFAULT_API_URL).replace(/\/+$/, "");
26
+ const DEFAULT_ENV_FILE = ".env";
27
+ const FileSettings = z.strictObject({
28
+ apiUrl: z.string().optional(),
29
+ apiKey: z.string().optional(),
30
+ clientId: z.string().optional(),
31
+ auth: z.strictObject({
32
+ authorizeUrl: z.string().optional(),
33
+ tokenUrl: z.string().optional(),
34
+ deviceUrl: z.string().optional(),
35
+ revokeUrl: z.string().optional()
36
+ }).optional()
37
+ });
38
+ /** ConfigFailure is a config file that cannot be read or does not match the schema. */
39
+ var ConfigFailure = class extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = "ConfigFailure";
43
+ }
44
+ };
45
+ /** toSettings resolves what this invocation talks to, and where each value came from. */
46
+ function toSettings(flags = {}, directory = process.cwd()) {
47
+ const fromEnvFile = loadEnvFile(directory);
48
+ const file = toFileSettings(directory);
49
+ const sources = {};
50
+ const pick = (key, flag, variable, fromFile, fallback) => {
51
+ const [value, source] = toLayer(flag, process.env[variable], fromEnvFile.has(variable), fromFile, fallback);
52
+ sources[key] = source;
53
+ return value;
54
+ };
55
+ const apiUrl = toTrimmedUrl(pick("apiUrl", flags.apiUrl, "HARDFIN_API_URL", file.apiUrl, DEFAULT_API_URL) ?? DEFAULT_API_URL);
56
+ return {
57
+ settings: {
58
+ apiUrl,
59
+ apiKey: pick("apiKey", flags.apiKey, "HARDFIN_API_KEY", file.apiKey),
60
+ clientId: pick("clientId", flags.clientId, "HARDFIN_CLIENT_ID", file.clientId),
61
+ authorizeUrl: pick("authorizeUrl", flags.authorizeUrl, "HARDFIN_AUTHORIZE_URL", file.auth?.authorizeUrl, `${apiUrl}/auth/authorize`) ?? "",
62
+ tokenUrl: pick("tokenUrl", flags.tokenUrl, "HARDFIN_TOKEN_URL", file.auth?.tokenUrl, `${apiUrl}/auth/token`) ?? "",
63
+ deviceUrl: pick("deviceUrl", flags.deviceUrl, "HARDFIN_DEVICE_URL", file.auth?.deviceUrl, `${apiUrl}/auth/device`) ?? "",
64
+ revokeUrl: pick("revokeUrl", flags.revokeUrl, "HARDFIN_REVOKE_URL", file.auth?.revokeUrl, `${apiUrl}/auth/revoke`) ?? ""
65
+ },
66
+ sources
67
+ };
68
+ }
69
+ function toLayer(flag, environment, isFromEnvFile, file, fallback) {
70
+ if (flag) return [flag, "flag"];
71
+ if (environment) return [environment, isFromEnvFile ? "env file" : "environment"];
72
+ if (file) return [file, "config file"];
73
+ return [fallback, "default"];
74
+ }
75
+ /** toFileSettings reads the local override file, which a local build is expected to have. */
76
+ function toFileSettings(directory) {
77
+ const path = resolve(directory, CONFIG_FILE);
78
+ if (!existsSync(path)) return {};
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(readFileSync(path, "utf8"));
82
+ } catch {
83
+ throw new ConfigFailure(`${CONFIG_FILE} does not hold JSON`);
84
+ }
85
+ const result = FileSettings.safeParse(parsed);
86
+ if (!result.success) {
87
+ const issue = result.error.issues[0];
88
+ throw new ConfigFailure(`${CONFIG_FILE} is not valid: ${issue?.path.join(".") || "root"} ${issue?.message ?? ""}`.trim());
89
+ }
90
+ return result.data;
91
+ }
92
+ /**
93
+ * loadEnvFile reads a .env beside the command, so a local build needs no exports, and
94
+ * answers which variables it supplied. Node leaves an exported variable alone, so a
95
+ * shell export still wins over the file.
96
+ */
97
+ function loadEnvFile(directory) {
98
+ const path = process.env["HARDFIN_ENV_FILE"] ?? resolve(directory, DEFAULT_ENV_FILE);
99
+ if (!existsSync(path)) return /* @__PURE__ */ new Set();
100
+ const before = new Set(Object.keys(process.env));
101
+ try {
102
+ process.loadEnvFile(path);
103
+ } catch {
104
+ throw new ConfigFailure(`${path} cannot be read as an env file`);
105
+ }
106
+ return new Set(Object.keys(process.env).filter((name) => !before.has(name)));
26
107
  }
27
- /** toApiKey reads the API key an unattended caller set. */
28
- function toApiKey() {
29
- return process.env["HARDFIN_API_KEY"] || void 0;
108
+ function toTrimmedUrl(url) {
109
+ return url.replace(/\/+$/, "");
30
110
  }
31
111
  //#endregion
32
112
  //#region src/output/writer.ts
@@ -272,7 +352,7 @@ const apiCommand = defineCommand({
272
352
  run: runApi
273
353
  });
274
354
  async function runApi(input) {
275
- const apiKey = toApiKey();
355
+ const apiKey = input.resolved.settings.apiKey;
276
356
  if (!apiKey) {
277
357
  writeFailure("not authenticated. Set HARDFIN_API_KEY to an API key for your organization", input.isJSON);
278
358
  return ExitCode.NOT_AUTHENTICATED;
@@ -297,7 +377,7 @@ async function runApi(input) {
297
377
  }
298
378
  try {
299
379
  writeData((await request({
300
- apiUrl: toApiUrl(),
380
+ apiUrl: input.resolved.settings.apiUrl,
301
381
  apiKey,
302
382
  method: String(input.flags["method"] ?? "GET").toUpperCase(),
303
383
  path,
@@ -333,6 +413,56 @@ function toBody$1(source) {
333
413
  }
334
414
  }
335
415
  //#endregion
416
+ //#region src/command/config.ts
417
+ const configCommand = defineCommand({
418
+ name: "config",
419
+ summary: "Print what this invocation talks to, and where each value came from",
420
+ description: `Resolves the API and authentication endpoints from the flags, the environment, a .env file, and ${CONFIG_FILE} in the working directory. Use it when a local build reaches the wrong server.`,
421
+ arguments: [],
422
+ flags: [{
423
+ name: "json",
424
+ description: "Print machine-readable output, which is the default when stdout is not a terminal",
425
+ schema: z.boolean()
426
+ }],
427
+ examples: [{
428
+ description: "See what a local build is pointed at",
429
+ command: "hardfin config"
430
+ }, {
431
+ description: "Read one value from a script",
432
+ command: "hardfin config --json | jq -r .apiUrl"
433
+ }],
434
+ run: runConfig
435
+ });
436
+ /** toReport pairs each setting with the layer that supplied it. */
437
+ function toReport(resolved) {
438
+ const report = { apiVersion: API_VERSION };
439
+ for (const [key, value] of Object.entries(resolved.settings)) {
440
+ const source = resolved.sources[key];
441
+ report[key] = key === "apiKey" ? {
442
+ set: value !== void 0,
443
+ from: source
444
+ } : {
445
+ value: value ?? null,
446
+ from: source
447
+ };
448
+ }
449
+ return report;
450
+ }
451
+ async function runConfig(input) {
452
+ const report = toReport(input.resolved);
453
+ if (input.isJSON) {
454
+ writeData(report);
455
+ return ExitCode.OK;
456
+ }
457
+ writeData(Object.entries(report).map(([key, entry]) => {
458
+ if (typeof entry !== "object" || entry === null) return `${key.padEnd(14)} ${String(entry)}`;
459
+ const holder = entry;
460
+ const shown = holder.value ?? (holder.set ? "set" : "not set");
461
+ return `${key.padEnd(14)} ${shown} (${holder.from})`;
462
+ }).join("\n"));
463
+ return ExitCode.OK;
464
+ }
465
+ //#endregion
336
466
  //#region src/command/operation.ts
337
467
  const INPUT_FLAG = {
338
468
  name: "input",
@@ -360,7 +490,7 @@ function defineOperation(operation) {
360
490
  };
361
491
  }
362
492
  async function runOperation(operation, input) {
363
- const apiKey = toApiKey();
493
+ const apiKey = input.resolved.settings.apiKey;
364
494
  if (!apiKey) {
365
495
  writeFailure("not authenticated. Set HARDFIN_API_KEY to an API key for your organization", input.isJSON);
366
496
  return ExitCode.NOT_AUTHENTICATED;
@@ -380,7 +510,7 @@ async function runOperation(operation, input) {
380
510
  }
381
511
  try {
382
512
  writeData((await request({
383
- apiUrl: toApiUrl(),
513
+ apiUrl: input.resolved.settings.apiUrl,
384
514
  apiKey,
385
515
  method: operation.method,
386
516
  path,
@@ -635,6 +765,44 @@ const commands = [
635
765
  queryFlags: [],
636
766
  takesBody: true
637
767
  }),
768
+ {
769
+ name: "move",
770
+ summary: "Move commands",
771
+ arguments: [],
772
+ flags: [],
773
+ examples: [],
774
+ subcommands: [{
775
+ name: "execute",
776
+ summary: "Execute commands",
777
+ arguments: [],
778
+ flags: [],
779
+ examples: [],
780
+ subcommands: [defineOperation({
781
+ name: "create",
782
+ summary: "Execute asset move",
783
+ method: "POST",
784
+ path: "/asset/move/execute",
785
+ pathParameters: [],
786
+ queryFlags: [],
787
+ takesBody: true
788
+ })]
789
+ }, {
790
+ name: "plan",
791
+ summary: "Plan commands",
792
+ arguments: [],
793
+ flags: [],
794
+ examples: [],
795
+ subcommands: [defineOperation({
796
+ name: "create",
797
+ summary: "Plan asset move",
798
+ method: "POST",
799
+ path: "/asset/move/plan",
800
+ pathParameters: [],
801
+ queryFlags: [],
802
+ takesBody: true
803
+ })]
804
+ }]
805
+ },
638
806
  defineOperation({
639
807
  name: "get",
640
808
  summary: "Get asset",
@@ -720,6 +888,74 @@ const commands = [
720
888
  takesBody: true
721
889
  })]
722
890
  },
891
+ {
892
+ name: "event",
893
+ summary: "Event commands",
894
+ arguments: [],
895
+ flags: [],
896
+ examples: [],
897
+ subcommands: [defineOperation({
898
+ name: "list",
899
+ summary: "Get asset event list",
900
+ method: "GET",
901
+ path: "/asset/{assetKey}/event",
902
+ pathParameters: [{
903
+ name: "assetKey",
904
+ description: "The asset's key",
905
+ required: true
906
+ }],
907
+ queryFlags: [],
908
+ takesBody: false
909
+ })]
910
+ },
911
+ {
912
+ name: "event-group",
913
+ summary: "Event group commands",
914
+ arguments: [],
915
+ flags: [],
916
+ examples: [],
917
+ subcommands: [defineOperation({
918
+ name: "list",
919
+ summary: "Get asset event group listing",
920
+ method: "GET",
921
+ path: "/asset/{assetKey}/event-group",
922
+ pathParameters: [{
923
+ name: "assetKey",
924
+ description: "The asset's key",
925
+ required: true
926
+ }],
927
+ queryFlags: [{
928
+ name: "start",
929
+ queryName: "start",
930
+ description: "The earliest an event group may have happened, or null to read from the first",
931
+ valueName: "value",
932
+ schema: z.string()
933
+ }, {
934
+ name: "end",
935
+ queryName: "end",
936
+ description: "The latest an event group may have happened, or null to read through the last",
937
+ valueName: "value",
938
+ schema: z.string()
939
+ }],
940
+ takesBody: false
941
+ }), defineOperation({
942
+ name: "get",
943
+ summary: "Get asset event group",
944
+ method: "GET",
945
+ path: "/asset/{assetKey}/event-group/{eventGroupKey}",
946
+ pathParameters: [{
947
+ name: "assetKey",
948
+ description: "The asset's key",
949
+ required: true
950
+ }, {
951
+ name: "eventGroupKey",
952
+ description: "The event group's key",
953
+ required: true
954
+ }],
955
+ queryFlags: [],
956
+ takesBody: false
957
+ })]
958
+ },
723
959
  {
724
960
  name: "file",
725
961
  summary: "File commands",
@@ -756,6 +992,26 @@ const commands = [
756
992
  takesBody: false
757
993
  })]
758
994
  },
995
+ {
996
+ name: "functional-status",
997
+ summary: "Functional status commands",
998
+ arguments: [],
999
+ flags: [],
1000
+ examples: [],
1001
+ subcommands: [defineOperation({
1002
+ name: "list",
1003
+ summary: "Get asset functional status history",
1004
+ method: "GET",
1005
+ path: "/asset/{assetKey}/functional-status",
1006
+ pathParameters: [{
1007
+ name: "assetKey",
1008
+ description: "The asset's key",
1009
+ required: true
1010
+ }],
1011
+ queryFlags: [],
1012
+ takesBody: false
1013
+ })]
1014
+ },
759
1015
  {
760
1016
  name: "ownership",
761
1017
  summary: "Ownership commands",
@@ -855,6 +1111,46 @@ const commands = [
855
1111
  })
856
1112
  ]
857
1113
  },
1114
+ {
1115
+ name: "scrap",
1116
+ summary: "Scrap commands",
1117
+ arguments: [],
1118
+ flags: [],
1119
+ examples: [],
1120
+ subcommands: [defineOperation({
1121
+ name: "create",
1122
+ summary: "Scrap asset",
1123
+ method: "POST",
1124
+ path: "/asset/{assetKey}/scrap",
1125
+ pathParameters: [{
1126
+ name: "assetKey",
1127
+ description: "The asset's key",
1128
+ required: true
1129
+ }],
1130
+ queryFlags: [],
1131
+ takesBody: true
1132
+ })]
1133
+ },
1134
+ {
1135
+ name: "unscrap",
1136
+ summary: "Unscrap commands",
1137
+ arguments: [],
1138
+ flags: [],
1139
+ examples: [],
1140
+ subcommands: [defineOperation({
1141
+ name: "create",
1142
+ summary: "Unscrap asset",
1143
+ method: "POST",
1144
+ path: "/asset/{assetKey}/unscrap",
1145
+ pathParameters: [{
1146
+ name: "assetKey",
1147
+ description: "The asset's key",
1148
+ required: true
1149
+ }],
1150
+ queryFlags: [],
1151
+ takesBody: false
1152
+ })]
1153
+ },
858
1154
  {
859
1155
  name: "url-link",
860
1156
  summary: "URL link commands",
@@ -1056,9 +1352,8 @@ const commands = [
1056
1352
  queryFlags: [{
1057
1353
  name: "attachment",
1058
1354
  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()
1355
+ description: "True when the file downloads as an attachment rather than opening inline",
1356
+ schema: z.boolean()
1062
1357
  }],
1063
1358
  takesBody: false
1064
1359
  })]
@@ -1479,6 +1774,7 @@ const commands = [
1479
1774
  }
1480
1775
  ],
1481
1776
  apiCommand,
1777
+ configCommand,
1482
1778
  agentGuideCommand
1483
1779
  ];
1484
1780
  //#endregion
@@ -1494,7 +1790,7 @@ function toRejectedFlag(command, flags) {
1494
1790
  //#endregion
1495
1791
  //#region src/cli.ts
1496
1792
  const program = new Command();
1497
- program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version, "-v, --version").showHelpAfterError().enablePositionalOptions();
1793
+ program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version, "-v, --version").option("--api-url <url>", "The API to call, which also moves the authentication endpoints").showHelpAfterError().enablePositionalOptions();
1498
1794
  for (const command of commands) program.addCommand(toProgram(command));
1499
1795
  await program.parseAsync(process.argv);
1500
1796
  /** toProgram wires one registry command into the parser. */
@@ -1530,15 +1826,17 @@ async function toExitCode(command, args, flags) {
1530
1826
  return ExitCode.USAGE;
1531
1827
  }
1532
1828
  try {
1829
+ const resolved = toSettings({ apiUrl: program.opts()["apiUrl"] });
1533
1830
  return await command.run?.({
1534
1831
  args,
1535
1832
  flags,
1536
1833
  isJSON,
1537
- commands
1834
+ commands,
1835
+ resolved
1538
1836
  }) ?? ExitCode.OK;
1539
1837
  } catch (error) {
1540
1838
  writeFailure(error instanceof Error ? error.message : String(error), isJSON);
1541
- return ExitCode.ERROR;
1839
+ return error instanceof ConfigFailure ? ExitCode.USAGE : ExitCode.ERROR;
1542
1840
  }
1543
1841
  }
1544
1842
  function toArgumentList(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.0.2-dev.6",
3
+ "version": "0.0.2-dev.8",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",