@hardfin/cli 0.0.1 → 0.0.2-dev.11

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 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. Signing in works against a Hardfin that publishes an authorization
11
+ server, and every command also accepts an API key.
11
12
 
12
13
  ## Install
13
14
 
@@ -15,6 +16,367 @@ This package is a placeholder that reserves the name. No commands work yet.
15
16
  npm install -g @hardfin/cli
16
17
  ```
17
18
 
19
+ Previews of unreleased work are published from the `dev` branch.
20
+
21
+ ```sh
22
+ npm install -g @hardfin/cli@dev
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
+ | `spec/core.openapi.yaml` | The API document the generator reads, vendored here |
35
+ | `scripts/generate-surface.mjs` | The generator |
36
+
37
+ ### How an endpoint becomes a command
38
+
39
+ The first path segment is the noun. Every later literal segment nests under it. The method
40
+ and the shape of the last segment decide the verb.
41
+
42
+ | Endpoint | Command |
43
+ | --- | --- |
44
+ | `GET /asset` | `hardfin asset list` |
45
+ | `POST /asset` | `hardfin asset create` |
46
+ | `GET /asset/{assetKey}` | `hardfin asset get <assetKey>` |
47
+ | `PATCH /asset/{assetKey}` | `hardfin asset update <assetKey>` |
48
+ | `PATCH /asset/{assetKey}/accounting` | `hardfin asset accounting update <assetKey>` |
49
+
50
+ A path parameter becomes a positional argument. A query parameter becomes a flag, named in
51
+ kebab case, and an array parameter becomes a flag you repeat. An enum parameter carries its
52
+ values, so a wrong value fails locally with exit code 2 rather than at the API.
53
+
54
+ ### Overrides
55
+
56
+ Two endpoints sometimes want one name. `DELETE /asset/{assetKey}/ownership` and
57
+ `DELETE /asset/{assetKey}/ownership/{segmentKey}` both generate `asset ownership delete`, so
58
+ the generator stops and names the pair.
59
+
60
+ Settle it in `surface-overrides.json`, keyed by `operationId`:
61
+
62
+ ```json
63
+ {
64
+ "assetClearAssetActiveOwnership": {
65
+ "command": ["asset", "ownership", "clear"],
66
+ "summary": "Clear the ownership an asset holds today"
67
+ }
68
+ }
69
+ ```
70
+
71
+ An override for an `operationId` the document no longer publishes fails the generator. That
72
+ is deliberate, because a silently dropped override renames a command nobody meant to rename.
73
+
74
+ ### The vendored document
75
+
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
+
79
+ Refresh it with the script, which then rewrites the generated commands:
80
+
81
+ ```sh
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
84
+ ```
85
+
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
+ ## Signing in
101
+
102
+ ```sh
103
+ hardfin login # approve this CLI in a browser
104
+ hardfin login --device # approve on another machine, by typing a code
105
+ hardfin status # what the CLI is configured with and signed in as
106
+ hardfin logout # forget this machine, and ask Hardfin to revoke it
107
+ ```
108
+
109
+ An API key is still the way to run unattended. `HARDFIN_API_KEY` wins over a stored sign in,
110
+ so CI and an agent sandbox need no browser.
111
+
112
+ ### What login does
113
+
114
+ 1. Reads `/.well-known/oauth-authorization-server` at the API's host, which names every
115
+ endpoint, including the authorize endpoint on the app host
116
+ 2. Opens a loopback listener on `127.0.0.1`, over IPv4, on whatever port the machine hands out
117
+ 3. Opens the browser to approve this CLI, with a PKCE challenge and a state value
118
+ 4. Prints the URL, and waits at the keyboard as well as on the listener
119
+ 5. Checks the state and the `iss` it came back with, then trades the code for tokens
120
+ 6. Keeps the refresh token, and nothing else
121
+
122
+ While it waits, the terminal takes two things.
123
+
124
+ | Key | Does |
125
+ | --- | --- |
126
+ | `c` | Copies the URL to the clipboard |
127
+ | A pasted value, then enter | Finishes the sign in without the listener |
128
+
129
+ Paste whichever of these you have: the whole redirect URL from the browser's address bar,
130
+ `code#state`, or the code alone. A browser on another machine never reaches this listener,
131
+ and the redirect it failed to follow is still in the address bar.
132
+
133
+ On WSL the browser is opened through Windows, by `wslview`, then PowerShell, then
134
+ `explorer.exe`, because `xdg-open` reaches nothing outside the distribution.
135
+
136
+ The port changes every time, so Hardfin matches a loopback redirect by everything except its
137
+ port, which is what RFC 8252 asks of an authorization server.
138
+
139
+ ### Signing in from a machine with no browser
140
+
141
+ `hardfin login --device` prints a short code and a page to enter it on, and waits. Approve
142
+ on your phone or any other machine, and the terminal finishes on its own.
143
+
144
+ ```
145
+ Code WDJB-MJHT
146
+ At https://app.hardfin.com/device
147
+ ```
148
+
149
+ The CLI refuses `--device` when the authorization server does not offer that grant, rather
150
+ than waiting for an endpoint that is not there.
151
+
152
+ ### When a sign in stops working
153
+
154
+ A refresh token can die: someone signed out on another machine, the token was presented
155
+ twice, or it aged out. The server answers `invalid_grant`, and the CLI removes the dead
156
+ credential and tells you to run `hardfin login` again.
157
+
158
+ A server that merely cannot be reached does not count, because a failed connection says
159
+ nothing about whether the credential is good.
160
+
161
+ ### Where the refresh token is kept
162
+
163
+ | Host | Kept in |
164
+ | --- | --- |
165
+ | macOS, Windows, and Linux with a secret service | the OS keyring |
166
+ | Everything else, including WSL, containers, and agent sandboxes | `$XDG_STATE_HOME/hardfin/credentials.json`, mode 0600 |
167
+
168
+ `HARDFIN_NO_BROWSER=1` keeps `hardfin login` from opening a tab, which is what a script or a
169
+ headless host wants. `HARDFIN_CREDENTIAL_STORE=file` forces the file. `hardfin status` reports which one holds
170
+ the credential, and where.
171
+
172
+ Access tokens are never written anywhere. One is fetched when a command needs it and lives
173
+ in that process only, so what sits at rest is revocable with `hardfin logout`.
174
+
175
+ A credential is filed under the authorization server it came from, so a local build and
176
+ production never share a sign in.
177
+
178
+ ### What it needs configured
179
+
180
+ Nothing. Hardfin registers this CLI as a first-party client, and its key is the same in every
181
+ environment, so `hardfin login` works out of the box.
182
+
183
+ | Setting | Holds | Default |
184
+ | --- | --- | --- |
185
+ | `clientId`, or `HARDFIN_CLIENT_ID` | the client this CLI names itself as | the seeded Hardfin CLI client |
186
+ | `issuerUrl`, or `HARDFIN_ISSUER_URL` | the authorization server | the API's host root |
187
+
188
+ ## Serving an agent
189
+
190
+ ```sh
191
+ hardfin mcp
192
+ ```
193
+
194
+ It speaks the Model Context Protocol on standard input and output, so Claude, Codex or any
195
+ other client can run Hardfin commands.
196
+
197
+ ```sh
198
+ claude mcp add hardfin -- hardfin mcp
199
+ ```
200
+
201
+ ### One tool, and resources for the rest
202
+
203
+ Every tool a server lists sits in the agent's context for the entire session, so this server
204
+ offers one.
205
+
206
+ | Offered as | Holds | Costs context |
207
+ | --- | --- | --- |
208
+ | The `hardfin` tool | A list of arguments, such as `["asset", "list", "--limit", "5"]` | Always, and it is about 400 bytes |
209
+ | `hardfin://guide` | Every command, its flags, and the exit codes | Only when the agent reads it |
210
+ | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
211
+
212
+ Fifty tools would describe the same surface and crowd out the work. An agent that needs the
213
+ list reads a resource, or runs `["--help"]`.
214
+
215
+ A tool call runs the CLI itself, so an agent and a person get identical parsing, output and
216
+ exit codes.
217
+
218
+ ### What it signs in as
219
+
220
+ The server uses whatever credential this machine holds, so an agent acts as the person who
221
+ ran `hardfin login`. Give an agent an API key through `HARDFIN_API_KEY` when it should act
222
+ as an integration instead.
223
+
224
+ ## Diagnosing a problem
225
+
226
+ `hardfin status` prints everything a support request needs, and is the first thing to send.
227
+
228
+ ```sh
229
+ hardfin status # for you
230
+ hardfin status --json # for a support request
231
+ hardfin status --offline # no network calls at all
232
+ ```
233
+
234
+ | Section | Holds |
235
+ | --- | --- |
236
+ | `cli` | The version, the API version it speaks, Node, and whether a terminal is attached |
237
+ | `host` | The operating system, kernel, build, processor, memory, timezone, shell, and the distribution and WSL details on Linux |
238
+ | `configuration` | Every setting, its value, and the layer it came from |
239
+ | `credential` | Whether you are signed in, where the refresh token is kept, and when it was issued and last renewed |
240
+ | `authorizationServer` | Whether discovery worked, and every endpoint it named |
241
+ | `api` | Whether the API accepted the credential, when the access token expires, and its scopes |
242
+
243
+ Nothing secret is printed. An API key and a refresh token are each reported as a
244
+ `sha256:` fingerprint, which identifies a credential across two machines without disclosing
245
+ it.
246
+
247
+ `refreshExpiresAt` is an estimate. Hardfin expires an unused refresh token after 90 days,
248
+ but the token endpoint reports no expiry, so the CLI applies that rule to the date it last
249
+ renewed. The flag `refreshExpiryIsEstimated` says so in the output.
250
+
251
+ ## Local configuration
252
+
253
+ A local build reaches a local server without editing code. Four layers supply the same
254
+ settings, and the one nearest the top wins.
255
+
256
+ | Layer | Where | Beats |
257
+ | --- | --- | --- |
258
+ | Flag | `--api-url` | everything below |
259
+ | Environment | an exported `HARDFIN_*` variable | the files below |
260
+ | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
261
+ | Config file | `config.local.json` in the working directory | the defaults |
262
+ | Default | the published API | nothing |
263
+
264
+ An exported variable beats `.env` because Node leaves a variable that is already set alone.
265
+
266
+ ### What a local build writes
267
+
268
+ ```json
269
+ {
270
+ "apiUrl": "http://localhost:8080/v2",
271
+ "clientId": "https://hardfin.com/cli/client.json"
272
+ }
273
+ ```
274
+
275
+ The authorization server sits at the API's host, so pointing at a local server moves signing
276
+ in with it. The keys are `apiUrl`, `apiKey`, `clientId`, and `issuerUrl`.
277
+
278
+ A key the file does not define fails the command with exit code 2. A typo that was silently
279
+ ignored would look like a setting that never applied.
280
+
281
+ `config.local.json` and `.env` are both gitignored.
282
+
283
+ ### Seeing what won
284
+
285
+ ```sh
286
+ hardfin config
287
+ ```
288
+
289
+ It prints each setting, its value, and the layer that supplied it. The API key is reported
290
+ as set or not set, never printed.
291
+
292
+ ### Both files are read from the working directory
293
+
294
+ The CLI reads whatever `config.local.json` and `.env` sit in the directory you run it from.
295
+ A directory you do not control can therefore point the CLI at a server you do not expect, so
296
+ run `hardfin config` when a command reaches somewhere surprising.
297
+
298
+ ## Releasing
299
+
300
+ This section is for anyone who merges a pull request in this repository. It tells you where
301
+ your branch goes and what reaches npm when it lands.
302
+
303
+ Nobody publishes from a laptop. Every version on npm is published by
304
+ `.github/workflows/release.yaml`, which npm trusts through OIDC. No npm token exists.
305
+
306
+ A published version carries no provenance attestation, because npm only builds one for a
307
+ package published from a public repository, and this repository is private.
308
+
309
+ ### Where a branch goes
310
+
311
+ - `dev` is the integration branch, and every pull request targets it
312
+ - `main` holds what is released, and the only pull requests it takes are promotions and
313
+ hotfixes
314
+ - A promotion is a pull request from `dev` to `main`, and merging it is how a release is cut
315
+ - A hotfix is a pull request straight to `main`, for a fix that cannot wait for the next
316
+ promotion
317
+
318
+ ### What each merge publishes
319
+
320
+ | Merge | Publishes | Version | Dist-tag |
321
+ | --- | --- | --- | --- |
322
+ | Pull request into `dev` | always | the next version, suffixed `-dev.<run number>` | `dev` |
323
+ | Promotion into `main` | when `package.json` names a version that is not yet on npm | that version | `latest` |
324
+ | Hotfix into `main` | same rule as a promotion | that version | `latest` |
325
+
326
+ A preview never moves `latest`, so `npm install @hardfin/cli` keeps returning the released
327
+ version.
328
+
329
+ The workflow picks the preview version itself. It reads `version` from `package.json`, and
330
+ if that version is already on npm it increments the patch, so a preview always sorts after
331
+ the last release.
332
+
333
+ ### Cut a release
334
+
335
+ 1. Open a pull request against `dev` that sets `version` in `package.json` to the version
336
+ you are releasing. Change nothing else in it
337
+ 2. Merge it, which publishes one more preview
338
+ 3. Open a promotion pull request from `dev` to `main`
339
+ 4. Merge it. The workflow publishes to npm under `latest` and opens a GitHub release tagged
340
+ `v<version>`
341
+
342
+ ### Ship a hotfix
343
+
344
+ 1. Branch from `main`
345
+ 2. Commit the fix, and in the same pull request raise the patch version in `package.json`
346
+ 3. Target `main`, and merge. The workflow publishes it
347
+ 4. Open a pull request merging `main` back into `dev` the same day
348
+
349
+ Step 4 is what keeps the fix from being reverted by the next promotion. A hotfix lives only
350
+ on `main` until someone brings it back.
351
+
352
+ ### What CI refuses
353
+
354
+ A required check reads `version` from `package.json` on every pull request and judges it
355
+ against the branch the pull request targets.
356
+
357
+ | Version | Into `dev` | Into `main` |
358
+ | --- | --- | --- |
359
+ | Plain, above what `main` holds | passes | passes |
360
+ | Plain, equal to what `main` holds | passes | refused, because npm already serves it |
361
+ | Plain, below what `main` holds | refused | refused |
362
+ | Carrying a `-dev` or any other prerelease suffix | refused | refused |
363
+ | Not a semver version | refused | refused |
364
+
365
+ A prerelease suffix is refused everywhere because the release workflow appends it at publish
366
+ time. A version equal to `main`'s is fine on `dev`, since the workflow increments the patch
367
+ when it builds a preview.
368
+
369
+ ### Rules
370
+
371
+ - Never run `npm publish` by hand. The package requires two-factor authentication and
372
+ disallows tokens, and a hand publish skips the version check that guards the branch
373
+ - Never reuse a version. npm refuses to serve a version's contents twice, even after an
374
+ unpublish, so a bad release is fixed by publishing the next patch
375
+ - Never rename `release.yaml`. npm's trusted publisher names this file, and a rename stops
376
+ every publish until someone updates the package settings on npm
377
+ - A merge into `main` that does not raise the version publishes nothing. The workflow finds
378
+ the version already on npm and stops
379
+
18
380
  ## License
19
381
 
20
382
  Apache 2.0. See [LICENSE](LICENSE)