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

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 +7 -0
  2. package/dist/cli.js +129 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -71,6 +71,13 @@ A value is sent as the type the document names. `--useful-life 36` sends the num
71
71
  decimal such as `--salvage-value 1500.00` stays a string, which is how the API takes money
72
72
  without losing precision.
73
73
 
74
+ A download is written where you name it, and a terminal is never filled with a file's bytes.
75
+
76
+ ```sh
77
+ hardfin file get file_7hq2mx9pkr4stz8w --output photo.jpg
78
+ hardfin file get file_7hq2mx9pkr4stz8w --output - | wc -c
79
+ ```
80
+
74
81
  Clear a field with `--unset`, naming the flag:
75
82
 
76
83
  ```sh
package/dist/cli.js CHANGED
@@ -136,7 +136,7 @@ function writeData(value) {
136
136
  process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
137
137
  return;
138
138
  }
139
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
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
142
  function writeFailure(message, isJSON, errors, requestId) {
@@ -258,7 +258,8 @@ function toSummary(command) {
258
258
  valueName: flag.valueName ?? null,
259
259
  repeatable: flag.repeatable ?? false
260
260
  })),
261
- examples: command.examples
261
+ examples: command.examples,
262
+ subcommands: command.subcommands?.filter((entry) => !entry.hidden).map(toSummary)
262
263
  };
263
264
  }
264
265
  //#endregion
@@ -719,11 +720,11 @@ var RequestFailure = class extends Error {
719
720
  /** request calls one /v2 endpoint and returns the envelope it answered with. */
720
721
  async function request(options) {
721
722
  const url = new URL(`${options.apiUrl}${toLeadingSlash(options.path)}`);
722
- if (options.query) url.search = options.query.toString();
723
+ for (const [name, value] of options.query ?? []) url.searchParams.append(name, value);
723
724
  const headers = {
724
725
  [options.credential.header]: options.credential.value,
725
726
  "X-API-Version": API_VERSION,
726
- Accept: "application/json"
727
+ Accept: options.downloads ? "*/*" : "application/json"
727
728
  };
728
729
  if (options.body !== void 0 && options.form === void 0) headers["Content-Type"] = "application/json";
729
730
  const response = await fetch(url, {
@@ -731,6 +732,11 @@ async function request(options) {
731
732
  headers,
732
733
  body: options.form ?? (options.body === void 0 ? void 0 : JSON.stringify(options.body))
733
734
  });
735
+ if (options.downloads && response.ok) return { data: {
736
+ bytes: Buffer.from(await response.arrayBuffer()),
737
+ contentType: response.headers.get("content-type"),
738
+ fileName: toFileName(response.headers.get("content-disposition"))
739
+ } };
734
740
  const envelope = toEnvelope(await response.text());
735
741
  if (!response.ok && envelope === void 0) throw new RequestFailure(response.status, [{
736
742
  error: toStatusMessage(response.status),
@@ -745,6 +751,11 @@ async function request(options) {
745
751
  }
746
752
  return envelope ?? { data: null };
747
753
  }
754
+ /** toFileName reads the name a download was offered under, when the server names one. */
755
+ function toFileName(disposition) {
756
+ const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition ?? "");
757
+ return matched?.[1] ? decodeURIComponent(matched[1]) : void 0;
758
+ }
748
759
  function toLeadingSlash(path) {
749
760
  return path.startsWith("/") ? path : `/${path}`;
750
761
  }
@@ -1090,8 +1101,8 @@ function toOpeners(url) {
1090
1101
  return [["xdg-open", [url]]];
1091
1102
  }
1092
1103
  /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1093
- function openBrowser(url) {
1094
- for (const [command, args] of toOpeners(url)) if (spawnSync(command, args, {
1104
+ function openBrowser(url, launch = spawnSync) {
1105
+ for (const [command, args] of toOpeners(url)) if (launch(command, args, {
1095
1106
  stdio: "ignore",
1096
1107
  cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1097
1108
  timeout: 1e4
@@ -1261,11 +1272,11 @@ function toClipboardCommand() {
1261
1272
  return ["xclip", ["-selection", "clipboard"]];
1262
1273
  }
1263
1274
  /** copyToClipboard puts text on the clipboard, and reports whether anything took it. */
1264
- function copyToClipboard(text) {
1275
+ function copyToClipboard(text, copy = spawnSync) {
1265
1276
  const command = toClipboardCommand();
1266
1277
  if (!command) return false;
1267
1278
  const [name, args] = command;
1268
- const result = spawnSync(name, args, { input: text });
1279
+ const result = copy(name, args, { input: text });
1269
1280
  return result.error === void 0 && result.status === 0;
1270
1281
  }
1271
1282
  //#endregion
@@ -1301,14 +1312,13 @@ function toPastedCallback(pasted) {
1301
1312
  * toPrompt watches the keyboard while the browser is away. Pressing c copies the URL, and
1302
1313
  * pasting a code finishes the sign in on a machine whose browser cannot reach this listener.
1303
1314
  */
1304
- function toPrompt(url) {
1315
+ function toPrompt(url, input = process.stdin, copy = copyToClipboard) {
1305
1316
  let settle = () => {};
1306
1317
  let fail = () => {};
1307
1318
  const pasted = new Promise((resolve, reject) => {
1308
1319
  settle = resolve;
1309
1320
  fail = reject;
1310
1321
  });
1311
- const input = process.stdin;
1312
1322
  pasted.catch(() => {});
1313
1323
  if (!input.isTTY) return {
1314
1324
  pasted,
@@ -1316,6 +1326,7 @@ function toPrompt(url) {
1316
1326
  };
1317
1327
  let typed = "";
1318
1328
  const onData = (chunk) => {
1329
+ const isKeystroke = chunk.length === 1;
1319
1330
  for (const character of chunk) {
1320
1331
  if (character === CTRL_C) {
1321
1332
  fail(/* @__PURE__ */ new Error("sign in was cancelled"));
@@ -1332,8 +1343,8 @@ function toPrompt(url) {
1332
1343
  process.stderr.write("\b \b");
1333
1344
  continue;
1334
1345
  }
1335
- if ((character === "c" || character === "C") && typed === "") {
1336
- process.stderr.write(copyToClipboard(url) ? "Copied the URL to your clipboard\n" : "Nothing on this host takes a clipboard\n");
1346
+ if (isKeystroke && (character === "c" || character === "C") && typed === "") {
1347
+ process.stderr.write(copy(url) ? "Copied the URL to your clipboard\n" : "Nothing on this host takes a clipboard\n");
1337
1348
  continue;
1338
1349
  }
1339
1350
  typed += character;
@@ -1674,7 +1685,21 @@ function toTree(commands) {
1674
1685
  subcommands: command.subcommands ? toTree(command.subcommands) : void 0
1675
1686
  }));
1676
1687
  }
1688
+ /** Signing in needs a browser and a person, neither of which an agent's session has. */
1689
+ const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
1690
+ "login",
1691
+ "logout",
1692
+ "mcp"
1693
+ ]);
1677
1694
  async function toToolResult(params, run) {
1695
+ const name = params?.["name"];
1696
+ if (name !== void 0 && name !== TOOL.name) return {
1697
+ content: [{
1698
+ type: "text",
1699
+ text: `this server offers one tool, ${TOOL.name}`
1700
+ }],
1701
+ isError: true
1702
+ };
1678
1703
  const args = (params?.["arguments"])?.args;
1679
1704
  if (!Array.isArray(args) || args.some((entry) => typeof entry !== "string")) return {
1680
1705
  content: [{
@@ -1683,7 +1708,15 @@ async function toToolResult(params, run) {
1683
1708
  }],
1684
1709
  isError: true
1685
1710
  };
1686
- const outcome = await run(args);
1711
+ const asked = args;
1712
+ if (REFUSED_COMMANDS.has(asked[0] ?? "")) return {
1713
+ content: [{
1714
+ 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`
1716
+ }],
1717
+ isError: true
1718
+ };
1719
+ const outcome = await run(asked);
1687
1720
  return {
1688
1721
  content: [{
1689
1722
  type: "text",
@@ -1692,6 +1725,14 @@ async function toToolResult(params, run) {
1692
1725
  isError: outcome.code !== 0
1693
1726
  };
1694
1727
  }
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
+ }
1735
+ }
1695
1736
  /** toCliRunner runs the CLI itself, so a tool call parses exactly as a terminal would. */
1696
1737
  function toCliRunner() {
1697
1738
  return (args) => new Promise((resolve) => {
@@ -1727,9 +1768,9 @@ async function serve(commands, run = toCliRunner()) {
1727
1768
  } catch (error) {
1728
1769
  response = {
1729
1770
  jsonrpc: "2.0",
1730
- id: null,
1771
+ id: toRequestId(line),
1731
1772
  error: {
1732
- code: -32700,
1773
+ code: -32603,
1733
1774
  message: error instanceof Error ? error.message : String(error)
1734
1775
  }
1735
1776
  };
@@ -1784,6 +1825,12 @@ const FILE_FLAG = {
1784
1825
  valueName: "path",
1785
1826
  schema: z.string()
1786
1827
  };
1828
+ const OUTPUT_FLAG = {
1829
+ name: "output",
1830
+ description: "Where to write the file, or - for standard output",
1831
+ valueName: "path",
1832
+ schema: z.string()
1833
+ };
1787
1834
  const JSON_FLAG = {
1788
1835
  name: "json",
1789
1836
  description: "Print machine-readable output, which is the default when stdout is not a terminal",
@@ -1796,6 +1843,7 @@ function defineOperation(operation) {
1796
1843
  ...operation.bodyFlags,
1797
1844
  ...operation.bodyFlags.some((flag) => flag.nullable) ? [UNSET_FLAG] : [],
1798
1845
  ...operation.upload ? [...operation.upload.fields, FILE_FLAG] : [],
1846
+ ...operation.downloads ? [OUTPUT_FLAG] : [],
1799
1847
  JSON_FLAG
1800
1848
  ];
1801
1849
  return {
@@ -1841,15 +1889,18 @@ async function runOperation(operation, input) {
1841
1889
  form = built;
1842
1890
  }
1843
1891
  try {
1844
- writeData((await request({
1892
+ const envelope = await request({
1845
1893
  apiUrl: input.resolved.settings.apiUrl,
1846
1894
  credential: await toRequestCredential(input.resolved.settings),
1847
1895
  method: operation.method,
1848
1896
  path,
1849
1897
  query: toQuery(operation, input.flags),
1850
1898
  body,
1851
- form
1852
- })).data);
1899
+ form,
1900
+ downloads: operation.downloads
1901
+ });
1902
+ if (operation.downloads) return toWritten(envelope.data, input);
1903
+ writeData(envelope.data);
1853
1904
  return ExitCode.OK;
1854
1905
  } catch (error) {
1855
1906
  if (error instanceof NoCredential) {
@@ -1864,6 +1915,29 @@ async function runOperation(operation, input) {
1864
1915
  return ExitCode.ERROR;
1865
1916
  }
1866
1917
  }
1918
+ /**
1919
+ * toWritten puts a downloaded file where it was asked for. A terminal is never written to,
1920
+ * because a person would otherwise have their session filled with a file's bytes.
1921
+ */
1922
+ function toWritten(download, input) {
1923
+ const asked = input.flags["output"];
1924
+ const path = typeof asked === "string" ? asked : download.fileName ?? "-";
1925
+ if (path === "-") {
1926
+ if (process.stdout.isTTY) {
1927
+ writeFailure("this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
1928
+ return ExitCode.USAGE;
1929
+ }
1930
+ process.stdout.write(download.bytes);
1931
+ return ExitCode.OK;
1932
+ }
1933
+ writeFileSync(path, download.bytes);
1934
+ writeData({
1935
+ written: path,
1936
+ bytes: download.bytes.length,
1937
+ contentType: download.contentType
1938
+ });
1939
+ return ExitCode.OK;
1940
+ }
1867
1941
  /** toPath fills the path template from the positional arguments, in order. */
1868
1942
  function toPath(operation, args) {
1869
1943
  if (args.length !== operation.pathParameters.length) return;
@@ -2593,6 +2667,7 @@ const surfaceCommands = [
2593
2667
  name: "is-in-service-date-managed-automatically",
2594
2668
  jsonPath: ["isInServiceDateManagedAutomatically"],
2595
2669
  description: "Whether Hardfin sets the in-service date itself, which sending an in-service date turns off",
2670
+ negatable: true,
2596
2671
  nullable: true,
2597
2672
  schema: z.boolean()
2598
2673
  },
@@ -2659,6 +2734,7 @@ const surfaceCommands = [
2659
2734
  name: "automatic",
2660
2735
  jsonPath: ["automatic"],
2661
2736
  description: "Whether Hardfin sets the asset's in-service date itself",
2737
+ negatable: true,
2662
2738
  required: true,
2663
2739
  schema: z.boolean()
2664
2740
  }]
@@ -3361,12 +3437,14 @@ const surfaceCommands = [
3361
3437
  name: "is-customer",
3362
3438
  jsonPath: ["isCustomer"],
3363
3439
  description: "Whether the company is a customer",
3440
+ negatable: true,
3364
3441
  schema: z.boolean()
3365
3442
  },
3366
3443
  {
3367
3444
  name: "is-supplier",
3368
3445
  jsonPath: ["isSupplier"],
3369
3446
  description: "Whether the company is a supplier",
3447
+ negatable: true,
3370
3448
  schema: z.boolean()
3371
3449
  },
3372
3450
  {
@@ -3466,6 +3544,7 @@ const surfaceCommands = [
3466
3544
  name: "is-archived",
3467
3545
  jsonPath: ["isArchived"],
3468
3546
  description: "Whether the customer is archived",
3547
+ negatable: true,
3469
3548
  nullable: true,
3470
3549
  schema: z.boolean()
3471
3550
  },
@@ -3473,6 +3552,7 @@ const surfaceCommands = [
3473
3552
  name: "is-customer",
3474
3553
  jsonPath: ["isCustomer"],
3475
3554
  description: "Whether the company is a customer",
3555
+ negatable: true,
3476
3556
  nullable: true,
3477
3557
  schema: z.boolean()
3478
3558
  },
@@ -3480,6 +3560,7 @@ const surfaceCommands = [
3480
3560
  name: "is-supplier",
3481
3561
  jsonPath: ["isSupplier"],
3482
3562
  description: "Whether the company is a supplier",
3563
+ negatable: true,
3483
3564
  nullable: true,
3484
3565
  schema: z.boolean()
3485
3566
  },
@@ -3554,7 +3635,8 @@ const surfaceCommands = [
3554
3635
  description: "True when the file downloads as an attachment rather than opening inline",
3555
3636
  schema: z.boolean()
3556
3637
  }],
3557
- bodyFlags: []
3638
+ bodyFlags: [],
3639
+ downloads: true
3558
3640
  })]
3559
3641
  },
3560
3642
  {
@@ -3657,6 +3739,7 @@ const surfaceCommands = [
3657
3739
  name: "accepts-bulk-serials",
3658
3740
  jsonPath: ["acceptsBulkSerials"],
3659
3741
  description: "Whether a BULK item records serial numbers on its units, which SERVICE and DEVICE items ignore",
3742
+ negatable: true,
3660
3743
  schema: z.boolean()
3661
3744
  },
3662
3745
  {
@@ -3793,6 +3876,7 @@ const surfaceCommands = [
3793
3876
  name: "accepts-bulk-serials",
3794
3877
  jsonPath: ["acceptsBulkSerials"],
3795
3878
  description: "Whether a BULK item records serial numbers on its units, read only beside type",
3879
+ negatable: true,
3796
3880
  nullable: true,
3797
3881
  schema: z.boolean()
3798
3882
  },
@@ -3821,6 +3905,7 @@ const surfaceCommands = [
3821
3905
  name: "is-archived",
3822
3906
  jsonPath: ["isArchived"],
3823
3907
  description: "Whether the item is archived, which cannot be null",
3908
+ negatable: true,
3824
3909
  nullable: true,
3825
3910
  schema: z.boolean()
3826
3911
  },
@@ -4290,18 +4375,21 @@ const surfaceCommands = [
4290
4375
  name: "is-inventory",
4291
4376
  jsonPath: ["isInventory"],
4292
4377
  description: "Whether assets at the location count as inventory for reporting",
4378
+ negatable: true,
4293
4379
  schema: z.boolean()
4294
4380
  },
4295
4381
  {
4296
4382
  name: "is-inventory-override",
4297
4383
  jsonPath: ["isInventoryOverride"],
4298
4384
  description: "Whether a zone sets its own isInventory rather than inheriting its parent site's, which a site refuses",
4385
+ negatable: true,
4299
4386
  schema: z.boolean()
4300
4387
  },
4301
4388
  {
4302
4389
  name: "is-transient",
4303
4390
  jsonPath: ["isTransient"],
4304
4391
  description: "Whether assets make only occasional or temporary stops at the location, which hides it from location lists by default",
4392
+ negatable: true,
4305
4393
  schema: z.boolean()
4306
4394
  },
4307
4395
  {
@@ -4443,6 +4531,7 @@ const surfaceCommands = [
4443
4531
  name: "is-archived",
4444
4532
  jsonPath: ["isArchived"],
4445
4533
  description: "Whether the location is archived, and archiving a site archives its zones",
4534
+ negatable: true,
4446
4535
  nullable: true,
4447
4536
  schema: z.boolean()
4448
4537
  },
@@ -4450,6 +4539,7 @@ const surfaceCommands = [
4450
4539
  name: "is-inventory",
4451
4540
  jsonPath: ["isInventory"],
4452
4541
  description: "Whether assets at the location count as inventory for reporting",
4542
+ negatable: true,
4453
4543
  nullable: true,
4454
4544
  schema: z.boolean()
4455
4545
  },
@@ -4457,6 +4547,7 @@ const surfaceCommands = [
4457
4547
  name: "is-inventory-override",
4458
4548
  jsonPath: ["isInventoryOverride"],
4459
4549
  description: "Whether a zone sets its own isInventory rather than inheriting its parent site's, which a site refuses",
4550
+ negatable: true,
4460
4551
  nullable: true,
4461
4552
  schema: z.boolean()
4462
4553
  },
@@ -4464,6 +4555,7 @@ const surfaceCommands = [
4464
4555
  name: "is-transient",
4465
4556
  jsonPath: ["isTransient"],
4466
4557
  description: "Whether assets make only occasional or temporary stops at the location, which hides it from location lists by default",
4558
+ negatable: true,
4467
4559
  nullable: true,
4468
4560
  schema: z.boolean()
4469
4561
  },
@@ -4871,13 +4963,16 @@ function toRejectedFlag(command, flags) {
4871
4963
  }
4872
4964
  }
4873
4965
  //#endregion
4874
- //#region src/cli.ts
4875
- const program = new Command();
4876
- program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").option("--api-url <url>", "The API to call, whose host also holds the authorization server").option("--issuer-url <url>", "The authorization server, when it does not sit at the API's host").showHelpAfterError().enablePositionalOptions();
4877
- for (const command of commands) program.addCommand(toProgram(command), { hidden: command.hidden });
4878
- await program.parseAsync(process.argv);
4966
+ //#region src/cli/program.ts
4967
+ /** toCli builds the parser from the registry, which is what every surface reads. */
4968
+ function toCli(entries = commands) {
4969
+ const program = new Command();
4970
+ program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").option("--api-url <url>", "The API to call, whose host also holds the authorization server").option("--issuer-url <url>", "The authorization server, when it does not sit at the API's host").showHelpAfterError().enablePositionalOptions();
4971
+ for (const command of entries) program.addCommand(toProgram(program, command), { hidden: command.hidden });
4972
+ return program;
4973
+ }
4879
4974
  /** toProgram wires one registry command into the parser. */
4880
- function toProgram(command) {
4975
+ function toProgram(root, command) {
4881
4976
  const program = new Command(command.name).summary(command.summary).description(command.description ?? command.summary);
4882
4977
  for (const argument of command.arguments) {
4883
4978
  const name = argument.variadic ? `${argument.name}...` : argument.name;
@@ -4887,21 +4982,22 @@ function toProgram(command) {
4887
4982
  const short = flag.short ? `-${flag.short}, ` : "";
4888
4983
  const value = flag.valueName ? ` <${flag.valueName}>` : "";
4889
4984
  const option = new Option(`${short}--${flag.name}${value}`, flag.description);
4985
+ if (flag.negatable) program.addOption(new Option(`--no-${flag.name}`, `${flag.description}, turned off`));
4890
4986
  if (flag.repeatable) option.argParser(collect);
4891
4987
  if (flag.defaultValue !== void 0) option.default(flag.defaultValue);
4892
4988
  program.addOption(option);
4893
4989
  }
4894
4990
  for (const example of command.examples) program.addHelpText("after", `\n${example.description}:\n $ ${example.command}`);
4895
- for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(subcommand), { hidden: subcommand.hidden });
4991
+ for (const subcommand of command.subcommands ?? []) program.addCommand(toProgram(root, subcommand), { hidden: subcommand.hidden });
4896
4992
  if (!command.run) return program;
4897
4993
  program.action(async (...parsed) => {
4898
4994
  const flags = parsed[parsed.length - 2] ?? {};
4899
4995
  const args = parsed.slice(0, parsed.length - 2).flatMap(toArgumentList);
4900
- process.exitCode = await toExitCode(command, args, flags);
4996
+ process.exitCode = await toExitCode(root, command, args, flags);
4901
4997
  });
4902
4998
  return program;
4903
4999
  }
4904
- async function toExitCode(command, args, flags) {
5000
+ async function toExitCode(root, command, args, flags) {
4905
5001
  const isJSON = isJSONOutput(flags);
4906
5002
  const rejected = toRejectedFlag(command, flags);
4907
5003
  if (rejected) {
@@ -4910,8 +5006,8 @@ async function toExitCode(command, args, flags) {
4910
5006
  }
4911
5007
  try {
4912
5008
  const resolved = toSettings({
4913
- apiUrl: program.opts()["apiUrl"],
4914
- issuerUrl: program.opts()["issuerUrl"]
5009
+ apiUrl: root.opts()["apiUrl"],
5010
+ issuerUrl: root.opts()["issuerUrl"]
4915
5011
  });
4916
5012
  return await command.run?.({
4917
5013
  args,
@@ -4933,4 +5029,7 @@ function collect(value, previous) {
4933
5029
  return [...previous ?? [], value];
4934
5030
  }
4935
5031
  //#endregion
5032
+ //#region src/cli.ts
5033
+ await toCli().parseAsync(process.argv);
5034
+ //#endregion
4936
5035
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.0.2-dev.16",
3
+ "version": "0.0.2-dev.18",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",