@hardfin/cli 0.1.0-dev.19 → 0.1.0-dev.20

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 +6 -2
  2. package/dist/cli.js +361 -192
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,8 +63,7 @@ hardfin asset ownership create ast_4f9xk2mq7plr8stz \
63
63
  --customer-id cst_a1 --date 2026-09-23 --sale-price 1500.00
64
64
 
65
65
  hardfin asset move execute create \
66
- --move assetId=ast_a1,locationId=loc_x \
67
- --move assetId=ast_b2,locationId=loc_y
66
+ --move assetId=0192f7d4-3b6a-7c1e-9f2d-5a8b4c6e7d70,originId=0192f7d4-3b6a-7c1e-9f2d-5a8b4c6e7d71,destinationId=0192f7d4-3b6a-7c1e-9f2d-5a8b4c6e7d72,shipAt=2026-09-24T10:00:00Z
68
67
  ```
69
68
 
70
69
  A value is sent as the type the document names. `--useful-life 36` sends the number 36. A
@@ -343,6 +342,11 @@ The CLI reads whatever `config.local.json` and `.env` sit in the directory you r
343
342
  A directory you do not control can therefore point the CLI at a server you do not expect, so
344
343
  run `hardfin config` when a command reaches somewhere surprising.
345
344
 
345
+ A sign in is not carried there. An access token is sent only to the host that issued it, and
346
+ only over https unless that host is this machine. Pointing `apiUrl` elsewhere gets a refusal
347
+ naming both hosts, rather than a token sent to a stranger. An API key is sent wherever you
348
+ point it, because setting one is a deliberate act.
349
+
346
350
  ## Working on the CLI
347
351
 
348
352
  ```sh
package/dist/cli.js CHANGED
@@ -139,9 +139,9 @@ function writeData(value) {
139
139
  process.stdout.write(`${JSON.stringify(value ?? null, null, 2)}\n`);
140
140
  }
141
141
  /** writeFailure prints why a command failed on stderr, as text or as JSON. */
142
- function writeFailure(message, isJSON, errors, requestId) {
142
+ function writeFailure(message, isJSON, errors, requestId, status) {
143
143
  if (!isJSON) {
144
- process.stderr.write(`error: ${message}\n`);
144
+ process.stderr.write(`error: ${message}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
145
145
  for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${entry.error} (${entry.statusCode})\n`);
146
146
  if (requestId) process.stderr.write(` request ${requestId}\n`);
147
147
  return;
@@ -151,7 +151,8 @@ function writeFailure(message, isJSON, errors, requestId) {
151
151
  error: message,
152
152
  statusCode: 0
153
153
  }],
154
- requestId: requestId ?? null
154
+ requestId: requestId ?? null,
155
+ status: status ?? null
155
156
  };
156
157
  process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
157
158
  }
@@ -289,112 +290,6 @@ function toScopes(decoded) {
289
290
  return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
290
291
  }
291
292
  //#endregion
292
- //#region src/auth/metadata.ts
293
- const METADATA_PATH = "/.well-known/oauth-authorization-server";
294
- const AuthorizationServerMetadata = z.looseObject({
295
- issuer: z.string(),
296
- authorization_endpoint: z.string(),
297
- token_endpoint: z.string(),
298
- revocation_endpoint: z.string().optional(),
299
- device_authorization_endpoint: z.string().optional(),
300
- scopes_supported: z.array(z.string()).optional(),
301
- grant_types_supported: z.array(z.string()).optional(),
302
- code_challenge_methods_supported: z.array(z.string()).optional()
303
- });
304
- /** DiscoveryFailure is an authorization server that cannot be read or does not describe itself. */
305
- var DiscoveryFailure = class extends Error {
306
- constructor(message) {
307
- super(message);
308
- this.name = "DiscoveryFailure";
309
- }
310
- };
311
- /** toMetadata reads what an authorization server says about itself. */
312
- async function toMetadata(issuer) {
313
- const url = `${issuer.replace(/\/+$/, "")}${METADATA_PATH}`;
314
- let response;
315
- try {
316
- response = await fetch(url, { headers: { Accept: "application/json" } });
317
- } catch (error) {
318
- throw new DiscoveryFailure(`${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`);
319
- }
320
- if (!response.ok) throw new DiscoveryFailure(`${url} answered ${response.status}, so this host publishes no authorization server`);
321
- const parsed = AuthorizationServerMetadata.safeParse(await response.json().catch(() => void 0));
322
- if (!parsed.success) throw new DiscoveryFailure(`${url} does not describe an authorization server`);
323
- if (parsed.data.issuer.replace(/\/+$/, "") !== issuer.replace(/\/+$/, "")) throw new DiscoveryFailure(`${url} names issuer ${parsed.data.issuer}, which is not the host it was read from`);
324
- return parsed.data;
325
- }
326
- //#endregion
327
- //#region src/auth/grant.ts
328
- const TokenResponse = z.looseObject({
329
- access_token: z.string(),
330
- token_type: z.string(),
331
- expires_in: z.number().optional(),
332
- refresh_token: z.string().optional(),
333
- refresh_token_expires_in: z.number().optional(),
334
- scope: z.string().optional()
335
- });
336
- /** GrantFailure is a token request the authorization server refused. */
337
- var GrantFailure = class extends Error {
338
- code;
339
- constructor(code, description) {
340
- super(description ? `${code}: ${description}` : code);
341
- this.name = "GrantFailure";
342
- this.code = code;
343
- }
344
- };
345
- /** toTokensFromCode trades an authorization code for tokens. */
346
- async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
347
- return await toTokens(tokenUrl, {
348
- grant_type: "authorization_code",
349
- client_id: clientId,
350
- code,
351
- redirect_uri: redirectUri,
352
- code_verifier: pkce.verifier
353
- });
354
- }
355
- /** toTokensFromRefresh trades a refresh token for a fresh pair. */
356
- async function toTokensFromRefresh(tokenUrl, clientId, refreshToken) {
357
- return await toTokens(tokenUrl, {
358
- grant_type: "refresh_token",
359
- client_id: clientId,
360
- refresh_token: refreshToken
361
- });
362
- }
363
- /** revoke tells the server to forget a token, so signing out reaches every machine. */
364
- async function revoke(revocationUrl, clientId, token) {
365
- await fetch(revocationUrl, {
366
- method: "POST",
367
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
368
- body: new URLSearchParams({
369
- client_id: clientId,
370
- token,
371
- token_type_hint: "refresh_token"
372
- })
373
- });
374
- }
375
- /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
376
- async function toTokens(url, form) {
377
- const response = await fetch(url, {
378
- method: "POST",
379
- headers: {
380
- "Content-Type": "application/x-www-form-urlencoded",
381
- Accept: "application/json"
382
- },
383
- body: new URLSearchParams(form)
384
- });
385
- const body = await response.json().catch(() => void 0);
386
- if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
387
- const parsed = TokenResponse.safeParse(body);
388
- if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
389
- return {
390
- accessToken: parsed.data.access_token,
391
- refreshToken: parsed.data.refresh_token,
392
- refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
393
- scope: parsed.data.scope,
394
- expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
395
- };
396
- }
397
- //#endregion
398
293
  //#region src/credential/store.ts
399
294
  const load = createRequire(import.meta.url);
400
295
  /** The service a keyring entry is filed under, alongside the issuer it belongs to. */
@@ -414,7 +309,7 @@ function toCredentialPath() {
414
309
  * family lifetime runs from. Callers hold the credential lock, which is what makes a write
415
310
  * from another process merge rather than disappear.
416
311
  */
417
- function keep(issuer, refreshToken, expiresAt) {
312
+ function keep$1(issuer, refreshToken, expiresAt) {
418
313
  const now = (/* @__PURE__ */ new Date()).toISOString();
419
314
  const record = {
420
315
  refreshToken,
@@ -526,6 +421,147 @@ function writeFile(held) {
526
421
  renameSync(pending, path);
527
422
  }
528
423
  //#endregion
424
+ //#region src/auth/metadata.ts
425
+ const METADATA_PATH = "/.well-known/oauth-authorization-server";
426
+ const AuthorizationServerMetadata = z.looseObject({
427
+ issuer: z.string(),
428
+ authorization_endpoint: z.string(),
429
+ token_endpoint: z.string(),
430
+ revocation_endpoint: z.string().optional(),
431
+ device_authorization_endpoint: z.string().optional(),
432
+ scopes_supported: z.array(z.string()).optional(),
433
+ grant_types_supported: z.array(z.string()).optional(),
434
+ code_challenge_methods_supported: z.array(z.string()).optional()
435
+ });
436
+ /** DiscoveryFailure is an authorization server that cannot be read or does not describe itself. */
437
+ var DiscoveryFailure = class extends Error {
438
+ constructor(message) {
439
+ super(message);
440
+ this.name = "DiscoveryFailure";
441
+ }
442
+ };
443
+ /** How long a discovery document is reused, which is the hour the server asks for. */
444
+ const CACHE_MS = 36e5;
445
+ /** toMetadata reads what an authorization server says about itself. */
446
+ async function toMetadata(issuer) {
447
+ const cached = toCached(issuer);
448
+ if (cached) return cached;
449
+ const url = `${issuer.replace(/\/+$/, "")}${METADATA_PATH}`;
450
+ let response;
451
+ try {
452
+ response = await fetch(url, { headers: { Accept: "application/json" } });
453
+ } catch (error) {
454
+ throw new DiscoveryFailure(`${url} could not be reached: ${error instanceof Error ? error.message : String(error)}`);
455
+ }
456
+ if (!response.ok) throw new DiscoveryFailure(`${url} answered ${response.status}, so this host publishes no authorization server`);
457
+ const parsed = AuthorizationServerMetadata.safeParse(await response.json().catch(() => void 0));
458
+ if (!parsed.success) throw new DiscoveryFailure(`${url} does not describe an authorization server`);
459
+ if (parsed.data.issuer.replace(/\/+$/, "") !== issuer.replace(/\/+$/, "")) throw new DiscoveryFailure(`${url} names issuer ${parsed.data.issuer}, which is not the host it was read from`);
460
+ keep(issuer, parsed.data);
461
+ return parsed.data;
462
+ }
463
+ /** toCachePath names where one server's document is kept, beside the credentials. */
464
+ function toCachePath(issuer) {
465
+ const name = createHash("sha256").update(issuer).digest("hex").slice(0, 16);
466
+ return join(dirname(toCredentialPath()), `metadata-${name}.json`);
467
+ }
468
+ function toCached(issuer) {
469
+ const path = toCachePath(issuer);
470
+ if (!existsSync(path)) return;
471
+ try {
472
+ const held = JSON.parse(readFileSync(path, "utf8"));
473
+ if ((held.readAt ?? 0) + CACHE_MS < Date.now()) return;
474
+ const parsed = AuthorizationServerMetadata.safeParse(held.document);
475
+ return parsed.success ? parsed.data : void 0;
476
+ } catch {
477
+ return;
478
+ }
479
+ }
480
+ function keep(issuer, document) {
481
+ const path = toCachePath(issuer);
482
+ try {
483
+ mkdirSync(dirname(path), {
484
+ recursive: true,
485
+ mode: 448
486
+ });
487
+ writeFileSync(path, JSON.stringify({
488
+ readAt: Date.now(),
489
+ document
490
+ }), { mode: 384 });
491
+ } catch {}
492
+ }
493
+ //#endregion
494
+ //#region src/auth/grant.ts
495
+ const TokenResponse = z.looseObject({
496
+ access_token: z.string(),
497
+ token_type: z.string(),
498
+ expires_in: z.number().optional(),
499
+ refresh_token: z.string().optional(),
500
+ refresh_token_expires_in: z.number().optional(),
501
+ scope: z.string().optional()
502
+ });
503
+ /** GrantFailure is a token request the authorization server refused. */
504
+ var GrantFailure = class extends Error {
505
+ code;
506
+ constructor(code, description) {
507
+ super(description ? `${code}: ${description}` : code);
508
+ this.name = "GrantFailure";
509
+ this.code = code;
510
+ }
511
+ };
512
+ /** toTokensFromCode trades an authorization code for tokens. */
513
+ async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
514
+ return await toTokens(tokenUrl, {
515
+ grant_type: "authorization_code",
516
+ client_id: clientId,
517
+ code,
518
+ redirect_uri: redirectUri,
519
+ code_verifier: pkce.verifier
520
+ });
521
+ }
522
+ /** toTokensFromRefresh trades a refresh token for a fresh pair. */
523
+ async function toTokensFromRefresh(tokenUrl, clientId, refreshToken) {
524
+ return await toTokens(tokenUrl, {
525
+ grant_type: "refresh_token",
526
+ client_id: clientId,
527
+ refresh_token: refreshToken
528
+ });
529
+ }
530
+ /** revoke tells the server to forget a token, so signing out reaches every machine. */
531
+ async function revoke(revocationUrl, clientId, token) {
532
+ await fetch(revocationUrl, {
533
+ method: "POST",
534
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
535
+ body: new URLSearchParams({
536
+ client_id: clientId,
537
+ token,
538
+ token_type_hint: "refresh_token"
539
+ })
540
+ });
541
+ }
542
+ /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
543
+ async function toTokens(url, form) {
544
+ const response = await fetch(url, {
545
+ method: "POST",
546
+ headers: {
547
+ "Content-Type": "application/x-www-form-urlencoded",
548
+ Accept: "application/json"
549
+ },
550
+ body: new URLSearchParams(form)
551
+ });
552
+ const body = await response.json().catch(() => void 0);
553
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
554
+ const parsed = TokenResponse.safeParse(body);
555
+ if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
556
+ return {
557
+ accessToken: parsed.data.access_token,
558
+ refreshToken: parsed.data.refresh_token,
559
+ refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
560
+ scope: parsed.data.scope,
561
+ expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
562
+ };
563
+ }
564
+ //#endregion
529
565
  //#region src/credential/lock.ts
530
566
  /** A lock held longer than this belongs to a process that died, so it is taken. */
531
567
  const STALE_MS = 3e4;
@@ -636,6 +672,7 @@ async function toRequestCredential(settings) {
636
672
  value: settings.apiKey,
637
673
  kind: "api key"
638
674
  };
675
+ refuseForeignHost(settings);
639
676
  const stored = toCredential(settings.issuerUrl);
640
677
  if (!stored) throw new NoCredential("not authenticated. Run hardfin login, or set HARDFIN_API_KEY");
641
678
  return {
@@ -693,11 +730,33 @@ function toDated(tokens) {
693
730
  function store(issuer, tokens, previous) {
694
731
  if (!tokens.refreshToken || tokens.refreshToken === previous) return;
695
732
  try {
696
- keep(issuer, tokens.refreshToken, tokens.refreshExpiresAt);
733
+ keep$1(issuer, tokens.refreshToken, tokens.refreshExpiresAt);
697
734
  } catch (error) {
698
735
  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`);
699
736
  }
700
737
  }
738
+ /**
739
+ * refuseForeignHost keeps an access token on the host that issued it. The API URL and the
740
+ * authorization server are configured separately, and a directory's config file can set
741
+ * either, so nothing else stops a token minted for Hardfin being sent somewhere else.
742
+ */
743
+ function refuseForeignHost(settings) {
744
+ const api = toHost(settings.apiUrl);
745
+ const issuer = toHost(settings.issuerUrl);
746
+ if (api === void 0 || issuer === void 0) throw new NoCredential(`${settings.apiUrl} is not a URL this CLI can call`);
747
+ if (api.host !== issuer.host) throw new NoCredential(`this sign in belongs to ${issuer.host}, so it is not sent to ${api.host}. Set HARDFIN_API_KEY to call another host, or point --issuer-url at the server that signs you in`);
748
+ if (api.protocol !== "https:" && !isLoopback(api.hostname)) throw new NoCredential(`${settings.apiUrl} is not https, so a sign in is not sent to it`);
749
+ }
750
+ function toHost(url) {
751
+ try {
752
+ return new URL(url);
753
+ } catch {
754
+ return;
755
+ }
756
+ }
757
+ function isLoopback(hostname) {
758
+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
759
+ }
701
760
  /** forgetHeldTokens drops the access tokens this process is holding. */
702
761
  function forgetHeldTokens() {
703
762
  held.clear();
@@ -838,6 +897,10 @@ async function runApi(input) {
838
897
  writeFailure("a path is required, such as /customer", input.isJSON);
839
898
  return ExitCode.USAGE;
840
899
  }
900
+ if (path.split("/").includes("..")) {
901
+ writeFailure("a path stays inside the API, so it cannot contain ..", input.isJSON);
902
+ return ExitCode.USAGE;
903
+ }
841
904
  const query = toQuery$1(input.flags["field"]);
842
905
  if (query === void 0) {
843
906
  writeFailure("each --field is key=value, such as -f limit=50", input.isJSON);
@@ -867,7 +930,7 @@ async function runApi(input) {
867
930
  return ExitCode.NOT_AUTHENTICATED;
868
931
  }
869
932
  if (error instanceof RequestFailure) {
870
- writeFailure(error.message, input.isJSON, error.errors, error.requestId);
933
+ writeFailure(error.message, input.isJSON, error.errors, error.requestId, error.status);
871
934
  return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
872
935
  }
873
936
  writeFailure(error instanceof Error ? error.message : String(error), input.isJSON);
@@ -1505,7 +1568,7 @@ async function toSignedIn(input, metadata, tokens) {
1505
1568
  writeFailure("the authorization server issued no refresh token, so this sign in cannot be kept", input.isJSON);
1506
1569
  return ExitCode.ERROR;
1507
1570
  }
1508
- const backend = await withLock(() => keep(input.resolved.settings.issuerUrl, refreshToken, tokens.refreshExpiresAt));
1571
+ const backend = await withLock(() => keep$1(input.resolved.settings.issuerUrl, refreshToken, tokens.refreshExpiresAt));
1509
1572
  if (input.isJSON) {
1510
1573
  writeData({
1511
1574
  signedIn: true,
@@ -1609,6 +1672,17 @@ const TOOL = {
1609
1672
  required: ["args"]
1610
1673
  }
1611
1674
  };
1675
+ /** ProtocolFailure is a line this server answers with a JSON-RPC error rather than a result. */
1676
+ var ProtocolFailure = class extends Error {
1677
+ code;
1678
+ id;
1679
+ constructor(code, message, id) {
1680
+ super(message);
1681
+ this.name = "ProtocolFailure";
1682
+ this.code = code;
1683
+ this.id = id;
1684
+ }
1685
+ };
1612
1686
  /** toResponse answers one request, and answers nothing to a notification. */
1613
1687
  async function toResponse(request, commands, run) {
1614
1688
  const answer = (result) => ({
@@ -1640,7 +1714,19 @@ async function toResponse(request, commands, run) {
1640
1714
  description: "The command tree as JSON",
1641
1715
  mimeType: "application/json"
1642
1716
  }] });
1643
- case "resources/read": return answer(toResource(toText(request.params?.["uri"] ?? ""), commands));
1717
+ case "resources/read": {
1718
+ const uri = toText(request.params?.["uri"] ?? "");
1719
+ const resource = toResource(uri, commands);
1720
+ if (!resource) return {
1721
+ jsonrpc: "2.0",
1722
+ id: request.id ?? null,
1723
+ error: {
1724
+ code: -32002,
1725
+ message: `${uri} is not a resource this server offers`
1726
+ }
1727
+ };
1728
+ return answer(resource);
1729
+ }
1644
1730
  case "tools/call": return answer(await toToolResult(request.params, run));
1645
1731
  case "ping": return answer({});
1646
1732
  default:
@@ -1655,9 +1741,16 @@ async function toResponse(request, commands, run) {
1655
1741
  };
1656
1742
  }
1657
1743
  }
1744
+ /** The revisions this server speaks, newest first, which is what it may answer with. */
1745
+ const PROTOCOL_VERSIONS = [
1746
+ PROTOCOL_VERSION,
1747
+ "2025-03-26",
1748
+ "2024-11-05"
1749
+ ];
1750
+ /** toProtocolVersion answers a version this server implements, never one it was told. */
1658
1751
  function toProtocolVersion(params) {
1659
1752
  const asked = params?.["protocolVersion"];
1660
- return typeof asked === "string" ? asked : PROTOCOL_VERSION;
1753
+ return typeof asked === "string" && PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSION;
1661
1754
  }
1662
1755
  function toResource(uri, commands) {
1663
1756
  if (uri === GUIDE_URI) return { contents: [{
@@ -1670,10 +1763,6 @@ function toResource(uri, commands) {
1670
1763
  mimeType: "application/json",
1671
1764
  text: JSON.stringify(toTree(commands), null, 2)
1672
1765
  }] };
1673
- return {
1674
- contents: [],
1675
- isError: true
1676
- };
1677
1766
  }
1678
1767
  /** toTree names every command and what it takes, without the schemas a tool list would carry. */
1679
1768
  function toTree(commands) {
@@ -1691,12 +1780,32 @@ const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
1691
1780
  "logout",
1692
1781
  "mcp"
1693
1782
  ]);
1783
+ /** The global options, which sit before a command and take a value of their own. */
1784
+ const GLOBAL_OPTIONS_WITH_VALUES = /* @__PURE__ */ new Set(["--api-url", "--issuer-url"]);
1785
+ /**
1786
+ * toCommandName reads which command a call names. A global option sits before it, so
1787
+ * reading the first argument alone would let --issuer-url x logout through.
1788
+ */
1789
+ function toCommandName(args) {
1790
+ for (let index = 0; index < args.length; index += 1) {
1791
+ const argument = args[index] ?? "";
1792
+ if (GLOBAL_OPTIONS_WITH_VALUES.has(argument)) {
1793
+ index += 1;
1794
+ continue;
1795
+ }
1796
+ if (argument.startsWith("-")) continue;
1797
+ return argument;
1798
+ }
1799
+ }
1800
+ /** toIsAskingForHelp reports whether a call only wants to read about a command. */
1801
+ function toIsAskingForHelp(args) {
1802
+ return args.includes("--help") || args.includes("-h") || args[0] === "help";
1803
+ }
1694
1804
  async function toToolResult(params, run) {
1695
- const name = params?.["name"];
1696
- if (name !== void 0 && name !== TOOL.name) return {
1805
+ if (params?.["name"] !== TOOL.name) return {
1697
1806
  content: [{
1698
1807
  type: "text",
1699
- text: `this server offers one tool, ${TOOL.name}`
1808
+ text: `a call names its tool, and this server offers one, ${TOOL.name}`
1700
1809
  }],
1701
1810
  isError: true
1702
1811
  };
@@ -1708,11 +1817,20 @@ async function toToolResult(params, run) {
1708
1817
  }],
1709
1818
  isError: true
1710
1819
  };
1820
+ const refused = toRefusedArgument(args);
1821
+ if (refused) return {
1822
+ content: [{
1823
+ type: "text",
1824
+ text: refused
1825
+ }],
1826
+ isError: true
1827
+ };
1711
1828
  const asked = args;
1712
- if (REFUSED_COMMANDS.has(asked[0] ?? "")) return {
1829
+ const command = toCommandName(asked) ?? "";
1830
+ if (REFUSED_COMMANDS.has(command) && !toIsAskingForHelp(asked)) return {
1713
1831
  content: [{
1714
1832
  type: "text",
1715
- text: `${asked[0] ?? ""} is run by a person at a terminal, not through this server. Run hardfin ${asked[0] ?? ""} yourself, then call this tool again`
1833
+ text: `${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`
1716
1834
  }],
1717
1835
  isError: true
1718
1836
  };
@@ -1725,13 +1843,14 @@ async function toToolResult(params, run) {
1725
1843
  isError: outcome.code !== 0
1726
1844
  };
1727
1845
  }
1728
- /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
1729
- function toRequestId(line) {
1730
- try {
1731
- return JSON.parse(line).id ?? null;
1732
- } catch {
1733
- return null;
1734
- }
1846
+ /** The most a command line can carry, below what the operating system refuses outright. */
1847
+ const ARGUMENT_LIMIT = 1e5;
1848
+ /** toRefusedArgument names an argument a command line cannot carry, rather than failing later. */
1849
+ function toRefusedArgument(args) {
1850
+ const withNul = args.findIndex((argument) => argument.includes("\0"));
1851
+ if (withNul >= 0) return `args[${withNul}] holds a NUL byte, which a command line cannot carry`;
1852
+ const total = args.reduce((carried, argument) => carried + argument.length, 0);
1853
+ if (total > ARGUMENT_LIMIT) return `these arguments are ${total} characters, and a command line carries at most ${ARGUMENT_LIMIT}`;
1735
1854
  }
1736
1855
  /** toCliRunner runs the CLI itself, so a tool call parses exactly as a terminal would. */
1737
1856
  function toCliRunner() {
@@ -1760,24 +1879,65 @@ function toCliRunner() {
1760
1879
  /** serve answers requests on stdin until the client closes it. */
1761
1880
  async function serve(commands, run = toCliRunner()) {
1762
1881
  const lines = createInterface({ input: process.stdin });
1882
+ const answering = /* @__PURE__ */ new Set();
1763
1883
  for await (const line of lines) {
1764
1884
  if (line.trim() === "") continue;
1765
- let response;
1766
- try {
1767
- response = await toResponse(JSON.parse(line), commands, run);
1768
- } catch (error) {
1769
- response = {
1770
- jsonrpc: "2.0",
1771
- id: toRequestId(line),
1772
- error: {
1773
- code: -32603,
1774
- message: error instanceof Error ? error.message : String(error)
1775
- }
1776
- };
1885
+ const answered = toAnswer(line, commands, run).finally(() => answering.delete(answered));
1886
+ answering.add(answered);
1887
+ }
1888
+ await Promise.all(answering);
1889
+ }
1890
+ /** toAnswer replies to one line, and never rejects, because a rejection ends the server. */
1891
+ async function toAnswer(line, commands, run) {
1892
+ let response;
1893
+ try {
1894
+ response = await toResponse(toRequest(line), commands, run);
1895
+ } catch (error) {
1896
+ response = error instanceof ProtocolFailure ? toErrorResponse(error.id, error.code, error.message) : toErrorResponse(toRequestId(line), -32603, error instanceof Error ? error.message : String(error));
1897
+ }
1898
+ if (response) write(`${JSON.stringify(response)}\n`);
1899
+ }
1900
+ /** toRequest reads one line, refusing what the protocol does not allow to be a request. */
1901
+ function toRequest(line) {
1902
+ let parsed;
1903
+ try {
1904
+ parsed = JSON.parse(line);
1905
+ } catch {
1906
+ throw new ProtocolFailure(-32700, "this line is not JSON", null);
1907
+ }
1908
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new ProtocolFailure(-32600, "a request is a JSON object, and this server takes one per line", null);
1909
+ const request = parsed;
1910
+ if (typeof request.method !== "string") throw new ProtocolFailure(-32600, "a request names a method", toAllowedId(request.id));
1911
+ if (request.id !== void 0 && request.id !== null && typeof request.id !== "string" && typeof request.id !== "number") throw new ProtocolFailure(-32600, "an id is a string, a number, or null", null);
1912
+ return request;
1913
+ }
1914
+ function toAllowedId(id) {
1915
+ return typeof id === "string" || typeof id === "number" ? id : null;
1916
+ }
1917
+ function toErrorResponse(id, code, message) {
1918
+ return {
1919
+ jsonrpc: "2.0",
1920
+ id,
1921
+ error: {
1922
+ code,
1923
+ message
1777
1924
  }
1778
- if (response) process.stdout.write(`${JSON.stringify(response)}\n`);
1925
+ };
1926
+ }
1927
+ /** toRequestId reads the id of a line that could not be answered, so a client is not left waiting. */
1928
+ function toRequestId(line) {
1929
+ try {
1930
+ return toAllowedId(JSON.parse(line).id);
1931
+ } catch {
1932
+ return null;
1779
1933
  }
1780
1934
  }
1935
+ /** write puts a reply on stdout, and stops quietly when the client has gone. */
1936
+ function write(text) {
1937
+ try {
1938
+ process.stdout.write(text);
1939
+ } catch {}
1940
+ }
1781
1941
  //#endregion
1782
1942
  //#region src/command/mcp.ts
1783
1943
  const mcpCommand = defineCommand({
@@ -1879,6 +2039,10 @@ async function runOperation(operation, input) {
1879
2039
  writeFailure(body.message, input.isJSON);
1880
2040
  return ExitCode.USAGE;
1881
2041
  }
2042
+ if (operation.bodyFlags.length > 0 && body === void 0) {
2043
+ writeFailure(`this command changes nothing unless a flag is given, such as ${operation.bodyFlags.slice(0, 3).map((flag) => `--${flag.name}`).join(", ")}. Run it with --help for the rest`, input.isJSON);
2044
+ return ExitCode.USAGE;
2045
+ }
1882
2046
  let form;
1883
2047
  if (operation.upload) {
1884
2048
  const built = await toForm(operation.upload, input.flags);
@@ -1908,7 +2072,7 @@ async function runOperation(operation, input) {
1908
2072
  return ExitCode.NOT_AUTHENTICATED;
1909
2073
  }
1910
2074
  if (error instanceof RequestFailure) {
1911
- writeFailure(error.message, input.isJSON, error.errors, error.requestId);
2075
+ writeFailure(error.message, input.isJSON, error.errors, error.requestId, error.status);
1912
2076
  return error.status === 401 ? ExitCode.NOT_AUTHENTICATED : ExitCode.ERROR;
1913
2077
  }
1914
2078
  writeFailure(error instanceof Error ? error.message : String(error), input.isJSON);
@@ -2021,10 +2185,11 @@ function toElements(flag, values) {
2021
2185
  const element = {};
2022
2186
  for (const pair of value.split(",")) {
2023
2187
  const split = pair.indexOf("=");
2024
- if (split < 1) return /* @__PURE__ */ new Error(`--${flag.name} takes ${flag.element?.join("=, ")}=, such as --${flag.name} ${flag.element?.[0]}=value`);
2025
- const key = pair.slice(0, split);
2188
+ if (split < 1) return /* @__PURE__ */ new Error(`--${flag.name} takes ${flag.element?.join("=, ") ?? ""}=, such as --${flag.name} ${flag.element?.[0] ?? "field"}=value. A value cannot contain a comma`);
2189
+ const key = pair.slice(0, split).trim();
2026
2190
  if (flag.element && !flag.element.includes(key)) return /* @__PURE__ */ new Error(`--${flag.name} has no field ${key}. It takes ${flag.element.join(", ")}`);
2027
- element[key] = pair.slice(split + 1);
2191
+ if (key in element) return /* @__PURE__ */ new Error(`--${flag.name} names ${key} twice in one element, so which one it takes is not clear`);
2192
+ element[key] = pair.slice(split + 1).trim();
2028
2193
  }
2029
2194
  elements.push(element);
2030
2195
  }
@@ -2114,9 +2279,9 @@ const surfaceCommands = [
2114
2279
  name: "for-asset-id",
2115
2280
  queryName: "forAssetId",
2116
2281
  description: "The IDs of the assets to list",
2117
- valueName: "value",
2282
+ valueName: "uuid",
2118
2283
  repeatable: true,
2119
- schema: z.array(z.string())
2284
+ schema: z.array(z.uuid())
2120
2285
  },
2121
2286
  {
2122
2287
  name: "for-asset-key",
@@ -2130,33 +2295,33 @@ const surfaceCommands = [
2130
2295
  name: "for-customer",
2131
2296
  queryName: "forCustomer",
2132
2297
  description: "The IDs of the customers whose assets to list",
2133
- valueName: "value",
2298
+ valueName: "uuid",
2134
2299
  repeatable: true,
2135
- schema: z.array(z.string())
2300
+ schema: z.array(z.uuid())
2136
2301
  },
2137
2302
  {
2138
2303
  name: "for-item",
2139
2304
  queryName: "forItem",
2140
2305
  description: "The IDs of the items whose assets to list",
2141
- valueName: "value",
2306
+ valueName: "uuid",
2142
2307
  repeatable: true,
2143
- schema: z.array(z.string())
2308
+ schema: z.array(z.uuid())
2144
2309
  },
2145
2310
  {
2146
2311
  name: "at-site",
2147
2312
  queryName: "atSite",
2148
2313
  description: "The IDs of the locations whose assets to list",
2149
- valueName: "value",
2314
+ valueName: "uuid",
2150
2315
  repeatable: true,
2151
- schema: z.array(z.string())
2316
+ schema: z.array(z.uuid())
2152
2317
  },
2153
2318
  {
2154
2319
  name: "at-customer-sites",
2155
2320
  queryName: "atCustomerSites",
2156
2321
  description: "The IDs of the customers whose sites to list assets at",
2157
- valueName: "value",
2322
+ valueName: "uuid",
2158
2323
  repeatable: true,
2159
- schema: z.array(z.string())
2324
+ schema: z.array(z.uuid())
2160
2325
  },
2161
2326
  {
2162
2327
  name: "with-functional-statuses",
@@ -2220,9 +2385,9 @@ const surfaceCommands = [
2220
2385
  name: "for-project",
2221
2386
  queryName: "forProject",
2222
2387
  description: "The IDs of the projects whose assets to list",
2223
- valueName: "value",
2388
+ valueName: "uuid",
2224
2389
  repeatable: true,
2225
- schema: z.array(z.string())
2390
+ schema: z.array(z.uuid())
2226
2391
  },
2227
2392
  {
2228
2393
  name: "scrapped",
@@ -2241,7 +2406,7 @@ const surfaceCommands = [
2241
2406
  defineOperation({
2242
2407
  name: "create",
2243
2408
  summary: "Create asset",
2244
- example: "hardfin asset create --item-id <value> --serial <value>",
2409
+ example: "hardfin asset create --item-id <uuid> --serial <value>",
2245
2410
  method: "POST",
2246
2411
  path: "/asset",
2247
2412
  pathParameters: [],
@@ -2355,16 +2520,16 @@ const surfaceCommands = [
2355
2520
  name: "item-id",
2356
2521
  jsonPath: ["itemId"],
2357
2522
  description: "The ID of the catalog item the asset is a unit of",
2358
- valueName: "value",
2523
+ valueName: "uuid",
2359
2524
  required: true,
2360
- schema: z.string()
2525
+ schema: z.uuid()
2361
2526
  },
2362
2527
  {
2363
2528
  name: "location-id",
2364
2529
  jsonPath: ["locationId"],
2365
2530
  description: "The ID of the location the asset enters inventory at",
2366
- valueName: "value",
2367
- schema: z.string()
2531
+ valueName: "uuid",
2532
+ schema: z.uuid()
2368
2533
  },
2369
2534
  {
2370
2535
  name: "salvage-value",
@@ -2431,7 +2596,7 @@ const surfaceCommands = [
2431
2596
  subcommands: [defineOperation({
2432
2597
  name: "create",
2433
2598
  summary: "Execute asset move",
2434
- example: "hardfin asset move execute create",
2599
+ example: "hardfin asset move execute create --move assetId=<value>,deliverAt=<value>,deliverAtTimezone=<value>,destinationId=<value>,originId=<value>,shipAt=<value>,shipAtTimezone=<value>",
2435
2600
  method: "POST",
2436
2601
  path: "/asset/move/execute",
2437
2602
  pathParameters: [],
@@ -2440,7 +2605,7 @@ const surfaceCommands = [
2440
2605
  name: "move",
2441
2606
  jsonPath: ["moves"],
2442
2607
  description: "The moves to carry out",
2443
- valueName: "assetId=,deliverAt=",
2608
+ valueName: "assetId=,deliverAt=,deliverAtTimezone=,destinationId=,originId=,shipAt=,shipAtTimezone=",
2444
2609
  repeatable: true,
2445
2610
  element: [
2446
2611
  "assetId",
@@ -2463,7 +2628,7 @@ const surfaceCommands = [
2463
2628
  subcommands: [defineOperation({
2464
2629
  name: "create",
2465
2630
  summary: "Plan asset move",
2466
- example: "hardfin asset move plan create",
2631
+ example: "hardfin asset move plan create --move assetId=<value>,deliverAt=<value>,id=<value>,shipAt=<value>",
2467
2632
  method: "POST",
2468
2633
  path: "/asset/move/plan",
2469
2634
  pathParameters: [],
@@ -2472,7 +2637,7 @@ const surfaceCommands = [
2472
2637
  name: "move",
2473
2638
  jsonPath: ["moves"],
2474
2639
  description: "The moves to plan",
2475
- valueName: "assetId=,deliverAt=",
2640
+ valueName: "assetId=,deliverAt=,id=,shipAt=",
2476
2641
  repeatable: true,
2477
2642
  element: [
2478
2643
  "assetId",
@@ -2502,7 +2667,7 @@ const surfaceCommands = [
2502
2667
  defineOperation({
2503
2668
  name: "update",
2504
2669
  summary: "Patch asset",
2505
- example: "hardfin asset update ast_4f9xk2mq7plr8stz",
2670
+ example: "hardfin asset update ast_4f9xk2mq7plr8stz --metadata fieldId=<value>,value=<value>",
2506
2671
  method: "PATCH",
2507
2672
  path: "/asset/{assetKey}",
2508
2673
  pathParameters: [{
@@ -2545,9 +2710,9 @@ const surfaceCommands = [
2545
2710
  name: "initial-location-id",
2546
2711
  jsonPath: ["initialLocationId"],
2547
2712
  description: "The ID of the location the asset entered inventory at",
2548
- valueName: "value",
2713
+ valueName: "uuid",
2549
2714
  nullable: true,
2550
- schema: z.string()
2715
+ schema: z.uuid()
2551
2716
  },
2552
2717
  {
2553
2718
  name: "metadata",
@@ -2967,7 +3132,7 @@ const surfaceCommands = [
2967
3132
  defineOperation({
2968
3133
  name: "create",
2969
3134
  summary: "Create asset ownership",
2970
- example: "hardfin asset ownership create ast_4f9xk2mq7plr8stz --customer-id <value> --date <value>",
3135
+ example: "hardfin asset ownership create ast_4f9xk2mq7plr8stz --customer-id <uuid> --date <value>",
2971
3136
  method: "POST",
2972
3137
  path: "/asset/{assetKey}/ownership",
2973
3138
  pathParameters: [{
@@ -2981,9 +3146,9 @@ const surfaceCommands = [
2981
3146
  name: "customer-id",
2982
3147
  jsonPath: ["customerId"],
2983
3148
  description: "The ID of the customer that takes ownership of the asset",
2984
- valueName: "value",
3149
+ valueName: "uuid",
2985
3150
  required: true,
2986
- schema: z.string()
3151
+ schema: z.uuid()
2987
3152
  },
2988
3153
  {
2989
3154
  name: "date",
@@ -3063,9 +3228,9 @@ const surfaceCommands = [
3063
3228
  name: "customer-id",
3064
3229
  jsonPath: ["customerId"],
3065
3230
  description: "The ID of the customer that owned the asset during the segment",
3066
- valueName: "value",
3231
+ valueName: "uuid",
3067
3232
  nullable: true,
3068
- schema: z.string()
3233
+ schema: z.uuid()
3069
3234
  },
3070
3235
  {
3071
3236
  name: "date",
@@ -3585,7 +3750,7 @@ const surfaceCommands = [
3585
3750
  subcommands: [defineOperation({
3586
3751
  name: "create",
3587
3752
  summary: "Upload file",
3588
- example: "hardfin file create --file-type <value> --for-entity <value> --file photo.jpg",
3753
+ example: "hardfin file create --file-type <value> --for-entity <uuid> --file photo.jpg",
3589
3754
  method: "POST",
3590
3755
  path: "/file",
3591
3756
  pathParameters: [],
@@ -3606,9 +3771,9 @@ const surfaceCommands = [
3606
3771
  name: "for-entity",
3607
3772
  jsonPath: ["forEntity"],
3608
3773
  description: "The ID of the asset the file is attached to",
3609
- valueName: "value",
3774
+ valueName: "uuid",
3610
3775
  required: true,
3611
- schema: z.string()
3776
+ schema: z.uuid()
3612
3777
  },
3613
3778
  {
3614
3779
  name: "is-public",
@@ -3862,7 +4027,7 @@ const surfaceCommands = [
3862
4027
  defineOperation({
3863
4028
  name: "update",
3864
4029
  summary: "Update item",
3865
- example: "hardfin item update item_7Hq2Lm9XcR4tWz8K",
4030
+ example: "hardfin item update item_7Hq2Lm9XcR4tWz8K --field fieldId=<value>,order=<value>,section=<value>",
3866
4031
  method: "PATCH",
3867
4032
  path: "/item/{itemKey}",
3868
4033
  pathParameters: [{
@@ -3892,7 +4057,7 @@ const surfaceCommands = [
3892
4057
  name: "field",
3893
4058
  jsonPath: ["fields"],
3894
4059
  description: "New positions for a DEVICE item's fields",
3895
- valueName: "fieldId=,order=",
4060
+ valueName: "fieldId=,order=,section=",
3896
4061
  repeatable: true,
3897
4062
  element: [
3898
4063
  "fieldId",
@@ -4351,17 +4516,17 @@ const surfaceCommands = [
4351
4516
  name: "consignee",
4352
4517
  jsonPath: ["consignee"],
4353
4518
  description: "The ID of the customer a zone is designated for, such as for reservations, provisioning or a 3PL",
4354
- valueName: "value",
4519
+ valueName: "uuid",
4355
4520
  nullable: true,
4356
- schema: z.string()
4521
+ schema: z.uuid()
4357
4522
  },
4358
4523
  {
4359
4524
  name: "customer-id",
4360
4525
  jsonPath: ["customerId"],
4361
4526
  description: "The ID of the customer to assign a site to, or null for your organization's own site",
4362
- valueName: "value",
4527
+ valueName: "uuid",
4363
4528
  nullable: true,
4364
- schema: z.string()
4529
+ schema: z.uuid()
4365
4530
  },
4366
4531
  {
4367
4532
  name: "description",
@@ -4403,9 +4568,9 @@ const surfaceCommands = [
4403
4568
  name: "parent-location-id",
4404
4569
  jsonPath: ["parentLocationId"],
4405
4570
  description: "The ID of the site a zone belongs to, required for a zone and refused for a site",
4406
- valueName: "value",
4571
+ valueName: "uuid",
4407
4572
  nullable: true,
4408
- schema: z.string()
4573
+ schema: z.uuid()
4409
4574
  },
4410
4575
  {
4411
4576
  name: "type",
@@ -4507,17 +4672,17 @@ const surfaceCommands = [
4507
4672
  name: "consignee",
4508
4673
  jsonPath: ["consignee"],
4509
4674
  description: "The ID of the customer a zone is designated for, such as for reservations, provisioning or a 3PL",
4510
- valueName: "value",
4675
+ valueName: "uuid",
4511
4676
  nullable: true,
4512
- schema: z.string()
4677
+ schema: z.uuid()
4513
4678
  },
4514
4679
  {
4515
4680
  name: "customer-id",
4516
4681
  jsonPath: ["customerId"],
4517
4682
  description: "The ID of the customer to assign a site to, or null for your organization's own site",
4518
- valueName: "value",
4683
+ valueName: "uuid",
4519
4684
  nullable: true,
4520
- schema: z.string()
4685
+ schema: z.uuid()
4521
4686
  },
4522
4687
  {
4523
4688
  name: "description",
@@ -5030,6 +5195,10 @@ function collect(value, previous) {
5030
5195
  }
5031
5196
  //#endregion
5032
5197
  //#region src/cli.ts
5198
+ process.stdout.on("error", (error) => {
5199
+ if (error.code === "EPIPE") process.exit(0);
5200
+ throw error;
5201
+ });
5033
5202
  await toCli().parseAsync(process.argv);
5034
5203
  //#endregion
5035
5204
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.1.0-dev.19",
3
+ "version": "0.1.0-dev.20",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",