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

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 +48 -0
  2. package/dist/cli.js +147 -14
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -85,6 +85,54 @@ npm run generate-surface -- ../api-spec/reference/core.openapi.yaml
85
85
  The document is bundled, meaning its external files are inlined, but `$ref` pointers within
86
86
  it remain. The generator follows those pointers itself.
87
87
 
88
+ ## Local configuration
89
+
90
+ A local build reaches a local server without editing code. Four layers supply the same
91
+ settings, and the one nearest the top wins.
92
+
93
+ | Layer | Where | Beats |
94
+ | --- | --- | --- |
95
+ | Flag | `--api-url` | everything below |
96
+ | Environment | an exported `HARDFIN_*` variable | the files below |
97
+ | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
98
+ | Config file | `config.local.json` in the working directory | the defaults |
99
+ | Default | the published API | nothing |
100
+
101
+ An exported variable beats `.env` because Node leaves a variable that is already set alone.
102
+
103
+ ### What a local build writes
104
+
105
+ ```json
106
+ {
107
+ "apiUrl": "http://localhost:8080/v2",
108
+ "auth": { "tokenUrl": "http://localhost:9000/oauth/token" }
109
+ }
110
+ ```
111
+
112
+ The authentication endpoints follow `apiUrl`, so pointing at a local server moves the whole
113
+ flow. Name one under `auth` to move only that one. The keys are `apiUrl`, `apiKey`,
114
+ `clientId`, and `auth` holding `authorizeUrl`, `tokenUrl`, `deviceUrl`, and `revokeUrl`.
115
+
116
+ A key the file does not define fails the command with exit code 2. A typo that was silently
117
+ ignored would look like a setting that never applied.
118
+
119
+ `config.local.json` and `.env` are both gitignored.
120
+
121
+ ### Seeing what won
122
+
123
+ ```sh
124
+ hardfin config
125
+ ```
126
+
127
+ It prints each setting, its value, and the layer that supplied it. The API key is reported
128
+ as set or not set, never printed.
129
+
130
+ ### Both files are read from the working directory
131
+
132
+ The CLI reads whatever `config.local.json` and `.env` sit in the directory you run it from.
133
+ A directory you do not control can therefore point the CLI at a server you do not expect, so
134
+ run `hardfin config` when a command reaches somewhere surprising.
135
+
88
136
  ## Releasing
89
137
 
90
138
  This section is for anyone who merges a pull request in this repository. It tells you where
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,
@@ -1479,6 +1609,7 @@ const commands = [
1479
1609
  }
1480
1610
  ],
1481
1611
  apiCommand,
1612
+ configCommand,
1482
1613
  agentGuideCommand
1483
1614
  ];
1484
1615
  //#endregion
@@ -1494,7 +1625,7 @@ function toRejectedFlag(command, flags) {
1494
1625
  //#endregion
1495
1626
  //#region src/cli.ts
1496
1627
  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();
1628
+ 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
1629
  for (const command of commands) program.addCommand(toProgram(command));
1499
1630
  await program.parseAsync(process.argv);
1500
1631
  /** toProgram wires one registry command into the parser. */
@@ -1530,15 +1661,17 @@ async function toExitCode(command, args, flags) {
1530
1661
  return ExitCode.USAGE;
1531
1662
  }
1532
1663
  try {
1664
+ const resolved = toSettings({ apiUrl: program.opts()["apiUrl"] });
1533
1665
  return await command.run?.({
1534
1666
  args,
1535
1667
  flags,
1536
1668
  isJSON,
1537
- commands
1669
+ commands,
1670
+ resolved
1538
1671
  }) ?? ExitCode.OK;
1539
1672
  } catch (error) {
1540
1673
  writeFailure(error instanceof Error ? error.message : String(error), isJSON);
1541
- return ExitCode.ERROR;
1674
+ return error instanceof ConfigFailure ? ExitCode.USAGE : ExitCode.ERROR;
1542
1675
  }
1543
1676
  }
1544
1677
  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.7",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",