@hardfin/cli 0.0.2-dev.13 → 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 (3) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.js +228 -112
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -285,9 +285,9 @@ 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
286
286
  it.
287
287
 
288
- `refreshExpiresAt` is an estimate. Hardfin expires an unused refresh token after 90 days,
289
- but the token endpoint reports no expiry, so the CLI applies that rule to the date it last
290
- renewed. The flag `refreshExpiryIsEstimated` says so in the output.
288
+ `refreshExpiresAt` is what the server said when it issued the token. A sign in stored before
289
+ the server reported one falls back to Hardfin's 90-day rule, and `refreshExpiryIsEstimated`
290
+ says which of the two you are looking at.
291
291
 
292
292
  ## Local configuration
293
293
 
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({
@@ -293,6 +319,7 @@ const TokenResponse = z.looseObject({
293
319
  token_type: z.string(),
294
320
  expires_in: z.number().optional(),
295
321
  refresh_token: z.string().optional(),
322
+ refresh_token_expires_in: z.number().optional(),
296
323
  scope: z.string().optional()
297
324
  });
298
325
  /** GrantFailure is a token request the authorization server refused. */
@@ -351,6 +378,7 @@ async function toTokens(url, form) {
351
378
  return {
352
379
  accessToken: parsed.data.access_token,
353
380
  refreshToken: parsed.data.refresh_token,
381
+ refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
354
382
  scope: parsed.data.scope,
355
383
  expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
356
384
  };
@@ -375,40 +403,55 @@ function toCredentialPath() {
375
403
  * family lifetime runs from. Callers hold the credential lock, which is what makes a write
376
404
  * from another process merge rather than disappear.
377
405
  */
378
- function keep(issuer, refreshToken) {
406
+ function keep(issuer, refreshToken, expiresAt) {
379
407
  const now = (/* @__PURE__ */ new Date()).toISOString();
380
408
  const record = {
381
409
  refreshToken,
382
410
  signedInAt: toCredential(issuer)?.signedInAt ?? now,
383
- renewedAt: now
411
+ renewedAt: now,
412
+ expiresAt: expiresAt === void 0 ? void 0 : new Date(expiresAt).toISOString()
384
413
  };
385
414
  const keyring = toKeyring(issuer);
386
415
  if (keyring) try {
387
416
  keyring.setPassword(JSON.stringify(record));
417
+ forgetFile(issuer);
388
418
  return "keyring";
389
419
  } catch {}
390
420
  writeFile({
391
421
  ...readFile(),
392
422
  [issuer]: record
393
423
  });
424
+ forgetKeyring(issuer);
394
425
  return "file";
395
426
  }
396
- /** 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
+ */
397
432
  function toCredential(issuer) {
398
- const keyring = toKeyring(issuer);
399
- if (keyring) try {
400
- const held = keyring.getPassword();
401
- if (held) return {
402
- ...toRecord(held),
403
- backend: "keyring"
404
- };
405
- } catch {}
433
+ const fromKeyring = toKeyringCredential(issuer);
406
434
  const held = readFile()[issuer];
407
- return held === void 0 ? void 0 : {
435
+ const fromFile = held === void 0 ? void 0 : {
408
436
  ...held,
409
437
  backend: "file",
410
438
  path: toCredentialPath()
411
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
+ }
412
455
  }
413
456
  /** toRecord reads a stored entry, which older versions wrote as the bare token. */
414
457
  function toRecord(held) {
@@ -421,10 +464,17 @@ function toRecord(held) {
421
464
  }
422
465
  /** forget removes whatever is held for an issuer, in both places. */
423
466
  function forget(issuer) {
467
+ forgetKeyring(issuer);
468
+ forgetFile(issuer);
469
+ }
470
+ function forgetKeyring(issuer) {
424
471
  const keyring = toKeyring(issuer);
425
- if (keyring) try {
472
+ if (!keyring) return;
473
+ try {
426
474
  keyring.deletePassword();
427
475
  } catch {}
476
+ }
477
+ function forgetFile(issuer) {
428
478
  const held = readFile();
429
479
  if (held[issuer] === void 0) return;
430
480
  delete held[issuer];
@@ -471,6 +521,8 @@ const STALE_MS = 3e4;
471
521
  const WAIT_MS$1 = 1e4;
472
522
  const RETRY_MS = 25;
473
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;
474
526
  /** toLockPath names the lock every process coordinates credential writes through. */
475
527
  function toLockPath() {
476
528
  return join(dirname(toCredentialPath()), "credentials.lock");
@@ -482,17 +534,23 @@ function tryAcquire() {
482
534
  recursive: true,
483
535
  mode: DIRECTORY_MODE
484
536
  });
537
+ const mark = `${process.pid}:${randomUUID()}`;
485
538
  try {
486
539
  const handle = openSync(path, "wx");
487
- writeSync(handle, String(process.pid));
540
+ writeSync(handle, mark);
488
541
  closeSync(handle);
542
+ heldBy = mark;
489
543
  return true;
490
544
  } catch {
491
- return isStale(path) ? steal(path) : false;
545
+ return isStale(path) ? steal(path, mark) : false;
492
546
  }
493
547
  }
494
- /** release lets the next process in. */
548
+ /** release lets the next process in, and only ever removes this process's own lock. */
495
549
  function release$1() {
550
+ const mark = heldBy;
551
+ if (mark === void 0) return;
552
+ heldBy = void 0;
553
+ if (toMark(toLockPath()) !== mark) return;
496
554
  rmSync(toLockPath(), { force: true });
497
555
  }
498
556
  /**
@@ -519,9 +577,29 @@ function isStale(path) {
519
577
  return true;
520
578
  }
521
579
  }
522
- 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) {
523
585
  rmSync(path, { force: true });
524
- 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
+ }
525
603
  }
526
604
  //#endregion
527
605
  //#region src/auth/session.ts
@@ -564,9 +642,9 @@ async function toAccessToken(settings, refreshToken) {
564
642
  return await withLock(async () => {
565
643
  const latest = toCredential(settings.issuerUrl)?.refreshToken ?? refreshToken;
566
644
  try {
567
- const tokens = await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest);
645
+ const tokens = toDated(await toTokensFromRefresh(metadata.token_endpoint, settings.clientId, latest));
568
646
  held.set(settings.issuerUrl, tokens);
569
- if (tokens.refreshToken && tokens.refreshToken !== latest) keep(settings.issuerUrl, tokens.refreshToken);
647
+ store(settings.issuerUrl, tokens, latest);
570
648
  return tokens;
571
649
  } catch (error) {
572
650
  if (error instanceof GrantFailure && isDead(error.code)) {
@@ -577,14 +655,37 @@ async function toAccessToken(settings, refreshToken) {
577
655
  }
578
656
  });
579
657
  }
580
- /** Codes the authorization server uses when a refresh token can never work again. */
581
- const DEAD_GRANT_CODES = /* @__PURE__ */ new Set([
582
- "invalid_grant",
583
- "invalid_client",
584
- "unauthorized_client"
585
- ]);
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
+ */
586
663
  function isDead(code) {
587
- 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
+ }
588
689
  }
589
690
  /** forgetHeldTokens drops the access tokens this process is holding. */
590
691
  function forgetHeldTokens() {
@@ -1194,6 +1295,7 @@ function toPrompt(url) {
1194
1295
  fail = reject;
1195
1296
  });
1196
1297
  const input = process.stdin;
1298
+ pasted.catch(() => {});
1197
1299
  if (!input.isTTY) return {
1198
1300
  pasted,
1199
1301
  close: () => {}
@@ -1309,9 +1411,13 @@ async function runLogin(input) {
1309
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`);
1310
1412
  const prompt = toPrompt(url);
1311
1413
  if (process.stdin.isTTY) process.stderr.write("Press c to copy the URL, or paste the code or redirect URL here: ");
1312
- const callback = await Promise.race([listener.callback, prompt.pasted]);
1313
- listener.close();
1314
- 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
+ }
1315
1421
  process.stderr.write(callback.error ? "\n" : "\nApproved, finishing the sign in\n");
1316
1422
  if (callback.error) {
1317
1423
  writeFailure(`sign in was refused: ${callback.error}${callback.errorDescription ? `, ${callback.errorDescription}` : ""}`, input.isJSON);
@@ -1357,7 +1463,15 @@ async function runDeviceLogin(input, metadata, scope) {
1357
1463
  opened ? "Opened your browser there. Waiting for approval\n" : "Open that page on any machine, and enter the code. Waiting for approval\n"
1358
1464
  ].join("\n"));
1359
1465
  const prompt = toPrompt(url);
1360
- 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");
1361
1475
  }
1362
1476
  /** toSignedIn stores what a sign in issued, whichever flow issued it. */
1363
1477
  async function toSignedIn(input, metadata, tokens) {
@@ -1366,7 +1480,7 @@ async function toSignedIn(input, metadata, tokens) {
1366
1480
  writeFailure("the authorization server issued no refresh token, so this sign in cannot be kept", input.isJSON);
1367
1481
  return ExitCode.ERROR;
1368
1482
  }
1369
- const backend = await withLock(() => keep(input.resolved.settings.issuerUrl, refreshToken));
1483
+ const backend = await withLock(() => keep(input.resolved.settings.issuerUrl, refreshToken, tokens.refreshExpiresAt));
1370
1484
  if (input.isJSON) {
1371
1485
  writeData({
1372
1486
  signedIn: true,
@@ -1632,6 +1746,13 @@ async function runMcp(input) {
1632
1746
  }
1633
1747
  //#endregion
1634
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
+ }
1635
1756
  const UNSET_FLAG = {
1636
1757
  name: "unset",
1637
1758
  description: "A field to clear, named as its flag is, repeatable",
@@ -1738,7 +1859,9 @@ function toQuery(operation, flags) {
1738
1859
  for (const flag of operation.queryFlags) {
1739
1860
  const value = flags[toOptionKey(flag.name)];
1740
1861
  if (value === void 0) continue;
1741
- 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));
1742
1865
  }
1743
1866
  return query;
1744
1867
  }
@@ -1758,7 +1881,10 @@ async function toForm(upload, flags) {
1758
1881
  }
1759
1882
  for (const field of upload.fields) {
1760
1883
  const value = flags[toOptionKey(field.name)];
1761
- 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
+ }
1762
1888
  }
1763
1889
  return form;
1764
1890
  }
@@ -1844,21 +1970,21 @@ const surfaceCommands = [
1844
1970
  queryName: "page",
1845
1971
  description: "The page to return, starting at 1",
1846
1972
  valueName: "number",
1847
- schema: z.coerce.number()
1973
+ schema: z.coerce.number().int()
1848
1974
  },
1849
1975
  {
1850
1976
  name: "limit",
1851
1977
  queryName: "limit",
1852
1978
  description: "The number of records per page, from 1 to 100",
1853
1979
  valueName: "number",
1854
- schema: z.coerce.number()
1980
+ schema: z.coerce.number().int().min(1).max(100)
1855
1981
  },
1856
1982
  {
1857
1983
  name: "sort-by",
1858
1984
  queryName: "sortBy",
1859
1985
  description: "The field to sort by",
1860
1986
  valueName: "value",
1861
- schema: z.enum([
1987
+ schema: toEnum([
1862
1988
  "serial",
1863
1989
  "project",
1864
1990
  "item",
@@ -1872,14 +1998,14 @@ const surfaceCommands = [
1872
1998
  queryName: "sortOrder",
1873
1999
  description: "The sort direction",
1874
2000
  valueName: "value",
1875
- schema: z.enum(["ASC", "DESC"])
2001
+ schema: toEnum(["ASC", "DESC"])
1876
2002
  },
1877
2003
  {
1878
2004
  name: "archived",
1879
2005
  queryName: "archived",
1880
2006
  description: "Whether to return unarchived records, archived records, or all of them",
1881
2007
  valueName: "value",
1882
- schema: z.enum([
2008
+ schema: toEnum([
1883
2009
  "all",
1884
2010
  "false",
1885
2011
  "true"
@@ -1946,7 +2072,7 @@ const surfaceCommands = [
1946
2072
  description: "The functional statuses to list, where SCRAPPED lists scrapped assets",
1947
2073
  valueName: "value",
1948
2074
  repeatable: true,
1949
- schema: z.array(z.enum([
2075
+ schema: z.array(toEnum([
1950
2076
  "FUNCTIONAL",
1951
2077
  "NEEDS_REVIEW",
1952
2078
  "NON-FUNCTIONAL",
@@ -1959,7 +2085,7 @@ const surfaceCommands = [
1959
2085
  description: "The transit statuses to list",
1960
2086
  valueName: "value",
1961
2087
  repeatable: true,
1962
- schema: z.array(z.enum([
2088
+ schema: z.array(toEnum([
1963
2089
  "IN_TRANSIT",
1964
2090
  "IN_TRANSIT_TO_FIELD",
1965
2091
  "IN_TRANSIT_TO_INVENTORY",
@@ -2011,7 +2137,7 @@ const surfaceCommands = [
2011
2137
  queryName: "scrapped",
2012
2138
  description: "Whether to list unscrapped assets, scrapped assets, or all of them",
2013
2139
  valueName: "value",
2014
- schema: z.enum([
2140
+ schema: toEnum([
2015
2141
  "all",
2016
2142
  "false",
2017
2143
  "true"
@@ -2050,7 +2176,7 @@ const surfaceCommands = [
2050
2176
  jsonPath: ["depreciationModel"],
2051
2177
  description: "The method one unit is depreciated by, or null to clear it",
2052
2178
  valueName: "value",
2053
- schema: z.enum([
2179
+ schema: toEnum([
2054
2180
  "DOUBLE_DECLINING",
2055
2181
  "STRAIGHT_LINE",
2056
2182
  "SUM_YEAR",
@@ -2095,7 +2221,7 @@ const surfaceCommands = [
2095
2221
  description: "The asset's starting functional status, FUNCTIONAL when absent, which cannot be SCRAPPED",
2096
2222
  valueName: "value",
2097
2223
  nullable: true,
2098
- schema: z.enum([
2224
+ schema: toEnum([
2099
2225
  "FUNCTIONAL",
2100
2226
  "NEEDS_REVIEW",
2101
2227
  "NON-FUNCTIONAL",
@@ -2194,7 +2320,7 @@ const surfaceCommands = [
2194
2320
  description: "The number of months one unit is depreciated over, or null to clear it",
2195
2321
  valueName: "number",
2196
2322
  nullable: true,
2197
- schema: z.coerce.number()
2323
+ schema: z.coerce.number().int()
2198
2324
  }
2199
2325
  ]
2200
2326
  }),
@@ -2308,7 +2434,7 @@ const surfaceCommands = [
2308
2434
  description: "The asset's new functional status, which cannot be SCRAPPED because scrapping has its own endpoint",
2309
2435
  valueName: "value",
2310
2436
  nullable: true,
2311
- schema: z.enum([
2437
+ schema: toEnum([
2312
2438
  "FUNCTIONAL",
2313
2439
  "NEEDS_REVIEW",
2314
2440
  "NON-FUNCTIONAL",
@@ -2390,7 +2516,7 @@ const surfaceCommands = [
2390
2516
  jsonPath: ["depreciationModel"],
2391
2517
  description: "The method one unit is depreciated by, or null to clear it",
2392
2518
  valueName: "value",
2393
- schema: z.enum([
2519
+ schema: toEnum([
2394
2520
  "DOUBLE_DECLINING",
2395
2521
  "STRAIGHT_LINE",
2396
2522
  "SUM_YEAR",
@@ -2490,7 +2616,7 @@ const surfaceCommands = [
2490
2616
  description: "The number of months one unit is depreciated over, or null to clear it",
2491
2617
  valueName: "number",
2492
2618
  nullable: true,
2493
- schema: z.coerce.number()
2619
+ schema: z.coerce.number().int()
2494
2620
  }
2495
2621
  ]
2496
2622
  }), {
@@ -2546,7 +2672,7 @@ const surfaceCommands = [
2546
2672
  description: "Whether the adjustment adds to the asset's cost basis or writes it down",
2547
2673
  valueName: "value",
2548
2674
  required: true,
2549
- schema: z.enum(["CAPITALIZATION", "IMPAIRMENT"])
2675
+ schema: toEnum(["CAPITALIZATION", "IMPAIRMENT"])
2550
2676
  },
2551
2677
  {
2552
2678
  name: "amount",
@@ -2578,7 +2704,7 @@ const surfaceCommands = [
2578
2704
  description: "Why the adjustment was made, which must be one its adjustment type allows",
2579
2705
  valueName: "value",
2580
2706
  required: true,
2581
- schema: z.enum([
2707
+ schema: toEnum([
2582
2708
  "ADDITION",
2583
2709
  "BETTERMENT",
2584
2710
  "DAMAGE",
@@ -3041,7 +3167,7 @@ const surfaceCommands = [
3041
3167
  description: "Why the useful life was revised",
3042
3168
  valueName: "value",
3043
3169
  required: true,
3044
- schema: z.enum([
3170
+ schema: toEnum([
3045
3171
  "CHANGE_IN_USE",
3046
3172
  "DAMAGE",
3047
3173
  "OBSOLESCENCE",
@@ -3057,7 +3183,7 @@ const surfaceCommands = [
3057
3183
  description: "The asset's revised useful life in months",
3058
3184
  valueName: "number",
3059
3185
  required: true,
3060
- schema: z.coerce.number()
3186
+ schema: z.coerce.number().int()
3061
3187
  }
3062
3188
  ]
3063
3189
  })]
@@ -3084,14 +3210,14 @@ const surfaceCommands = [
3084
3210
  queryName: "page",
3085
3211
  description: "The page to return, starting at 1",
3086
3212
  valueName: "number",
3087
- schema: z.coerce.number()
3213
+ schema: z.coerce.number().int()
3088
3214
  },
3089
3215
  {
3090
3216
  name: "limit",
3091
3217
  queryName: "limit",
3092
3218
  description: "The number of records per page, from 1 to 100",
3093
3219
  valueName: "number",
3094
- schema: z.coerce.number()
3220
+ schema: z.coerce.number().int().min(1).max(100)
3095
3221
  },
3096
3222
  {
3097
3223
  name: "sort-by",
@@ -3105,14 +3231,14 @@ const surfaceCommands = [
3105
3231
  queryName: "sortOrder",
3106
3232
  description: "The sort direction",
3107
3233
  valueName: "value",
3108
- schema: z.enum(["ASC", "DESC"])
3234
+ schema: toEnum(["ASC", "DESC"])
3109
3235
  },
3110
3236
  {
3111
3237
  name: "archived",
3112
3238
  queryName: "archived",
3113
3239
  description: "Whether to return unarchived records, archived records, or all of them",
3114
3240
  valueName: "value",
3115
- schema: z.enum([
3241
+ schema: toEnum([
3116
3242
  "all",
3117
3243
  "false",
3118
3244
  "true"
@@ -3389,8 +3515,7 @@ const surfaceCommands = [
3389
3515
  name: "is-public",
3390
3516
  jsonPath: ["isPublic"],
3391
3517
  description: "Whether any organization's API key may download the file, which is false unless sent as true",
3392
- valueName: "value",
3393
- schema: z.string()
3518
+ schema: z.boolean()
3394
3519
  }
3395
3520
  ]
3396
3521
  }
@@ -3434,7 +3559,7 @@ const surfaceCommands = [
3434
3559
  queryName: "type",
3435
3560
  description: "The item type to list, SERVICE, DEVICE, or BULK, or absent for every type",
3436
3561
  valueName: "value",
3437
- schema: z.enum([
3562
+ schema: toEnum([
3438
3563
  "BULK",
3439
3564
  "DEVICE",
3440
3565
  "SERVICE"
@@ -3452,21 +3577,21 @@ const surfaceCommands = [
3452
3577
  queryName: "page",
3453
3578
  description: "The page to return, starting at 1",
3454
3579
  valueName: "number",
3455
- schema: z.coerce.number()
3580
+ schema: z.coerce.number().int()
3456
3581
  },
3457
3582
  {
3458
3583
  name: "limit",
3459
3584
  queryName: "limit",
3460
3585
  description: "The number of records per page, from 1 to 100",
3461
3586
  valueName: "number",
3462
- schema: z.coerce.number()
3587
+ schema: z.coerce.number().int().min(1).max(100)
3463
3588
  },
3464
3589
  {
3465
3590
  name: "sort-by",
3466
3591
  queryName: "sortBy",
3467
3592
  description: "The field to sort by",
3468
3593
  valueName: "value",
3469
- schema: z.enum([
3594
+ schema: toEnum([
3470
3595
  "name",
3471
3596
  "sku",
3472
3597
  "type",
@@ -3478,14 +3603,14 @@ const surfaceCommands = [
3478
3603
  queryName: "sortOrder",
3479
3604
  description: "The sort direction",
3480
3605
  valueName: "value",
3481
- schema: z.enum(["ASC", "DESC"])
3606
+ schema: toEnum(["ASC", "DESC"])
3482
3607
  },
3483
3608
  {
3484
3609
  name: "archived",
3485
3610
  queryName: "archived",
3486
3611
  description: "Whether to return unarchived records, archived records, or all of them",
3487
3612
  valueName: "value",
3488
- schema: z.enum([
3613
+ schema: toEnum([
3489
3614
  "all",
3490
3615
  "false",
3491
3616
  "true"
@@ -3546,7 +3671,7 @@ const surfaceCommands = [
3546
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",
3547
3672
  valueName: "value",
3548
3673
  required: true,
3549
- schema: z.enum([
3674
+ schema: toEnum([
3550
3675
  "BULK",
3551
3676
  "DEVICE",
3552
3677
  "SERVICE"
@@ -3557,7 +3682,7 @@ const surfaceCommands = [
3557
3682
  jsonPath: ["unitOfMeasure"],
3558
3683
  description: "The unit a BULK item's quantities are counted in, which SERVICE and DEVICE items ignore",
3559
3684
  valueName: "value",
3560
- schema: z.enum([
3685
+ schema: toEnum([
3561
3686
  "BG",
3562
3687
  "BO",
3563
3688
  "BX",
@@ -3703,7 +3828,7 @@ const surfaceCommands = [
3703
3828
  description: "The type to convert the item to, when the item's assets and inventory history allow the conversion",
3704
3829
  valueName: "value",
3705
3830
  nullable: true,
3706
- schema: z.enum([
3831
+ schema: toEnum([
3707
3832
  "BULK",
3708
3833
  "DEVICE",
3709
3834
  "SERVICE"
@@ -3751,7 +3876,7 @@ const surfaceCommands = [
3751
3876
  jsonPath: ["depreciationModel"],
3752
3877
  description: "The method one unit is depreciated by, or null to clear it",
3753
3878
  valueName: "value",
3754
- schema: z.enum([
3879
+ schema: toEnum([
3755
3880
  "DOUBLE_DECLINING",
3756
3881
  "STRAIGHT_LINE",
3757
3882
  "SUM_YEAR",
@@ -3836,7 +3961,7 @@ const surfaceCommands = [
3836
3961
  description: "The number of months one unit is depreciated over, or null to clear it",
3837
3962
  valueName: "number",
3838
3963
  nullable: true,
3839
- schema: z.coerce.number()
3964
+ schema: z.coerce.number().int()
3840
3965
  }
3841
3966
  ]
3842
3967
  })]
@@ -3867,7 +3992,7 @@ const surfaceCommands = [
3867
3992
  description: "The kind of value the field holds",
3868
3993
  valueName: "value",
3869
3994
  required: true,
3870
- schema: z.enum([
3995
+ schema: toEnum([
3871
3996
  "BOOLEAN",
3872
3997
  "DATE",
3873
3998
  "DATE_TIME",
@@ -3893,7 +4018,7 @@ const surfaceCommands = [
3893
4018
  valueName: "number",
3894
4019
  required: true,
3895
4020
  nullable: true,
3896
- schema: z.coerce.number()
4021
+ schema: z.coerce.number().int()
3897
4022
  },
3898
4023
  {
3899
4024
  name: "section",
@@ -3902,7 +4027,7 @@ const surfaceCommands = [
3902
4027
  valueName: "number",
3903
4028
  required: true,
3904
4029
  nullable: true,
3905
- schema: z.coerce.number()
4030
+ schema: z.coerce.number().int()
3906
4031
  }
3907
4032
  ]
3908
4033
  }),
@@ -3973,21 +4098,21 @@ const surfaceCommands = [
3973
4098
  queryName: "page",
3974
4099
  description: "The page to return, starting at 1",
3975
4100
  valueName: "number",
3976
- schema: z.coerce.number()
4101
+ schema: z.coerce.number().int()
3977
4102
  },
3978
4103
  {
3979
4104
  name: "limit",
3980
4105
  queryName: "limit",
3981
4106
  description: "The number of records per page, from 1 to 100",
3982
4107
  valueName: "number",
3983
- schema: z.coerce.number()
4108
+ schema: z.coerce.number().int().min(1).max(100)
3984
4109
  },
3985
4110
  {
3986
4111
  name: "sort-by",
3987
4112
  queryName: "sortBy",
3988
4113
  description: "The field to sort by",
3989
4114
  valueName: "value",
3990
- schema: z.enum([
4115
+ schema: toEnum([
3991
4116
  "name",
3992
4117
  "company",
3993
4118
  "assetCount"
@@ -3998,14 +4123,14 @@ const surfaceCommands = [
3998
4123
  queryName: "sortOrder",
3999
4124
  description: "The sort direction",
4000
4125
  valueName: "value",
4001
- schema: z.enum(["ASC", "DESC"])
4126
+ schema: toEnum(["ASC", "DESC"])
4002
4127
  },
4003
4128
  {
4004
4129
  name: "archived",
4005
4130
  queryName: "archived",
4006
4131
  description: "Whether to return unarchived records, archived records, or all of them",
4007
4132
  valueName: "value",
4008
- schema: z.enum([
4133
+ schema: toEnum([
4009
4134
  "all",
4010
4135
  "false",
4011
4136
  "true"
@@ -4016,7 +4141,7 @@ const surfaceCommands = [
4016
4141
  queryName: "isTransient",
4017
4142
  description: "Whether to return permanent locations (false), transient locations (true), or both (all)",
4018
4143
  valueName: "value",
4019
- schema: z.enum([
4144
+ schema: toEnum([
4020
4145
  "all",
4021
4146
  "false",
4022
4147
  "true"
@@ -4181,7 +4306,7 @@ const surfaceCommands = [
4181
4306
  jsonPath: ["type"],
4182
4307
  description: "SITE for a site, or ZONE for a zone within a site",
4183
4308
  valueName: "value",
4184
- schema: z.enum([
4309
+ schema: toEnum([
4185
4310
  "SITE",
4186
4311
  "UNKNOWN",
4187
4312
  "ZONE"
@@ -4337,7 +4462,7 @@ const surfaceCommands = [
4337
4462
  jsonPath: ["type"],
4338
4463
  description: "SITE for a site, or ZONE for a zone within a site",
4339
4464
  valueName: "value",
4340
- schema: z.enum([
4465
+ schema: toEnum([
4341
4466
  "SITE",
4342
4467
  "UNKNOWN",
4343
4468
  "ZONE"
@@ -4367,7 +4492,7 @@ const surfaceCommands = [
4367
4492
  queryName: "archived",
4368
4493
  description: "Whether to return unarchived zones, archived zones, or all of them",
4369
4494
  valueName: "value",
4370
- schema: z.enum([
4495
+ schema: toEnum([
4371
4496
  "all",
4372
4497
  "false",
4373
4498
  "true"
@@ -4378,6 +4503,23 @@ const surfaceCommands = [
4378
4503
  }
4379
4504
  ]
4380
4505
  },
4506
+ {
4507
+ name: "token",
4508
+ summary: "Token commands",
4509
+ arguments: [],
4510
+ flags: [],
4511
+ examples: [],
4512
+ subcommands: [defineOperation({
4513
+ name: "get",
4514
+ summary: "Report the credential this CLI is calling with",
4515
+ example: "hardfin token get",
4516
+ method: "GET",
4517
+ path: "/token",
4518
+ pathParameters: [],
4519
+ queryFlags: [],
4520
+ bodyFlags: []
4521
+ })]
4522
+ },
4381
4523
  {
4382
4524
  name: "url-link",
4383
4525
  summary: "URL link commands",
@@ -4445,32 +4587,6 @@ const surfaceCommands = [
4445
4587
  }
4446
4588
  ];
4447
4589
  //#endregion
4448
- //#region src/auth/jwt.ts
4449
- /**
4450
- * toClaims reads an access token's payload for display. Nothing here verifies the
4451
- * signature, because the API is what decides whether a token is good.
4452
- */
4453
- function toClaims(token) {
4454
- const payload = token.split(".")[1];
4455
- if (!payload) return {};
4456
- try {
4457
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
4458
- return {
4459
- expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
4460
- issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
4461
- scopes: toScopes(decoded),
4462
- subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
4463
- };
4464
- } catch {
4465
- return {};
4466
- }
4467
- }
4468
- /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
4469
- function toScopes(decoded) {
4470
- if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
4471
- return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
4472
- }
4473
- //#endregion
4474
4590
  //#region src/system/host.ts
4475
4591
  const BYTES_PER_GB = 1024 ** 3;
4476
4592
  /** toDistribution reads the name a Linux distribution gives itself. */
@@ -4514,8 +4630,8 @@ function toFile(path) {
4514
4630
  //#endregion
4515
4631
  //#region src/command/status.ts
4516
4632
  /**
4517
- * Hardfin expires an unused refresh token after this long. It is the server's rule, not the
4518
- * CLI's, and the token endpoint reports no expiry, so what this produces is an estimate.
4633
+ * What the CLI falls back to for an older sign in, stored before the server began reporting
4634
+ * when a refresh token expires. Hardfin's own rule, and an estimate rather than a fact.
4519
4635
  */
4520
4636
  const REFRESH_SLIDING_DAYS = 90;
4521
4637
  const statusCommand = defineCommand({
@@ -4619,8 +4735,8 @@ function toCredentialReport(apiKey, stored) {
4619
4735
  fingerprint: toFingerprint(stored.refreshToken),
4620
4736
  signedInAt: stored.signedInAt ?? null,
4621
4737
  renewedAt: stored.renewedAt ?? null,
4622
- refreshExpiresAt: toRefreshExpiry(stored.renewedAt),
4623
- refreshExpiryIsEstimated: true
4738
+ refreshExpiresAt: stored.expiresAt ?? toRefreshExpiry(stored.renewedAt),
4739
+ refreshExpiryIsEstimated: stored.expiresAt === void 0
4624
4740
  };
4625
4741
  }
4626
4742
  function toServerReport(metadata) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.0.2-dev.13",
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",