@hardfin/cli 0.0.2-dev.14 → 0.0.2-dev.16

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 +84 -66
  2. package/dist/cli.js +248 -138
  3. package/package.json +14 -2
package/README.md CHANGED
@@ -26,37 +26,37 @@ npm install -g @hardfin/cli@dev
26
26
 
27
27
  Most commands are generated from the published API document rather than written by hand.
28
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 |
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
36
 
37
37
  ### How an endpoint becomes a command
38
38
 
39
39
  The first path segment is the noun. Every later literal segment nests under it. The method
40
40
  and the shape of the last segment decide the verb.
41
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>` |
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
48
  | `PATCH /asset/{assetKey}/accounting` | `hardfin asset accounting update <assetKey>` |
49
49
 
50
50
  Everything an endpoint takes becomes a flag or an argument. No command asks for JSON.
51
51
 
52
- | The document says | You type |
53
- | --- | --- |
54
- | A path parameter | a positional argument |
55
- | A query or body field | `--field-name`, in kebab case |
56
- | A field inside a nested object | `--address-city`, the path joined |
57
- | An array of values | the flag, repeated |
58
- | An array of objects | the flag, repeated, each carrying that element's fields as `key=value` |
59
- | A file upload | `--file <path>`, sent as multipart |
52
+ | The document says | You type |
53
+ | ------------------------------ | ---------------------------------------------------------------------- |
54
+ | A path parameter | a positional argument |
55
+ | A query or body field | `--field-name`, in kebab case |
56
+ | A field inside a nested object | `--address-city`, the path joined |
57
+ | An array of values | the flag, repeated |
58
+ | An array of objects | the flag, repeated, each carrying that element's fields as `key=value` |
59
+ | A file upload | `--file <path>`, sent as multipart |
60
60
 
61
61
  ```sh
62
62
  hardfin asset ownership create ast_4f9xk2mq7plr8stz \
@@ -118,10 +118,10 @@ what the api-spec bridge publishes. Commit the document and the generated comman
118
118
 
119
119
  Two checks keep the pair honest.
120
120
 
121
- | Check | Refuses |
122
- | --- | --- |
121
+ | Check | Refuses |
122
+ | ------------- | ----------------------------------------------------------------------------- |
123
123
  | The generator | a document whose `info.version` is not a date, which means an earlier release |
124
- | CI | a vendored document that was updated without regenerating the commands |
124
+ | CI | a vendored document that was updated without regenerating the commands |
125
125
 
126
126
  The first one matters because `hardfinhq/api-spec` can sit a release behind the monorepo
127
127
  while its bridge pull request is open. Generating from that document would replace the
@@ -151,9 +151,9 @@ so CI and an agent sandbox need no browser.
151
151
 
152
152
  While it waits, the terminal takes two things.
153
153
 
154
- | Key | Does |
155
- | --- | --- |
156
- | `c` | Copies the URL to the clipboard |
154
+ | Key | Does |
155
+ | -------------------------- | ----------------------------------------- |
156
+ | `c` | Copies the URL to the clipboard |
157
157
  | A pasted value, then enter | Finishes the sign in without the listener |
158
158
 
159
159
  Paste whichever of these you have: the whole redirect URL from the browser's address bar,
@@ -190,9 +190,9 @@ nothing about whether the credential is good.
190
190
 
191
191
  ### Where the refresh token is kept
192
192
 
193
- | Host | Kept in |
194
- | --- | --- |
195
- | macOS, Windows, and Linux with a secret service | the OS keyring |
193
+ | Host | Kept in |
194
+ | --------------------------------------------------------------- | ----------------------------------------------------- |
195
+ | macOS, Windows, and Linux with a secret service | the OS keyring |
196
196
  | Everything else, including WSL, containers, and agent sandboxes | `$XDG_STATE_HOME/hardfin/credentials.json`, mode 0600 |
197
197
 
198
198
  `HARDFIN_NO_BROWSER=1` keeps `hardfin login` from opening a tab, which is what a script or a
@@ -210,10 +210,10 @@ production never share a sign in.
210
210
  Nothing. Hardfin registers this CLI as a first-party client, and its key is the same in every
211
211
  environment, so `hardfin login` works out of the box.
212
212
 
213
- | Setting | Holds | Default |
214
- | --- | --- | --- |
215
- | `clientId`, or `HARDFIN_CLIENT_ID` | the client this CLI names itself as | the seeded Hardfin CLI client |
216
- | `issuerUrl`, or `HARDFIN_ISSUER_URL` | the authorization server | the API's host root |
213
+ | Setting | Holds | Default |
214
+ | ------------------------------------ | ----------------------------------- | ----------------------------- |
215
+ | `clientId`, or `HARDFIN_CLIENT_ID` | the client this CLI names itself as | the seeded Hardfin CLI client |
216
+ | `issuerUrl`, or `HARDFIN_ISSUER_URL` | the authorization server | the API's host root |
217
217
 
218
218
  ## Completing commands in your shell
219
219
 
@@ -244,11 +244,11 @@ claude mcp add hardfin -- hardfin mcp
244
244
  Every tool a server lists sits in the agent's context for the entire session, so this server
245
245
  offers one.
246
246
 
247
- | Offered as | Holds | Costs context |
248
- | --- | --- | --- |
249
- | The `hardfin` tool | A list of arguments, such as `["asset", "list", "--limit", "5"]` | Always, and it is about 400 bytes |
250
- | `hardfin://guide` | Every command, its flags, and the exit codes | Only when the agent reads it |
251
- | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
247
+ | Offered as | Holds | Costs context |
248
+ | -------------------- | ---------------------------------------------------------------- | --------------------------------- |
249
+ | The `hardfin` tool | A list of arguments, such as `["asset", "list", "--limit", "5"]` | Always, and it is about 400 bytes |
250
+ | `hardfin://guide` | Every command, its flags, and the exit codes | Only when the agent reads it |
251
+ | `hardfin://commands` | The command tree as JSON | Only when the agent reads it |
252
252
 
253
253
  Fifty tools would describe the same surface and crowd out the work. An agent that needs the
254
254
  list reads a resource, or runs `["--help"]`.
@@ -272,14 +272,14 @@ hardfin status --json # for a support request
272
272
  hardfin status --offline # no network calls at all
273
273
  ```
274
274
 
275
- | Section | Holds |
276
- | --- | --- |
277
- | `cli` | The version, the API version it speaks, Node, and whether a terminal is attached |
278
- | `host` | The operating system, kernel, build, processor, memory, timezone, shell, and the distribution and WSL details on Linux |
279
- | `configuration` | Every setting, its value, and the layer it came from |
280
- | `credential` | Whether you are signed in, where the refresh token is kept, and when it was issued and last renewed |
281
- | `authorizationServer` | Whether discovery worked, and every endpoint it named |
282
- | `api` | Whether the API accepted the credential, when the access token expires, and its scopes |
275
+ | Section | Holds |
276
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------- |
277
+ | `cli` | The version, the API version it speaks, Node, and whether a terminal is attached |
278
+ | `host` | The operating system, kernel, build, processor, memory, timezone, shell, and the distribution and WSL details on Linux |
279
+ | `configuration` | Every setting, its value, and the layer it came from |
280
+ | `credential` | Whether you are signed in, where the refresh token is kept, and when it was issued and last renewed |
281
+ | `authorizationServer` | Whether discovery worked, and every endpoint it named |
282
+ | `api` | Whether the API accepted the credential, when the access token expires, and its scopes |
283
283
 
284
284
  Nothing secret is printed. An API key and a refresh token are each reported as a
285
285
  `sha256:` fingerprint, which identifies a credential across two machines without disclosing
@@ -294,13 +294,13 @@ says which of the two you are looking at.
294
294
  A local build reaches a local server without editing code. Four layers supply the same
295
295
  settings, and the one nearest the top wins.
296
296
 
297
- | Layer | Where | Beats |
298
- | --- | --- | --- |
299
- | Flag | `--api-url` | everything below |
300
- | Environment | an exported `HARDFIN_*` variable | the files below |
301
- | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
302
- | Config file | `config.local.json` in the working directory | the defaults |
303
- | Default | the published API | nothing |
297
+ | Layer | Where | Beats |
298
+ | ----------- | --------------------------------------------------------------------- | ---------------- |
299
+ | Flag | `--api-url` | everything below |
300
+ | Environment | an exported `HARDFIN_*` variable | the files below |
301
+ | Env file | `.env` in the working directory, or the file `HARDFIN_ENV_FILE` names | the config file |
302
+ | Config file | `config.local.json` in the working directory | the defaults |
303
+ | Default | the published API | nothing |
304
304
 
305
305
  An exported variable beats `.env` because Node leaves a variable that is already set alone.
306
306
 
@@ -336,6 +336,24 @@ The CLI reads whatever `config.local.json` and `.env` sit in the directory you r
336
336
  A directory you do not control can therefore point the CLI at a server you do not expect, so
337
337
  run `hardfin config` when a command reaches somewhere surprising.
338
338
 
339
+ ## Working on the CLI
340
+
341
+ ```sh
342
+ npm run check-types # tsc
343
+ npm run lint # eslint, with type-aware rules
344
+ npm run prettier:write # formatting
345
+ npm test # vitest
346
+ npm run build # tsdown, into dist/cli.js
347
+ ```
348
+
349
+ CI runs all five on every pull request, plus a check that the generated commands still match
350
+ the vendored API document. A pull request that fails any of them cannot merge.
351
+
352
+ Two lint rules exist because of defects that reached the repository. `no-floating-promises`
353
+ covers a promise nobody waits for, which once killed the process when a sign in was
354
+ cancelled. `no-base-to-string` covers a value stringified as `[object Object]`, which a
355
+ request would have carried to the API.
356
+
339
357
  ## Releasing
340
358
 
341
359
  This section is for anyone who merges a pull request in this repository. It tells you where
@@ -358,11 +376,11 @@ package published from a public repository, and this repository is private.
358
376
 
359
377
  ### What each merge publishes
360
378
 
361
- | Merge | Publishes | Version | Dist-tag |
362
- | --- | --- | --- | --- |
363
- | Pull request into `dev` | always | the next version, suffixed `-dev.<run number>` | `dev` |
364
- | Promotion into `main` | when `package.json` names a version that is not yet on npm | that version | `latest` |
365
- | Hotfix into `main` | same rule as a promotion | that version | `latest` |
379
+ | Merge | Publishes | Version | Dist-tag |
380
+ | ----------------------- | ---------------------------------------------------------- | ---------------------------------------------- | -------- |
381
+ | Pull request into `dev` | always | the next version, suffixed `-dev.<run number>` | `dev` |
382
+ | Promotion into `main` | when `package.json` names a version that is not yet on npm | that version | `latest` |
383
+ | Hotfix into `main` | same rule as a promotion | that version | `latest` |
366
384
 
367
385
  A preview never moves `latest`, so `npm install @hardfin/cli` keeps returning the released
368
386
  version.
@@ -395,13 +413,13 @@ on `main` until someone brings it back.
395
413
  A required check reads `version` from `package.json` on every pull request and judges it
396
414
  against the branch the pull request targets.
397
415
 
398
- | Version | Into `dev` | Into `main` |
399
- | --- | --- | --- |
400
- | Plain, above what `main` holds | passes | passes |
401
- | Plain, equal to what `main` holds | passes | refused, because npm already serves it |
402
- | Plain, below what `main` holds | refused | refused |
403
- | Carrying a `-dev` or any other prerelease suffix | refused | refused |
404
- | Not a semver version | refused | refused |
416
+ | Version | Into `dev` | Into `main` |
417
+ | ------------------------------------------------ | ---------- | -------------------------------------- |
418
+ | Plain, above what `main` holds | passes | passes |
419
+ | Plain, equal to what `main` holds | passes | refused, because npm already serves it |
420
+ | Plain, below what `main` holds | refused | refused |
421
+ | Carrying a `-dev` or any other prerelease suffix | refused | refused |
422
+ | Not a semver version | refused | refused |
405
423
 
406
424
  A prerelease suffix is refused everywhere because the release workflow appends it at publish
407
425
  time. A version equal to `main`'s is fine on `dev`, since the workflow increments the patch
package/dist/cli.js CHANGED
@@ -4,10 +4,10 @@ import { Command, Option } from "commander";
4
4
  import { z } from "zod";
5
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openAsBlob, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
6
6
  import { basename, dirname, join, resolve } from "node:path";
7
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
7
8
  import { arch, cpus, homedir, release, totalmem, type, version } from "node:os";
8
9
  import { spawn, spawnSync } from "node:child_process";
9
10
  import { createServer } from "node:http";
10
- import { createHash, randomBytes } from "node:crypto";
11
11
  import { createInterface } from "node:readline";
12
12
  //#region src/command/registry.ts
13
13
  /** ExitCode is what the process returns, and what an agent branches on. */
@@ -120,6 +120,16 @@ function toOrigin(apiUrl) {
120
120
  }
121
121
  //#endregion
122
122
  //#region src/output/writer.ts
123
+ /**
124
+ * toText reads a value a caller supplied, which arrives typed as unknown. An object would
125
+ * otherwise reach a request as the text "[object Object]".
126
+ */
127
+ function toText(value) {
128
+ if (typeof value === "string") return value;
129
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
130
+ if (value === void 0 || value === null) return "";
131
+ return JSON.stringify(value);
132
+ }
123
133
  /** writeData prints a command's result on stdout. */
124
134
  function writeData(value) {
125
135
  if (typeof value === "string") {
@@ -252,6 +262,32 @@ function toSummary(command) {
252
262
  };
253
263
  }
254
264
  //#endregion
265
+ //#region src/auth/jwt.ts
266
+ /**
267
+ * toClaims reads an access token's payload for display. Nothing here verifies the
268
+ * signature, because the API is what decides whether a token is good.
269
+ */
270
+ function toClaims(token) {
271
+ const payload = token.split(".")[1];
272
+ if (!payload) return {};
273
+ try {
274
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
275
+ return {
276
+ expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
277
+ issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
278
+ scopes: toScopes(decoded),
279
+ subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
280
+ };
281
+ } catch {
282
+ return {};
283
+ }
284
+ }
285
+ /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
286
+ function toScopes(decoded) {
287
+ if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
288
+ return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
289
+ }
290
+ //#endregion
255
291
  //#region src/auth/metadata.ts
256
292
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
257
293
  const AuthorizationServerMetadata = z.looseObject({
@@ -346,7 +382,7 @@ async function toTokens(url, form) {
346
382
  body: new URLSearchParams(form)
347
383
  });
348
384
  const body = await response.json().catch(() => void 0);
349
- if (!response.ok) throw new GrantFailure(String(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : String(body["error_description"]));
385
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
350
386
  const parsed = TokenResponse.safeParse(body);
351
387
  if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
352
388
  return {
@@ -388,30 +424,44 @@ function keep(issuer, refreshToken, expiresAt) {
388
424
  const keyring = toKeyring(issuer);
389
425
  if (keyring) try {
390
426
  keyring.setPassword(JSON.stringify(record));
427
+ forgetFile(issuer);
391
428
  return "keyring";
392
429
  } catch {}
393
430
  writeFile({
394
431
  ...readFile(),
395
432
  [issuer]: record
396
433
  });
434
+ forgetKeyring(issuer);
397
435
  return "file";
398
436
  }
399
- /** toCredential reads what is held for an issuer, and where it was held. */
437
+ /**
438
+ * toCredential reads what is held for an issuer, and where it was held. Both backends are
439
+ * read, because a host that lost its keyring for a while wrote to the file instead, and the
440
+ * newer of the two is the one the server has not spent.
441
+ */
400
442
  function toCredential(issuer) {
401
- const keyring = toKeyring(issuer);
402
- if (keyring) try {
403
- const held = keyring.getPassword();
404
- if (held) return {
405
- ...toRecord(held),
406
- backend: "keyring"
407
- };
408
- } catch {}
443
+ const fromKeyring = toKeyringCredential(issuer);
409
444
  const held = readFile()[issuer];
410
- return held === void 0 ? void 0 : {
445
+ const fromFile = held === void 0 ? void 0 : {
411
446
  ...held,
412
447
  backend: "file",
413
448
  path: toCredentialPath()
414
449
  };
450
+ if (!fromKeyring || !fromFile) return fromKeyring ?? fromFile;
451
+ return (fromFile.renewedAt ?? "") > (fromKeyring.renewedAt ?? "") ? fromFile : fromKeyring;
452
+ }
453
+ function toKeyringCredential(issuer) {
454
+ const keyring = toKeyring(issuer);
455
+ if (!keyring) return;
456
+ try {
457
+ const held = keyring.getPassword();
458
+ return held ? {
459
+ ...toRecord(held),
460
+ backend: "keyring"
461
+ } : void 0;
462
+ } catch {
463
+ return;
464
+ }
415
465
  }
416
466
  /** toRecord reads a stored entry, which older versions wrote as the bare token. */
417
467
  function toRecord(held) {
@@ -424,18 +474,25 @@ function toRecord(held) {
424
474
  }
425
475
  /** forget removes whatever is held for an issuer, in both places. */
426
476
  function forget(issuer) {
477
+ forgetKeyring(issuer);
478
+ forgetFile(issuer);
479
+ }
480
+ function forgetKeyring(issuer) {
427
481
  const keyring = toKeyring(issuer);
428
- if (keyring) try {
482
+ if (!keyring) return;
483
+ try {
429
484
  keyring.deletePassword();
430
485
  } catch {}
486
+ }
487
+ function forgetFile(issuer) {
431
488
  const held = readFile();
432
489
  if (held[issuer] === void 0) return;
433
- delete held[issuer];
434
- if (Object.keys(held).length === 0) {
490
+ const remaining = Object.fromEntries(Object.entries(held).filter(([name]) => name !== issuer));
491
+ if (Object.keys(remaining).length === 0) {
435
492
  rmSync(toCredentialPath(), { force: true });
436
493
  return;
437
494
  }
438
- writeFile(held);
495
+ writeFile(remaining);
439
496
  }
440
497
  /** toKeyring opens the OS keyring, or answers undefined where the platform has none. */
441
498
  function toKeyring(issuer) {
@@ -474,6 +531,8 @@ const STALE_MS = 3e4;
474
531
  const WAIT_MS$1 = 1e4;
475
532
  const RETRY_MS = 25;
476
533
  const DIRECTORY_MODE = 448;
534
+ /** What this process wrote into the lock, which is how it knows the lock is still its own. */
535
+ let heldBy;
477
536
  /** toLockPath names the lock every process coordinates credential writes through. */
478
537
  function toLockPath() {
479
538
  return join(dirname(toCredentialPath()), "credentials.lock");
@@ -485,17 +544,23 @@ function tryAcquire() {
485
544
  recursive: true,
486
545
  mode: DIRECTORY_MODE
487
546
  });
547
+ const mark = `${process.pid}:${randomUUID()}`;
488
548
  try {
489
549
  const handle = openSync(path, "wx");
490
- writeSync(handle, String(process.pid));
550
+ writeSync(handle, mark);
491
551
  closeSync(handle);
552
+ heldBy = mark;
492
553
  return true;
493
554
  } catch {
494
- return isStale(path) ? steal(path) : false;
555
+ return isStale(path) ? steal(path, mark) : false;
495
556
  }
496
557
  }
497
- /** release lets the next process in. */
558
+ /** release lets the next process in, and only ever removes this process's own lock. */
498
559
  function release$1() {
560
+ const mark = heldBy;
561
+ if (mark === void 0) return;
562
+ heldBy = void 0;
563
+ if (toMark(toLockPath()) !== mark) return;
499
564
  rmSync(toLockPath(), { force: true });
500
565
  }
501
566
  /**
@@ -522,9 +587,29 @@ function isStale(path) {
522
587
  return true;
523
588
  }
524
589
  }
525
- function steal(path) {
590
+ /**
591
+ * steal takes over a lock whose holder is gone. Two processes can reach this at once, so
592
+ * the winner is whichever mark survives in the file, not whichever removed it.
593
+ */
594
+ function steal(path, mark) {
526
595
  rmSync(path, { force: true });
527
- return tryAcquire();
596
+ try {
597
+ const handle = openSync(path, "wx");
598
+ writeSync(handle, mark);
599
+ closeSync(handle);
600
+ } catch {
601
+ return false;
602
+ }
603
+ if (toMark(path) !== mark) return false;
604
+ heldBy = mark;
605
+ return true;
606
+ }
607
+ function toMark(path) {
608
+ try {
609
+ return readFileSync(path, "utf8");
610
+ } catch {
611
+ return;
612
+ }
528
613
  }
529
614
  //#endregion
530
615
  //#region src/auth/session.ts
@@ -567,9 +652,9 @@ async function toAccessToken(settings, refreshToken) {
567
652
  return await withLock(async () => {
568
653
  const latest = toCredential(settings.issuerUrl)?.refreshToken ?? refreshToken;
569
654
  try {
570
- const tokens = await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest);
655
+ const tokens = toDated(await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest));
571
656
  held.set(settings.issuerUrl, tokens);
572
- if (tokens.refreshToken && tokens.refreshToken !== latest) keep(settings.issuerUrl, tokens.refreshToken, tokens.refreshExpiresAt);
657
+ store(settings.issuerUrl, tokens, latest);
573
658
  return tokens;
574
659
  } catch (error) {
575
660
  if (error instanceof GrantFailure && isDead(error.code)) {
@@ -580,14 +665,37 @@ async function toAccessToken(settings, refreshToken) {
580
665
  }
581
666
  });
582
667
  }
583
- /** Codes the authorization server uses when a refresh token can never work again. */
584
- const DEAD_GRANT_CODES = /* @__PURE__ */ new Set([
585
- "invalid_grant",
586
- "invalid_client",
587
- "unauthorized_client"
588
- ]);
668
+ /**
669
+ * invalid_grant is the only refusal that says anything about the refresh token itself.
670
+ * invalid_client and unauthorized_client describe the client registration, which is server
671
+ * configuration and a setting a person can mistype, so a good credential survives them.
672
+ */
589
673
  function isDead(code) {
590
- return DEAD_GRANT_CODES.has(code);
674
+ return code === "invalid_grant";
675
+ }
676
+ /**
677
+ * toDated fills in when an access token expires. A token endpoint that states no expires_in
678
+ * would otherwise have this process refresh on every command, and every refresh spends a
679
+ * generation of the token family.
680
+ */
681
+ function toDated(tokens) {
682
+ if (tokens.expiresAt !== void 0) return tokens;
683
+ return {
684
+ ...tokens,
685
+ expiresAt: toClaims(tokens.accessToken).expiresAt
686
+ };
687
+ }
688
+ /**
689
+ * store writes what a rotation issued. The old token is spent either way, so failing to
690
+ * write the new one is worth saying out loud rather than failing a command that succeeded.
691
+ */
692
+ function store(issuer, tokens, previous) {
693
+ if (!tokens.refreshToken || tokens.refreshToken === previous) return;
694
+ try {
695
+ keep(issuer, tokens.refreshToken, tokens.refreshExpiresAt);
696
+ } catch (error) {
697
+ process.stderr.write(`warning: this sign in was renewed but could not be stored, so the next command will ask you to sign in again: ${error instanceof Error ? error.message : String(error)}\n`);
698
+ }
591
699
  }
592
700
  /** forgetHeldTokens drops the access tokens this process is holding. */
593
701
  function forgetHeldTokens() {
@@ -736,7 +844,7 @@ async function runApi(input) {
736
844
  writeData((await request({
737
845
  apiUrl: input.resolved.settings.apiUrl,
738
846
  credential: await toRequestCredential(input.resolved.settings),
739
- method: String(input.flags["method"] ?? "GET").toUpperCase(),
847
+ method: toText(input.flags["method"] ?? "GET").toUpperCase(),
740
848
  path,
741
849
  query,
742
850
  body
@@ -864,7 +972,7 @@ function toFlagNames(command) {
864
972
  return [...(command?.flags ?? []).map((flag) => `--${flag.name}`), "--help"];
865
973
  }
866
974
  async function runCompletion(input) {
867
- const shell = String(input.args[0] ?? "");
975
+ const shell = input.args[0] ?? "";
868
976
  if (!SHELLS.includes(shell)) {
869
977
  writeFailure(`${shell || "no shell"} is not one this CLI writes for. Choose ${SHELLS.join(", ")}`, input.isJSON);
870
978
  return ExitCode.USAGE;
@@ -1018,7 +1126,7 @@ async function toDeviceAuthorization(endpoint, clientId, scope) {
1018
1126
  })
1019
1127
  });
1020
1128
  const body = await response.json().catch(() => void 0);
1021
- if (!response.ok) throw new GrantFailure(String(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : String(body["error_description"]));
1129
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
1022
1130
  const parsed = DeviceResponse.safeParse(body);
1023
1131
  if (!parsed.success) throw new GrantFailure("invalid_response", "the device endpoint did not answer with a code");
1024
1132
  return {
@@ -1094,8 +1202,12 @@ async function toListener(timeoutMs) {
1094
1202
  }, timeoutMs);
1095
1203
  return {
1096
1204
  redirectUri: `http://${HOST}:${server.address().port}${CALLBACK_PATH}`,
1097
- callback: callback.finally(() => close(server, timer)),
1098
- close: () => close(server, timer)
1205
+ callback: callback.finally(() => {
1206
+ close(server, timer);
1207
+ }),
1208
+ close: () => {
1209
+ close(server, timer);
1210
+ }
1099
1211
  };
1100
1212
  }
1101
1213
  function toCallback(request) {
@@ -1197,6 +1309,7 @@ function toPrompt(url) {
1197
1309
  fail = reject;
1198
1310
  });
1199
1311
  const input = process.stdin;
1312
+ pasted.catch(() => {});
1200
1313
  if (!input.isTTY) return {
1201
1314
  pasted,
1202
1315
  close: () => {}
@@ -1296,7 +1409,7 @@ async function runLogin(input) {
1296
1409
  try {
1297
1410
  const clientId = settings.clientId;
1298
1411
  const metadata = await toMetadata(settings.issuerUrl);
1299
- const scope = String(input.flags["scope"] ?? DEFAULT_SCOPES);
1412
+ const scope = toText(input.flags["scope"] ?? DEFAULT_SCOPES);
1300
1413
  if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
1301
1414
  const listener = await toListener(WAIT_MS);
1302
1415
  const pkce = toPkce();
@@ -1312,9 +1425,13 @@ async function runLogin(input) {
1312
1425
  process.stderr.write(opened ? `Opening your browser to sign in. If it did not open:\n\n${url}\n\n` : `Open this URL to sign in:\n\n${url}\n\n`);
1313
1426
  const prompt = toPrompt(url);
1314
1427
  if (process.stdin.isTTY) process.stderr.write("Press c to copy the URL, or paste the code or redirect URL here: ");
1315
- const callback = await Promise.race([listener.callback, prompt.pasted]);
1316
- listener.close();
1317
- prompt.close();
1428
+ let callback;
1429
+ try {
1430
+ callback = await Promise.race([listener.callback, prompt.pasted]);
1431
+ } finally {
1432
+ listener.close();
1433
+ prompt.close();
1434
+ }
1318
1435
  process.stderr.write(callback.error ? "\n" : "\nApproved, finishing the sign in\n");
1319
1436
  if (callback.error) {
1320
1437
  writeFailure(`sign in was refused: ${callback.error}${callback.errorDescription ? `, ${callback.errorDescription}` : ""}`, input.isJSON);
@@ -1360,7 +1477,15 @@ async function runDeviceLogin(input, metadata, scope) {
1360
1477
  opened ? "Opened your browser there. Waiting for approval\n" : "Open that page on any machine, and enter the code. Waiting for approval\n"
1361
1478
  ].join("\n"));
1362
1479
  const prompt = toPrompt(url);
1363
- return await toSignedIn(input, metadata, await toTokensFromDevice(metadata.token_endpoint, settings.clientId, device).finally(() => prompt.close()));
1480
+ try {
1481
+ return await toSignedIn(input, metadata, await Promise.race([toTokensFromDevice(metadata.token_endpoint, settings.clientId, device), prompt.pasted.then(toCancelled)]));
1482
+ } finally {
1483
+ prompt.close();
1484
+ }
1485
+ }
1486
+ /** toCancelled ends a device sign in that the keyboard interrupted. */
1487
+ function toCancelled() {
1488
+ throw new Error("sign in was cancelled");
1364
1489
  }
1365
1490
  /** toSignedIn stores what a sign in issued, whichever flow issued it. */
1366
1491
  async function toSignedIn(input, metadata, tokens) {
@@ -1440,7 +1565,9 @@ async function runLogout(input) {
1440
1565
  } catch {
1441
1566
  revoked = false;
1442
1567
  }
1443
- await withLock(() => forget(settings.issuerUrl));
1568
+ await withLock(() => {
1569
+ forget(settings.issuerUrl);
1570
+ });
1444
1571
  forgetHeldTokens();
1445
1572
  writeData({
1446
1573
  signedOut: true,
@@ -1502,7 +1629,7 @@ async function toResponse(request, commands, run) {
1502
1629
  description: "The command tree as JSON",
1503
1630
  mimeType: "application/json"
1504
1631
  }] });
1505
- case "resources/read": return answer(toResource(String(request.params?.["uri"] ?? ""), commands));
1632
+ case "resources/read": return answer(toResource(toText(request.params?.["uri"] ?? ""), commands));
1506
1633
  case "tools/call": return answer(await toToolResult(request.params, run));
1507
1634
  case "ping": return answer({});
1508
1635
  default:
@@ -1580,11 +1707,13 @@ function toCliRunner() {
1580
1707
  child.stderr.on("data", (chunk) => {
1581
1708
  stderr += chunk.toString();
1582
1709
  });
1583
- child.on("close", (code) => resolve({
1584
- stdout,
1585
- stderr,
1586
- code: code ?? 1
1587
- }));
1710
+ child.on("close", (code) => {
1711
+ resolve({
1712
+ stdout,
1713
+ stderr,
1714
+ code: code ?? 1
1715
+ });
1716
+ });
1588
1717
  });
1589
1718
  }
1590
1719
  /** serve answers requests on stdin until the client closes it. */
@@ -1635,6 +1764,13 @@ async function runMcp(input) {
1635
1764
  }
1636
1765
  //#endregion
1637
1766
  //#region src/command/operation.ts
1767
+ /**
1768
+ * toEnum takes a value in any case, as the API does, and answers the spelling the document
1769
+ * lists, which is what a response always uses.
1770
+ */
1771
+ function toEnum(values) {
1772
+ return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
1773
+ }
1638
1774
  const UNSET_FLAG = {
1639
1775
  name: "unset",
1640
1776
  description: "A field to clear, named as its flag is, repeatable",
@@ -1741,7 +1877,9 @@ function toQuery(operation, flags) {
1741
1877
  for (const flag of operation.queryFlags) {
1742
1878
  const value = flags[toOptionKey(flag.name)];
1743
1879
  if (value === void 0) continue;
1744
- for (const entry of Array.isArray(value) ? value : [value]) query.append(flag.queryName, String(entry));
1880
+ const parsed = flag.schema.safeParse(value);
1881
+ const carried = parsed.success ? parsed.data : value;
1882
+ for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, toText(entry));
1745
1883
  }
1746
1884
  return query;
1747
1885
  }
@@ -1761,7 +1899,10 @@ async function toForm(upload, flags) {
1761
1899
  }
1762
1900
  for (const field of upload.fields) {
1763
1901
  const value = flags[toOptionKey(field.name)];
1764
- if (value !== void 0) form.append(field.jsonPath[0] ?? field.name, String(value));
1902
+ if (value !== void 0) {
1903
+ const parsed = field.schema.safeParse(value);
1904
+ form.append(field.jsonPath[0] ?? field.name, toText(parsed.success ? parsed.data : value));
1905
+ }
1765
1906
  }
1766
1907
  return form;
1767
1908
  }
@@ -1773,7 +1914,7 @@ function toBody(operation, flags) {
1773
1914
  const value = flags[toOptionKey(flag.name)];
1774
1915
  if (value === void 0) continue;
1775
1916
  if (flag.element) {
1776
- const elements = toElements(flag, Array.isArray(value) ? value.map(String) : [String(value)]);
1917
+ const elements = toElements(flag, Array.isArray(value) ? value.map(toText) : [toText(value)]);
1777
1918
  if (elements instanceof Error) return elements;
1778
1919
  set(body, flag.jsonPath, elements);
1779
1920
  hasField = true;
@@ -1790,7 +1931,7 @@ function toBody(operation, flags) {
1790
1931
  /** toCleared sends null for each field named by --unset, which is how a field is cleared. */
1791
1932
  function toCleared(operation, flags, body) {
1792
1933
  const named = flags["unset"];
1793
- const names = Array.isArray(named) ? named.map(String) : named === void 0 ? [] : [String(named)];
1934
+ const names = Array.isArray(named) ? named.map(toText) : named === void 0 ? [] : [toText(named)];
1794
1935
  for (const name of names) {
1795
1936
  const flag = operation.bodyFlags.find((entry) => entry.name === name.replace(/^--/, ""));
1796
1937
  if (!flag) return /* @__PURE__ */ new Error(`--unset names no field called ${name}`);
@@ -1847,21 +1988,21 @@ const surfaceCommands = [
1847
1988
  queryName: "page",
1848
1989
  description: "The page to return, starting at 1",
1849
1990
  valueName: "number",
1850
- schema: z.coerce.number()
1991
+ schema: z.coerce.number().int()
1851
1992
  },
1852
1993
  {
1853
1994
  name: "limit",
1854
1995
  queryName: "limit",
1855
1996
  description: "The number of records per page, from 1 to 100",
1856
1997
  valueName: "number",
1857
- schema: z.coerce.number()
1998
+ schema: z.coerce.number().int().min(1).max(100)
1858
1999
  },
1859
2000
  {
1860
2001
  name: "sort-by",
1861
2002
  queryName: "sortBy",
1862
2003
  description: "The field to sort by",
1863
2004
  valueName: "value",
1864
- schema: z.enum([
2005
+ schema: toEnum([
1865
2006
  "serial",
1866
2007
  "project",
1867
2008
  "item",
@@ -1875,14 +2016,14 @@ const surfaceCommands = [
1875
2016
  queryName: "sortOrder",
1876
2017
  description: "The sort direction",
1877
2018
  valueName: "value",
1878
- schema: z.enum(["ASC", "DESC"])
2019
+ schema: toEnum(["ASC", "DESC"])
1879
2020
  },
1880
2021
  {
1881
2022
  name: "archived",
1882
2023
  queryName: "archived",
1883
2024
  description: "Whether to return unarchived records, archived records, or all of them",
1884
2025
  valueName: "value",
1885
- schema: z.enum([
2026
+ schema: toEnum([
1886
2027
  "all",
1887
2028
  "false",
1888
2029
  "true"
@@ -1949,7 +2090,7 @@ const surfaceCommands = [
1949
2090
  description: "The functional statuses to list, where SCRAPPED lists scrapped assets",
1950
2091
  valueName: "value",
1951
2092
  repeatable: true,
1952
- schema: z.array(z.enum([
2093
+ schema: z.array(toEnum([
1953
2094
  "FUNCTIONAL",
1954
2095
  "NEEDS_REVIEW",
1955
2096
  "NON-FUNCTIONAL",
@@ -1962,7 +2103,7 @@ const surfaceCommands = [
1962
2103
  description: "The transit statuses to list",
1963
2104
  valueName: "value",
1964
2105
  repeatable: true,
1965
- schema: z.array(z.enum([
2106
+ schema: z.array(toEnum([
1966
2107
  "IN_TRANSIT",
1967
2108
  "IN_TRANSIT_TO_FIELD",
1968
2109
  "IN_TRANSIT_TO_INVENTORY",
@@ -2014,7 +2155,7 @@ const surfaceCommands = [
2014
2155
  queryName: "scrapped",
2015
2156
  description: "Whether to list unscrapped assets, scrapped assets, or all of them",
2016
2157
  valueName: "value",
2017
- schema: z.enum([
2158
+ schema: toEnum([
2018
2159
  "all",
2019
2160
  "false",
2020
2161
  "true"
@@ -2053,7 +2194,7 @@ const surfaceCommands = [
2053
2194
  jsonPath: ["depreciationModel"],
2054
2195
  description: "The method one unit is depreciated by, or null to clear it",
2055
2196
  valueName: "value",
2056
- schema: z.enum([
2197
+ schema: toEnum([
2057
2198
  "DOUBLE_DECLINING",
2058
2199
  "STRAIGHT_LINE",
2059
2200
  "SUM_YEAR",
@@ -2098,7 +2239,7 @@ const surfaceCommands = [
2098
2239
  description: "The asset's starting functional status, FUNCTIONAL when absent, which cannot be SCRAPPED",
2099
2240
  valueName: "value",
2100
2241
  nullable: true,
2101
- schema: z.enum([
2242
+ schema: toEnum([
2102
2243
  "FUNCTIONAL",
2103
2244
  "NEEDS_REVIEW",
2104
2245
  "NON-FUNCTIONAL",
@@ -2197,7 +2338,7 @@ const surfaceCommands = [
2197
2338
  description: "The number of months one unit is depreciated over, or null to clear it",
2198
2339
  valueName: "number",
2199
2340
  nullable: true,
2200
- schema: z.coerce.number()
2341
+ schema: z.coerce.number().int()
2201
2342
  }
2202
2343
  ]
2203
2344
  }),
@@ -2311,7 +2452,7 @@ const surfaceCommands = [
2311
2452
  description: "The asset's new functional status, which cannot be SCRAPPED because scrapping has its own endpoint",
2312
2453
  valueName: "value",
2313
2454
  nullable: true,
2314
- schema: z.enum([
2455
+ schema: toEnum([
2315
2456
  "FUNCTIONAL",
2316
2457
  "NEEDS_REVIEW",
2317
2458
  "NON-FUNCTIONAL",
@@ -2393,7 +2534,7 @@ const surfaceCommands = [
2393
2534
  jsonPath: ["depreciationModel"],
2394
2535
  description: "The method one unit is depreciated by, or null to clear it",
2395
2536
  valueName: "value",
2396
- schema: z.enum([
2537
+ schema: toEnum([
2397
2538
  "DOUBLE_DECLINING",
2398
2539
  "STRAIGHT_LINE",
2399
2540
  "SUM_YEAR",
@@ -2493,7 +2634,7 @@ const surfaceCommands = [
2493
2634
  description: "The number of months one unit is depreciated over, or null to clear it",
2494
2635
  valueName: "number",
2495
2636
  nullable: true,
2496
- schema: z.coerce.number()
2637
+ schema: z.coerce.number().int()
2497
2638
  }
2498
2639
  ]
2499
2640
  }), {
@@ -2549,7 +2690,7 @@ const surfaceCommands = [
2549
2690
  description: "Whether the adjustment adds to the asset's cost basis or writes it down",
2550
2691
  valueName: "value",
2551
2692
  required: true,
2552
- schema: z.enum(["CAPITALIZATION", "IMPAIRMENT"])
2693
+ schema: toEnum(["CAPITALIZATION", "IMPAIRMENT"])
2553
2694
  },
2554
2695
  {
2555
2696
  name: "amount",
@@ -2581,7 +2722,7 @@ const surfaceCommands = [
2581
2722
  description: "Why the adjustment was made, which must be one its adjustment type allows",
2582
2723
  valueName: "value",
2583
2724
  required: true,
2584
- schema: z.enum([
2725
+ schema: toEnum([
2585
2726
  "ADDITION",
2586
2727
  "BETTERMENT",
2587
2728
  "DAMAGE",
@@ -3044,7 +3185,7 @@ const surfaceCommands = [
3044
3185
  description: "Why the useful life was revised",
3045
3186
  valueName: "value",
3046
3187
  required: true,
3047
- schema: z.enum([
3188
+ schema: toEnum([
3048
3189
  "CHANGE_IN_USE",
3049
3190
  "DAMAGE",
3050
3191
  "OBSOLESCENCE",
@@ -3060,7 +3201,7 @@ const surfaceCommands = [
3060
3201
  description: "The asset's revised useful life in months",
3061
3202
  valueName: "number",
3062
3203
  required: true,
3063
- schema: z.coerce.number()
3204
+ schema: z.coerce.number().int()
3064
3205
  }
3065
3206
  ]
3066
3207
  })]
@@ -3087,14 +3228,14 @@ const surfaceCommands = [
3087
3228
  queryName: "page",
3088
3229
  description: "The page to return, starting at 1",
3089
3230
  valueName: "number",
3090
- schema: z.coerce.number()
3231
+ schema: z.coerce.number().int()
3091
3232
  },
3092
3233
  {
3093
3234
  name: "limit",
3094
3235
  queryName: "limit",
3095
3236
  description: "The number of records per page, from 1 to 100",
3096
3237
  valueName: "number",
3097
- schema: z.coerce.number()
3238
+ schema: z.coerce.number().int().min(1).max(100)
3098
3239
  },
3099
3240
  {
3100
3241
  name: "sort-by",
@@ -3108,14 +3249,14 @@ const surfaceCommands = [
3108
3249
  queryName: "sortOrder",
3109
3250
  description: "The sort direction",
3110
3251
  valueName: "value",
3111
- schema: z.enum(["ASC", "DESC"])
3252
+ schema: toEnum(["ASC", "DESC"])
3112
3253
  },
3113
3254
  {
3114
3255
  name: "archived",
3115
3256
  queryName: "archived",
3116
3257
  description: "Whether to return unarchived records, archived records, or all of them",
3117
3258
  valueName: "value",
3118
- schema: z.enum([
3259
+ schema: toEnum([
3119
3260
  "all",
3120
3261
  "false",
3121
3262
  "true"
@@ -3392,8 +3533,7 @@ const surfaceCommands = [
3392
3533
  name: "is-public",
3393
3534
  jsonPath: ["isPublic"],
3394
3535
  description: "Whether any organization's API key may download the file, which is false unless sent as true",
3395
- valueName: "value",
3396
- schema: z.string()
3536
+ schema: z.boolean()
3397
3537
  }
3398
3538
  ]
3399
3539
  }
@@ -3437,7 +3577,7 @@ const surfaceCommands = [
3437
3577
  queryName: "type",
3438
3578
  description: "The item type to list, SERVICE, DEVICE, or BULK, or absent for every type",
3439
3579
  valueName: "value",
3440
- schema: z.enum([
3580
+ schema: toEnum([
3441
3581
  "BULK",
3442
3582
  "DEVICE",
3443
3583
  "SERVICE"
@@ -3455,21 +3595,21 @@ const surfaceCommands = [
3455
3595
  queryName: "page",
3456
3596
  description: "The page to return, starting at 1",
3457
3597
  valueName: "number",
3458
- schema: z.coerce.number()
3598
+ schema: z.coerce.number().int()
3459
3599
  },
3460
3600
  {
3461
3601
  name: "limit",
3462
3602
  queryName: "limit",
3463
3603
  description: "The number of records per page, from 1 to 100",
3464
3604
  valueName: "number",
3465
- schema: z.coerce.number()
3605
+ schema: z.coerce.number().int().min(1).max(100)
3466
3606
  },
3467
3607
  {
3468
3608
  name: "sort-by",
3469
3609
  queryName: "sortBy",
3470
3610
  description: "The field to sort by",
3471
3611
  valueName: "value",
3472
- schema: z.enum([
3612
+ schema: toEnum([
3473
3613
  "name",
3474
3614
  "sku",
3475
3615
  "type",
@@ -3481,14 +3621,14 @@ const surfaceCommands = [
3481
3621
  queryName: "sortOrder",
3482
3622
  description: "The sort direction",
3483
3623
  valueName: "value",
3484
- schema: z.enum(["ASC", "DESC"])
3624
+ schema: toEnum(["ASC", "DESC"])
3485
3625
  },
3486
3626
  {
3487
3627
  name: "archived",
3488
3628
  queryName: "archived",
3489
3629
  description: "Whether to return unarchived records, archived records, or all of them",
3490
3630
  valueName: "value",
3491
- schema: z.enum([
3631
+ schema: toEnum([
3492
3632
  "all",
3493
3633
  "false",
3494
3634
  "true"
@@ -3549,7 +3689,7 @@ const surfaceCommands = [
3549
3689
  description: "SERVICE for a non-physical item, DEVICE for a physical item tracked by serial number, or BULK for a part tracked by quantity",
3550
3690
  valueName: "value",
3551
3691
  required: true,
3552
- schema: z.enum([
3692
+ schema: toEnum([
3553
3693
  "BULK",
3554
3694
  "DEVICE",
3555
3695
  "SERVICE"
@@ -3560,7 +3700,7 @@ const surfaceCommands = [
3560
3700
  jsonPath: ["unitOfMeasure"],
3561
3701
  description: "The unit a BULK item's quantities are counted in, which SERVICE and DEVICE items ignore",
3562
3702
  valueName: "value",
3563
- schema: z.enum([
3703
+ schema: toEnum([
3564
3704
  "BG",
3565
3705
  "BO",
3566
3706
  "BX",
@@ -3706,7 +3846,7 @@ const surfaceCommands = [
3706
3846
  description: "The type to convert the item to, when the item's assets and inventory history allow the conversion",
3707
3847
  valueName: "value",
3708
3848
  nullable: true,
3709
- schema: z.enum([
3849
+ schema: toEnum([
3710
3850
  "BULK",
3711
3851
  "DEVICE",
3712
3852
  "SERVICE"
@@ -3754,7 +3894,7 @@ const surfaceCommands = [
3754
3894
  jsonPath: ["depreciationModel"],
3755
3895
  description: "The method one unit is depreciated by, or null to clear it",
3756
3896
  valueName: "value",
3757
- schema: z.enum([
3897
+ schema: toEnum([
3758
3898
  "DOUBLE_DECLINING",
3759
3899
  "STRAIGHT_LINE",
3760
3900
  "SUM_YEAR",
@@ -3839,7 +3979,7 @@ const surfaceCommands = [
3839
3979
  description: "The number of months one unit is depreciated over, or null to clear it",
3840
3980
  valueName: "number",
3841
3981
  nullable: true,
3842
- schema: z.coerce.number()
3982
+ schema: z.coerce.number().int()
3843
3983
  }
3844
3984
  ]
3845
3985
  })]
@@ -3870,7 +4010,7 @@ const surfaceCommands = [
3870
4010
  description: "The kind of value the field holds",
3871
4011
  valueName: "value",
3872
4012
  required: true,
3873
- schema: z.enum([
4013
+ schema: toEnum([
3874
4014
  "BOOLEAN",
3875
4015
  "DATE",
3876
4016
  "DATE_TIME",
@@ -3896,7 +4036,7 @@ const surfaceCommands = [
3896
4036
  valueName: "number",
3897
4037
  required: true,
3898
4038
  nullable: true,
3899
- schema: z.coerce.number()
4039
+ schema: z.coerce.number().int()
3900
4040
  },
3901
4041
  {
3902
4042
  name: "section",
@@ -3905,7 +4045,7 @@ const surfaceCommands = [
3905
4045
  valueName: "number",
3906
4046
  required: true,
3907
4047
  nullable: true,
3908
- schema: z.coerce.number()
4048
+ schema: z.coerce.number().int()
3909
4049
  }
3910
4050
  ]
3911
4051
  }),
@@ -3976,21 +4116,21 @@ const surfaceCommands = [
3976
4116
  queryName: "page",
3977
4117
  description: "The page to return, starting at 1",
3978
4118
  valueName: "number",
3979
- schema: z.coerce.number()
4119
+ schema: z.coerce.number().int()
3980
4120
  },
3981
4121
  {
3982
4122
  name: "limit",
3983
4123
  queryName: "limit",
3984
4124
  description: "The number of records per page, from 1 to 100",
3985
4125
  valueName: "number",
3986
- schema: z.coerce.number()
4126
+ schema: z.coerce.number().int().min(1).max(100)
3987
4127
  },
3988
4128
  {
3989
4129
  name: "sort-by",
3990
4130
  queryName: "sortBy",
3991
4131
  description: "The field to sort by",
3992
4132
  valueName: "value",
3993
- schema: z.enum([
4133
+ schema: toEnum([
3994
4134
  "name",
3995
4135
  "company",
3996
4136
  "assetCount"
@@ -4001,14 +4141,14 @@ const surfaceCommands = [
4001
4141
  queryName: "sortOrder",
4002
4142
  description: "The sort direction",
4003
4143
  valueName: "value",
4004
- schema: z.enum(["ASC", "DESC"])
4144
+ schema: toEnum(["ASC", "DESC"])
4005
4145
  },
4006
4146
  {
4007
4147
  name: "archived",
4008
4148
  queryName: "archived",
4009
4149
  description: "Whether to return unarchived records, archived records, or all of them",
4010
4150
  valueName: "value",
4011
- schema: z.enum([
4151
+ schema: toEnum([
4012
4152
  "all",
4013
4153
  "false",
4014
4154
  "true"
@@ -4019,7 +4159,7 @@ const surfaceCommands = [
4019
4159
  queryName: "isTransient",
4020
4160
  description: "Whether to return permanent locations (false), transient locations (true), or both (all)",
4021
4161
  valueName: "value",
4022
- schema: z.enum([
4162
+ schema: toEnum([
4023
4163
  "all",
4024
4164
  "false",
4025
4165
  "true"
@@ -4184,7 +4324,7 @@ const surfaceCommands = [
4184
4324
  jsonPath: ["type"],
4185
4325
  description: "SITE for a site, or ZONE for a zone within a site",
4186
4326
  valueName: "value",
4187
- schema: z.enum([
4327
+ schema: toEnum([
4188
4328
  "SITE",
4189
4329
  "UNKNOWN",
4190
4330
  "ZONE"
@@ -4340,7 +4480,7 @@ const surfaceCommands = [
4340
4480
  jsonPath: ["type"],
4341
4481
  description: "SITE for a site, or ZONE for a zone within a site",
4342
4482
  valueName: "value",
4343
- schema: z.enum([
4483
+ schema: toEnum([
4344
4484
  "SITE",
4345
4485
  "UNKNOWN",
4346
4486
  "ZONE"
@@ -4370,7 +4510,7 @@ const surfaceCommands = [
4370
4510
  queryName: "archived",
4371
4511
  description: "Whether to return unarchived zones, archived zones, or all of them",
4372
4512
  valueName: "value",
4373
- schema: z.enum([
4513
+ schema: toEnum([
4374
4514
  "all",
4375
4515
  "false",
4376
4516
  "true"
@@ -4465,32 +4605,6 @@ const surfaceCommands = [
4465
4605
  }
4466
4606
  ];
4467
4607
  //#endregion
4468
- //#region src/auth/jwt.ts
4469
- /**
4470
- * toClaims reads an access token's payload for display. Nothing here verifies the
4471
- * signature, because the API is what decides whether a token is good.
4472
- */
4473
- function toClaims(token) {
4474
- const payload = token.split(".")[1];
4475
- if (!payload) return {};
4476
- try {
4477
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
4478
- return {
4479
- expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
4480
- issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
4481
- scopes: toScopes(decoded),
4482
- subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
4483
- };
4484
- } catch {
4485
- return {};
4486
- }
4487
- }
4488
- /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
4489
- function toScopes(decoded) {
4490
- if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
4491
- return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
4492
- }
4493
- //#endregion
4494
4608
  //#region src/system/host.ts
4495
4609
  const BYTES_PER_GB = 1024 ** 3;
4496
4610
  /** toDistribution reads the name a Linux distribution gives itself. */
@@ -4598,27 +4712,24 @@ function toEnvironmentReport() {
4598
4712
  apiVersion: API_VERSION,
4599
4713
  node: process.version,
4600
4714
  platform: process.platform,
4601
- interactive: Boolean(process.stdin.isTTY)
4715
+ interactive: process.stdin.isTTY
4602
4716
  };
4603
4717
  }
4604
4718
  /** toConfigurationReport names every setting, its source, and never a secret's value. */
4605
4719
  function toConfigurationReport(resolved) {
4606
- const entries = Object.entries(resolved.settings).map(([key, value]) => {
4720
+ const report = { configFile: CONFIG_FILE };
4721
+ for (const [key, value] of Object.entries(resolved.settings)) {
4607
4722
  const from = resolved.sources[key];
4608
- if (key === "apiKey") return [key, {
4723
+ report[key] = key === "apiKey" ? {
4609
4724
  set: value !== void 0,
4610
4725
  fingerprint: toFingerprint(value),
4611
4726
  from
4612
- }];
4613
- return [key, {
4727
+ } : {
4614
4728
  value: value ?? null,
4615
4729
  from
4616
- }];
4617
- });
4618
- return {
4619
- ...Object.fromEntries(entries),
4620
- configFile: CONFIG_FILE
4621
- };
4730
+ };
4731
+ }
4732
+ return report;
4622
4733
  }
4623
4734
  function toCredentialReport(apiKey, stored) {
4624
4735
  if (apiKey) return {
@@ -4714,8 +4825,7 @@ function toFlattened(value) {
4714
4825
  if (value === null || typeof value !== "object" || Array.isArray(value)) return;
4715
4826
  const holder = value;
4716
4827
  if (holder.from === void 0) return;
4717
- const shown = holder.value ?? (holder.set ? holder.fingerprint ?? "set" : "not set");
4718
- return `${String(shown)} (${holder.from})`;
4828
+ return `${toText(holder.value ?? (holder.set ? holder.fingerprint ?? "set" : "not set"))} (${holder.from})`;
4719
4829
  }
4720
4830
  /** toLines lays the report out for a person, one indented line per value. */
4721
4831
  function toLines(report, depth = 0) {
@@ -4817,7 +4927,7 @@ async function toExitCode(command, args, flags) {
4817
4927
  }
4818
4928
  function toArgumentList(value) {
4819
4929
  if (Array.isArray(value)) return value.map(String);
4820
- return value === void 0 ? [] : [String(value)];
4930
+ return value === void 0 ? [] : [toText(value)];
4821
4931
  }
4822
4932
  function collect(value, previous) {
4823
4933
  return [...previous ?? [], value];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.0.2-dev.14",
3
+ "version": "0.0.2-dev.16",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",
@@ -37,7 +37,12 @@
37
37
  "check-types": "tsc --noEmit",
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
- "prepublishOnly": "npm run build"
40
+ "prepublishOnly": "npm run build",
41
+ "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
42
+ "lint:fix": "eslint . --fix",
43
+ "prettier:check": "prettier --check .",
44
+ "prettier:write": "prettier --write .",
45
+ "test:coverage": "vitest run --coverage"
41
46
  },
42
47
  "dependencies": {
43
48
  "@napi-rs/keyring": "^2.1.0",
@@ -45,9 +50,16 @@
45
50
  "zod": "^4.6.5"
46
51
  },
47
52
  "devDependencies": {
53
+ "@eslint/js": "^10.0.1",
48
54
  "@types/node": "^22.15.0",
55
+ "@vitest/coverage-v8": "^5.0.1",
56
+ "eslint": "^10.11.0",
57
+ "eslint-plugin-unused-imports": "^4.4.1",
58
+ "globals": "^17.12.0",
59
+ "prettier": "^3.9.9",
49
60
  "tsdown": "^0.23.0",
50
61
  "typescript": "^5.9.0",
62
+ "typescript-eslint": "^8.70.1",
51
63
  "unrun": "^0.3.1",
52
64
  "vitest": "^5.0.1",
53
65
  "yaml": "^2.9.1"