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

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 (2) hide show
  1. package/dist/cli.js +201 -105
  2. package/package.json +2 -1
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. */
@@ -252,6 +252,32 @@ function toSummary(command) {
252
252
  };
253
253
  }
254
254
  //#endregion
255
+ //#region src/auth/jwt.ts
256
+ /**
257
+ * toClaims reads an access token's payload for display. Nothing here verifies the
258
+ * signature, because the API is what decides whether a token is good.
259
+ */
260
+ function toClaims(token) {
261
+ const payload = token.split(".")[1];
262
+ if (!payload) return {};
263
+ try {
264
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
265
+ return {
266
+ expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
267
+ issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
268
+ scopes: toScopes(decoded),
269
+ subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
270
+ };
271
+ } catch {
272
+ return {};
273
+ }
274
+ }
275
+ /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
276
+ function toScopes(decoded) {
277
+ if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
278
+ return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
279
+ }
280
+ //#endregion
255
281
  //#region src/auth/metadata.ts
256
282
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
257
283
  const AuthorizationServerMetadata = z.looseObject({
@@ -388,30 +414,44 @@ function keep(issuer, refreshToken, expiresAt) {
388
414
  const keyring = toKeyring(issuer);
389
415
  if (keyring) try {
390
416
  keyring.setPassword(JSON.stringify(record));
417
+ forgetFile(issuer);
391
418
  return "keyring";
392
419
  } catch {}
393
420
  writeFile({
394
421
  ...readFile(),
395
422
  [issuer]: record
396
423
  });
424
+ forgetKeyring(issuer);
397
425
  return "file";
398
426
  }
399
- /** toCredential reads what is held for an issuer, and where it was held. */
427
+ /**
428
+ * toCredential reads what is held for an issuer, and where it was held. Both backends are
429
+ * read, because a host that lost its keyring for a while wrote to the file instead, and the
430
+ * newer of the two is the one the server has not spent.
431
+ */
400
432
  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 {}
433
+ const fromKeyring = toKeyringCredential(issuer);
409
434
  const held = readFile()[issuer];
410
- return held === void 0 ? void 0 : {
435
+ const fromFile = held === void 0 ? void 0 : {
411
436
  ...held,
412
437
  backend: "file",
413
438
  path: toCredentialPath()
414
439
  };
440
+ if (!fromKeyring || !fromFile) return fromKeyring ?? fromFile;
441
+ return (fromFile.renewedAt ?? "") > (fromKeyring.renewedAt ?? "") ? fromFile : fromKeyring;
442
+ }
443
+ function toKeyringCredential(issuer) {
444
+ const keyring = toKeyring(issuer);
445
+ if (!keyring) return;
446
+ try {
447
+ const held = keyring.getPassword();
448
+ return held ? {
449
+ ...toRecord(held),
450
+ backend: "keyring"
451
+ } : void 0;
452
+ } catch {
453
+ return;
454
+ }
415
455
  }
416
456
  /** toRecord reads a stored entry, which older versions wrote as the bare token. */
417
457
  function toRecord(held) {
@@ -424,10 +464,17 @@ function toRecord(held) {
424
464
  }
425
465
  /** forget removes whatever is held for an issuer, in both places. */
426
466
  function forget(issuer) {
467
+ forgetKeyring(issuer);
468
+ forgetFile(issuer);
469
+ }
470
+ function forgetKeyring(issuer) {
427
471
  const keyring = toKeyring(issuer);
428
- if (keyring) try {
472
+ if (!keyring) return;
473
+ try {
429
474
  keyring.deletePassword();
430
475
  } catch {}
476
+ }
477
+ function forgetFile(issuer) {
431
478
  const held = readFile();
432
479
  if (held[issuer] === void 0) return;
433
480
  delete held[issuer];
@@ -474,6 +521,8 @@ const STALE_MS = 3e4;
474
521
  const WAIT_MS$1 = 1e4;
475
522
  const RETRY_MS = 25;
476
523
  const DIRECTORY_MODE = 448;
524
+ /** What this process wrote into the lock, which is how it knows the lock is still its own. */
525
+ let heldBy;
477
526
  /** toLockPath names the lock every process coordinates credential writes through. */
478
527
  function toLockPath() {
479
528
  return join(dirname(toCredentialPath()), "credentials.lock");
@@ -485,17 +534,23 @@ function tryAcquire() {
485
534
  recursive: true,
486
535
  mode: DIRECTORY_MODE
487
536
  });
537
+ const mark = `${process.pid}:${randomUUID()}`;
488
538
  try {
489
539
  const handle = openSync(path, "wx");
490
- writeSync(handle, String(process.pid));
540
+ writeSync(handle, mark);
491
541
  closeSync(handle);
542
+ heldBy = mark;
492
543
  return true;
493
544
  } catch {
494
- return isStale(path) ? steal(path) : false;
545
+ return isStale(path) ? steal(path, mark) : false;
495
546
  }
496
547
  }
497
- /** release lets the next process in. */
548
+ /** release lets the next process in, and only ever removes this process's own lock. */
498
549
  function release$1() {
550
+ const mark = heldBy;
551
+ if (mark === void 0) return;
552
+ heldBy = void 0;
553
+ if (toMark(toLockPath()) !== mark) return;
499
554
  rmSync(toLockPath(), { force: true });
500
555
  }
501
556
  /**
@@ -522,9 +577,29 @@ function isStale(path) {
522
577
  return true;
523
578
  }
524
579
  }
525
- function steal(path) {
580
+ /**
581
+ * steal takes over a lock whose holder is gone. Two processes can reach this at once, so
582
+ * the winner is whichever mark survives in the file, not whichever removed it.
583
+ */
584
+ function steal(path, mark) {
526
585
  rmSync(path, { force: true });
527
- return tryAcquire();
586
+ try {
587
+ const handle = openSync(path, "wx");
588
+ writeSync(handle, mark);
589
+ closeSync(handle);
590
+ } catch {
591
+ return false;
592
+ }
593
+ if (toMark(path) !== mark) return false;
594
+ heldBy = mark;
595
+ return true;
596
+ }
597
+ function toMark(path) {
598
+ try {
599
+ return readFileSync(path, "utf8");
600
+ } catch {
601
+ return;
602
+ }
528
603
  }
529
604
  //#endregion
530
605
  //#region src/auth/session.ts
@@ -567,9 +642,9 @@ async function toAccessToken(settings, refreshToken) {
567
642
  return await withLock(async () => {
568
643
  const latest = toCredential(settings.issuerUrl)?.refreshToken ?? refreshToken;
569
644
  try {
570
- const tokens = await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest);
645
+ const tokens = toDated(await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest));
571
646
  held.set(settings.issuerUrl, tokens);
572
- if (tokens.refreshToken && tokens.refreshToken !== latest) keep(settings.issuerUrl, tokens.refreshToken, tokens.refreshExpiresAt);
647
+ store(settings.issuerUrl, tokens, latest);
573
648
  return tokens;
574
649
  } catch (error) {
575
650
  if (error instanceof GrantFailure && isDead(error.code)) {
@@ -580,14 +655,37 @@ async function toAccessToken(settings, refreshToken) {
580
655
  }
581
656
  });
582
657
  }
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
- ]);
658
+ /**
659
+ * invalid_grant is the only refusal that says anything about the refresh token itself.
660
+ * invalid_client and unauthorized_client describe the client registration, which is server
661
+ * configuration and a setting a person can mistype, so a good credential survives them.
662
+ */
589
663
  function isDead(code) {
590
- return DEAD_GRANT_CODES.has(code);
664
+ return code === "invalid_grant";
665
+ }
666
+ /**
667
+ * toDated fills in when an access token expires. A token endpoint that states no expires_in
668
+ * would otherwise have this process refresh on every command, and every refresh spends a
669
+ * generation of the token family.
670
+ */
671
+ function toDated(tokens) {
672
+ if (tokens.expiresAt !== void 0) return tokens;
673
+ return {
674
+ ...tokens,
675
+ expiresAt: toClaims(tokens.accessToken).expiresAt
676
+ };
677
+ }
678
+ /**
679
+ * store writes what a rotation issued. The old token is spent either way, so failing to
680
+ * write the new one is worth saying out loud rather than failing a command that succeeded.
681
+ */
682
+ function store(issuer, tokens, previous) {
683
+ if (!tokens.refreshToken || tokens.refreshToken === previous) return;
684
+ try {
685
+ keep(issuer, tokens.refreshToken, tokens.refreshExpiresAt);
686
+ } catch (error) {
687
+ 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`);
688
+ }
591
689
  }
592
690
  /** forgetHeldTokens drops the access tokens this process is holding. */
593
691
  function forgetHeldTokens() {
@@ -1197,6 +1295,7 @@ function toPrompt(url) {
1197
1295
  fail = reject;
1198
1296
  });
1199
1297
  const input = process.stdin;
1298
+ pasted.catch(() => {});
1200
1299
  if (!input.isTTY) return {
1201
1300
  pasted,
1202
1301
  close: () => {}
@@ -1312,9 +1411,13 @@ async function runLogin(input) {
1312
1411
  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
1412
  const prompt = toPrompt(url);
1314
1413
  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();
1414
+ let callback;
1415
+ try {
1416
+ callback = await Promise.race([listener.callback, prompt.pasted]);
1417
+ } finally {
1418
+ listener.close();
1419
+ prompt.close();
1420
+ }
1318
1421
  process.stderr.write(callback.error ? "\n" : "\nApproved, finishing the sign in\n");
1319
1422
  if (callback.error) {
1320
1423
  writeFailure(`sign in was refused: ${callback.error}${callback.errorDescription ? `, ${callback.errorDescription}` : ""}`, input.isJSON);
@@ -1360,7 +1463,15 @@ async function runDeviceLogin(input, metadata, scope) {
1360
1463
  opened ? "Opened your browser there. Waiting for approval\n" : "Open that page on any machine, and enter the code. Waiting for approval\n"
1361
1464
  ].join("\n"));
1362
1465
  const prompt = toPrompt(url);
1363
- return await toSignedIn(input, metadata, await toTokensFromDevice(metadata.token_endpoint, settings.clientId, device).finally(() => prompt.close()));
1466
+ try {
1467
+ return await toSignedIn(input, metadata, await Promise.race([toTokensFromDevice(metadata.token_endpoint, settings.clientId, device), prompt.pasted.then(toCancelled)]));
1468
+ } finally {
1469
+ prompt.close();
1470
+ }
1471
+ }
1472
+ /** toCancelled ends a device sign in that the keyboard interrupted. */
1473
+ function toCancelled() {
1474
+ throw new Error("sign in was cancelled");
1364
1475
  }
1365
1476
  /** toSignedIn stores what a sign in issued, whichever flow issued it. */
1366
1477
  async function toSignedIn(input, metadata, tokens) {
@@ -1635,6 +1746,13 @@ async function runMcp(input) {
1635
1746
  }
1636
1747
  //#endregion
1637
1748
  //#region src/command/operation.ts
1749
+ /**
1750
+ * toEnum takes a value in any case, as the API does, and answers the spelling the document
1751
+ * lists, which is what a response always uses.
1752
+ */
1753
+ function toEnum(values) {
1754
+ return z.string().transform((value) => values.find((allowed) => allowed.toLowerCase() === value.toLowerCase()) ?? value).pipe(z.enum(values));
1755
+ }
1638
1756
  const UNSET_FLAG = {
1639
1757
  name: "unset",
1640
1758
  description: "A field to clear, named as its flag is, repeatable",
@@ -1741,7 +1859,9 @@ function toQuery(operation, flags) {
1741
1859
  for (const flag of operation.queryFlags) {
1742
1860
  const value = flags[toOptionKey(flag.name)];
1743
1861
  if (value === void 0) continue;
1744
- for (const entry of Array.isArray(value) ? value : [value]) query.append(flag.queryName, String(entry));
1862
+ const parsed = flag.schema.safeParse(value);
1863
+ const carried = parsed.success ? parsed.data : value;
1864
+ for (const entry of Array.isArray(carried) ? carried : [carried]) query.append(flag.queryName, String(entry));
1745
1865
  }
1746
1866
  return query;
1747
1867
  }
@@ -1761,7 +1881,10 @@ async function toForm(upload, flags) {
1761
1881
  }
1762
1882
  for (const field of upload.fields) {
1763
1883
  const value = flags[toOptionKey(field.name)];
1764
- if (value !== void 0) form.append(field.jsonPath[0] ?? field.name, String(value));
1884
+ if (value !== void 0) {
1885
+ const parsed = field.schema.safeParse(value);
1886
+ form.append(field.jsonPath[0] ?? field.name, String(parsed.success ? parsed.data : value));
1887
+ }
1765
1888
  }
1766
1889
  return form;
1767
1890
  }
@@ -1847,21 +1970,21 @@ const surfaceCommands = [
1847
1970
  queryName: "page",
1848
1971
  description: "The page to return, starting at 1",
1849
1972
  valueName: "number",
1850
- schema: z.coerce.number()
1973
+ schema: z.coerce.number().int()
1851
1974
  },
1852
1975
  {
1853
1976
  name: "limit",
1854
1977
  queryName: "limit",
1855
1978
  description: "The number of records per page, from 1 to 100",
1856
1979
  valueName: "number",
1857
- schema: z.coerce.number()
1980
+ schema: z.coerce.number().int().min(1).max(100)
1858
1981
  },
1859
1982
  {
1860
1983
  name: "sort-by",
1861
1984
  queryName: "sortBy",
1862
1985
  description: "The field to sort by",
1863
1986
  valueName: "value",
1864
- schema: z.enum([
1987
+ schema: toEnum([
1865
1988
  "serial",
1866
1989
  "project",
1867
1990
  "item",
@@ -1875,14 +1998,14 @@ const surfaceCommands = [
1875
1998
  queryName: "sortOrder",
1876
1999
  description: "The sort direction",
1877
2000
  valueName: "value",
1878
- schema: z.enum(["ASC", "DESC"])
2001
+ schema: toEnum(["ASC", "DESC"])
1879
2002
  },
1880
2003
  {
1881
2004
  name: "archived",
1882
2005
  queryName: "archived",
1883
2006
  description: "Whether to return unarchived records, archived records, or all of them",
1884
2007
  valueName: "value",
1885
- schema: z.enum([
2008
+ schema: toEnum([
1886
2009
  "all",
1887
2010
  "false",
1888
2011
  "true"
@@ -1949,7 +2072,7 @@ const surfaceCommands = [
1949
2072
  description: "The functional statuses to list, where SCRAPPED lists scrapped assets",
1950
2073
  valueName: "value",
1951
2074
  repeatable: true,
1952
- schema: z.array(z.enum([
2075
+ schema: z.array(toEnum([
1953
2076
  "FUNCTIONAL",
1954
2077
  "NEEDS_REVIEW",
1955
2078
  "NON-FUNCTIONAL",
@@ -1962,7 +2085,7 @@ const surfaceCommands = [
1962
2085
  description: "The transit statuses to list",
1963
2086
  valueName: "value",
1964
2087
  repeatable: true,
1965
- schema: z.array(z.enum([
2088
+ schema: z.array(toEnum([
1966
2089
  "IN_TRANSIT",
1967
2090
  "IN_TRANSIT_TO_FIELD",
1968
2091
  "IN_TRANSIT_TO_INVENTORY",
@@ -2014,7 +2137,7 @@ const surfaceCommands = [
2014
2137
  queryName: "scrapped",
2015
2138
  description: "Whether to list unscrapped assets, scrapped assets, or all of them",
2016
2139
  valueName: "value",
2017
- schema: z.enum([
2140
+ schema: toEnum([
2018
2141
  "all",
2019
2142
  "false",
2020
2143
  "true"
@@ -2053,7 +2176,7 @@ const surfaceCommands = [
2053
2176
  jsonPath: ["depreciationModel"],
2054
2177
  description: "The method one unit is depreciated by, or null to clear it",
2055
2178
  valueName: "value",
2056
- schema: z.enum([
2179
+ schema: toEnum([
2057
2180
  "DOUBLE_DECLINING",
2058
2181
  "STRAIGHT_LINE",
2059
2182
  "SUM_YEAR",
@@ -2098,7 +2221,7 @@ const surfaceCommands = [
2098
2221
  description: "The asset's starting functional status, FUNCTIONAL when absent, which cannot be SCRAPPED",
2099
2222
  valueName: "value",
2100
2223
  nullable: true,
2101
- schema: z.enum([
2224
+ schema: toEnum([
2102
2225
  "FUNCTIONAL",
2103
2226
  "NEEDS_REVIEW",
2104
2227
  "NON-FUNCTIONAL",
@@ -2197,7 +2320,7 @@ const surfaceCommands = [
2197
2320
  description: "The number of months one unit is depreciated over, or null to clear it",
2198
2321
  valueName: "number",
2199
2322
  nullable: true,
2200
- schema: z.coerce.number()
2323
+ schema: z.coerce.number().int()
2201
2324
  }
2202
2325
  ]
2203
2326
  }),
@@ -2311,7 +2434,7 @@ const surfaceCommands = [
2311
2434
  description: "The asset's new functional status, which cannot be SCRAPPED because scrapping has its own endpoint",
2312
2435
  valueName: "value",
2313
2436
  nullable: true,
2314
- schema: z.enum([
2437
+ schema: toEnum([
2315
2438
  "FUNCTIONAL",
2316
2439
  "NEEDS_REVIEW",
2317
2440
  "NON-FUNCTIONAL",
@@ -2393,7 +2516,7 @@ const surfaceCommands = [
2393
2516
  jsonPath: ["depreciationModel"],
2394
2517
  description: "The method one unit is depreciated by, or null to clear it",
2395
2518
  valueName: "value",
2396
- schema: z.enum([
2519
+ schema: toEnum([
2397
2520
  "DOUBLE_DECLINING",
2398
2521
  "STRAIGHT_LINE",
2399
2522
  "SUM_YEAR",
@@ -2493,7 +2616,7 @@ const surfaceCommands = [
2493
2616
  description: "The number of months one unit is depreciated over, or null to clear it",
2494
2617
  valueName: "number",
2495
2618
  nullable: true,
2496
- schema: z.coerce.number()
2619
+ schema: z.coerce.number().int()
2497
2620
  }
2498
2621
  ]
2499
2622
  }), {
@@ -2549,7 +2672,7 @@ const surfaceCommands = [
2549
2672
  description: "Whether the adjustment adds to the asset's cost basis or writes it down",
2550
2673
  valueName: "value",
2551
2674
  required: true,
2552
- schema: z.enum(["CAPITALIZATION", "IMPAIRMENT"])
2675
+ schema: toEnum(["CAPITALIZATION", "IMPAIRMENT"])
2553
2676
  },
2554
2677
  {
2555
2678
  name: "amount",
@@ -2581,7 +2704,7 @@ const surfaceCommands = [
2581
2704
  description: "Why the adjustment was made, which must be one its adjustment type allows",
2582
2705
  valueName: "value",
2583
2706
  required: true,
2584
- schema: z.enum([
2707
+ schema: toEnum([
2585
2708
  "ADDITION",
2586
2709
  "BETTERMENT",
2587
2710
  "DAMAGE",
@@ -3044,7 +3167,7 @@ const surfaceCommands = [
3044
3167
  description: "Why the useful life was revised",
3045
3168
  valueName: "value",
3046
3169
  required: true,
3047
- schema: z.enum([
3170
+ schema: toEnum([
3048
3171
  "CHANGE_IN_USE",
3049
3172
  "DAMAGE",
3050
3173
  "OBSOLESCENCE",
@@ -3060,7 +3183,7 @@ const surfaceCommands = [
3060
3183
  description: "The asset's revised useful life in months",
3061
3184
  valueName: "number",
3062
3185
  required: true,
3063
- schema: z.coerce.number()
3186
+ schema: z.coerce.number().int()
3064
3187
  }
3065
3188
  ]
3066
3189
  })]
@@ -3087,14 +3210,14 @@ const surfaceCommands = [
3087
3210
  queryName: "page",
3088
3211
  description: "The page to return, starting at 1",
3089
3212
  valueName: "number",
3090
- schema: z.coerce.number()
3213
+ schema: z.coerce.number().int()
3091
3214
  },
3092
3215
  {
3093
3216
  name: "limit",
3094
3217
  queryName: "limit",
3095
3218
  description: "The number of records per page, from 1 to 100",
3096
3219
  valueName: "number",
3097
- schema: z.coerce.number()
3220
+ schema: z.coerce.number().int().min(1).max(100)
3098
3221
  },
3099
3222
  {
3100
3223
  name: "sort-by",
@@ -3108,14 +3231,14 @@ const surfaceCommands = [
3108
3231
  queryName: "sortOrder",
3109
3232
  description: "The sort direction",
3110
3233
  valueName: "value",
3111
- schema: z.enum(["ASC", "DESC"])
3234
+ schema: toEnum(["ASC", "DESC"])
3112
3235
  },
3113
3236
  {
3114
3237
  name: "archived",
3115
3238
  queryName: "archived",
3116
3239
  description: "Whether to return unarchived records, archived records, or all of them",
3117
3240
  valueName: "value",
3118
- schema: z.enum([
3241
+ schema: toEnum([
3119
3242
  "all",
3120
3243
  "false",
3121
3244
  "true"
@@ -3392,8 +3515,7 @@ const surfaceCommands = [
3392
3515
  name: "is-public",
3393
3516
  jsonPath: ["isPublic"],
3394
3517
  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()
3518
+ schema: z.boolean()
3397
3519
  }
3398
3520
  ]
3399
3521
  }
@@ -3437,7 +3559,7 @@ const surfaceCommands = [
3437
3559
  queryName: "type",
3438
3560
  description: "The item type to list, SERVICE, DEVICE, or BULK, or absent for every type",
3439
3561
  valueName: "value",
3440
- schema: z.enum([
3562
+ schema: toEnum([
3441
3563
  "BULK",
3442
3564
  "DEVICE",
3443
3565
  "SERVICE"
@@ -3455,21 +3577,21 @@ const surfaceCommands = [
3455
3577
  queryName: "page",
3456
3578
  description: "The page to return, starting at 1",
3457
3579
  valueName: "number",
3458
- schema: z.coerce.number()
3580
+ schema: z.coerce.number().int()
3459
3581
  },
3460
3582
  {
3461
3583
  name: "limit",
3462
3584
  queryName: "limit",
3463
3585
  description: "The number of records per page, from 1 to 100",
3464
3586
  valueName: "number",
3465
- schema: z.coerce.number()
3587
+ schema: z.coerce.number().int().min(1).max(100)
3466
3588
  },
3467
3589
  {
3468
3590
  name: "sort-by",
3469
3591
  queryName: "sortBy",
3470
3592
  description: "The field to sort by",
3471
3593
  valueName: "value",
3472
- schema: z.enum([
3594
+ schema: toEnum([
3473
3595
  "name",
3474
3596
  "sku",
3475
3597
  "type",
@@ -3481,14 +3603,14 @@ const surfaceCommands = [
3481
3603
  queryName: "sortOrder",
3482
3604
  description: "The sort direction",
3483
3605
  valueName: "value",
3484
- schema: z.enum(["ASC", "DESC"])
3606
+ schema: toEnum(["ASC", "DESC"])
3485
3607
  },
3486
3608
  {
3487
3609
  name: "archived",
3488
3610
  queryName: "archived",
3489
3611
  description: "Whether to return unarchived records, archived records, or all of them",
3490
3612
  valueName: "value",
3491
- schema: z.enum([
3613
+ schema: toEnum([
3492
3614
  "all",
3493
3615
  "false",
3494
3616
  "true"
@@ -3549,7 +3671,7 @@ const surfaceCommands = [
3549
3671
  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
3672
  valueName: "value",
3551
3673
  required: true,
3552
- schema: z.enum([
3674
+ schema: toEnum([
3553
3675
  "BULK",
3554
3676
  "DEVICE",
3555
3677
  "SERVICE"
@@ -3560,7 +3682,7 @@ const surfaceCommands = [
3560
3682
  jsonPath: ["unitOfMeasure"],
3561
3683
  description: "The unit a BULK item's quantities are counted in, which SERVICE and DEVICE items ignore",
3562
3684
  valueName: "value",
3563
- schema: z.enum([
3685
+ schema: toEnum([
3564
3686
  "BG",
3565
3687
  "BO",
3566
3688
  "BX",
@@ -3706,7 +3828,7 @@ const surfaceCommands = [
3706
3828
  description: "The type to convert the item to, when the item's assets and inventory history allow the conversion",
3707
3829
  valueName: "value",
3708
3830
  nullable: true,
3709
- schema: z.enum([
3831
+ schema: toEnum([
3710
3832
  "BULK",
3711
3833
  "DEVICE",
3712
3834
  "SERVICE"
@@ -3754,7 +3876,7 @@ const surfaceCommands = [
3754
3876
  jsonPath: ["depreciationModel"],
3755
3877
  description: "The method one unit is depreciated by, or null to clear it",
3756
3878
  valueName: "value",
3757
- schema: z.enum([
3879
+ schema: toEnum([
3758
3880
  "DOUBLE_DECLINING",
3759
3881
  "STRAIGHT_LINE",
3760
3882
  "SUM_YEAR",
@@ -3839,7 +3961,7 @@ const surfaceCommands = [
3839
3961
  description: "The number of months one unit is depreciated over, or null to clear it",
3840
3962
  valueName: "number",
3841
3963
  nullable: true,
3842
- schema: z.coerce.number()
3964
+ schema: z.coerce.number().int()
3843
3965
  }
3844
3966
  ]
3845
3967
  })]
@@ -3870,7 +3992,7 @@ const surfaceCommands = [
3870
3992
  description: "The kind of value the field holds",
3871
3993
  valueName: "value",
3872
3994
  required: true,
3873
- schema: z.enum([
3995
+ schema: toEnum([
3874
3996
  "BOOLEAN",
3875
3997
  "DATE",
3876
3998
  "DATE_TIME",
@@ -3896,7 +4018,7 @@ const surfaceCommands = [
3896
4018
  valueName: "number",
3897
4019
  required: true,
3898
4020
  nullable: true,
3899
- schema: z.coerce.number()
4021
+ schema: z.coerce.number().int()
3900
4022
  },
3901
4023
  {
3902
4024
  name: "section",
@@ -3905,7 +4027,7 @@ const surfaceCommands = [
3905
4027
  valueName: "number",
3906
4028
  required: true,
3907
4029
  nullable: true,
3908
- schema: z.coerce.number()
4030
+ schema: z.coerce.number().int()
3909
4031
  }
3910
4032
  ]
3911
4033
  }),
@@ -3976,21 +4098,21 @@ const surfaceCommands = [
3976
4098
  queryName: "page",
3977
4099
  description: "The page to return, starting at 1",
3978
4100
  valueName: "number",
3979
- schema: z.coerce.number()
4101
+ schema: z.coerce.number().int()
3980
4102
  },
3981
4103
  {
3982
4104
  name: "limit",
3983
4105
  queryName: "limit",
3984
4106
  description: "The number of records per page, from 1 to 100",
3985
4107
  valueName: "number",
3986
- schema: z.coerce.number()
4108
+ schema: z.coerce.number().int().min(1).max(100)
3987
4109
  },
3988
4110
  {
3989
4111
  name: "sort-by",
3990
4112
  queryName: "sortBy",
3991
4113
  description: "The field to sort by",
3992
4114
  valueName: "value",
3993
- schema: z.enum([
4115
+ schema: toEnum([
3994
4116
  "name",
3995
4117
  "company",
3996
4118
  "assetCount"
@@ -4001,14 +4123,14 @@ const surfaceCommands = [
4001
4123
  queryName: "sortOrder",
4002
4124
  description: "The sort direction",
4003
4125
  valueName: "value",
4004
- schema: z.enum(["ASC", "DESC"])
4126
+ schema: toEnum(["ASC", "DESC"])
4005
4127
  },
4006
4128
  {
4007
4129
  name: "archived",
4008
4130
  queryName: "archived",
4009
4131
  description: "Whether to return unarchived records, archived records, or all of them",
4010
4132
  valueName: "value",
4011
- schema: z.enum([
4133
+ schema: toEnum([
4012
4134
  "all",
4013
4135
  "false",
4014
4136
  "true"
@@ -4019,7 +4141,7 @@ const surfaceCommands = [
4019
4141
  queryName: "isTransient",
4020
4142
  description: "Whether to return permanent locations (false), transient locations (true), or both (all)",
4021
4143
  valueName: "value",
4022
- schema: z.enum([
4144
+ schema: toEnum([
4023
4145
  "all",
4024
4146
  "false",
4025
4147
  "true"
@@ -4184,7 +4306,7 @@ const surfaceCommands = [
4184
4306
  jsonPath: ["type"],
4185
4307
  description: "SITE for a site, or ZONE for a zone within a site",
4186
4308
  valueName: "value",
4187
- schema: z.enum([
4309
+ schema: toEnum([
4188
4310
  "SITE",
4189
4311
  "UNKNOWN",
4190
4312
  "ZONE"
@@ -4340,7 +4462,7 @@ const surfaceCommands = [
4340
4462
  jsonPath: ["type"],
4341
4463
  description: "SITE for a site, or ZONE for a zone within a site",
4342
4464
  valueName: "value",
4343
- schema: z.enum([
4465
+ schema: toEnum([
4344
4466
  "SITE",
4345
4467
  "UNKNOWN",
4346
4468
  "ZONE"
@@ -4370,7 +4492,7 @@ const surfaceCommands = [
4370
4492
  queryName: "archived",
4371
4493
  description: "Whether to return unarchived zones, archived zones, or all of them",
4372
4494
  valueName: "value",
4373
- schema: z.enum([
4495
+ schema: toEnum([
4374
4496
  "all",
4375
4497
  "false",
4376
4498
  "true"
@@ -4465,32 +4587,6 @@ const surfaceCommands = [
4465
4587
  }
4466
4588
  ];
4467
4589
  //#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
4590
  //#region src/system/host.ts
4495
4591
  const BYTES_PER_GB = 1024 ** 3;
4496
4592
  /** toDistribution reads the name a Linux distribution gives itself. */
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.15",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^22.15.0",
49
+ "@vitest/coverage-v8": "^5.0.1",
49
50
  "tsdown": "^0.23.0",
50
51
  "typescript": "^5.9.0",
51
52
  "unrun": "^0.3.1",