@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.
- package/README.md +48 -0
- package/dist/cli.js +147 -14
- 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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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) {
|