@hardfin/cli 0.1.1-dev.42 → 0.1.1-dev.44

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.
package/README.md CHANGED
@@ -71,6 +71,9 @@ decimal such as `--salvage-value 1500.00` stays a string, which is how the API t
71
71
  without losing precision.
72
72
 
73
73
  A download is written where you name it, and a terminal is never filled with a file's bytes.
74
+ With no `--output`, the file takes the name it was uploaded under, in the working directory.
75
+ That name never chooses a folder, a hidden file, or a file that is already there. The command
76
+ asks for `--output` in those cases.
74
77
 
75
78
  ```sh
76
79
  hardfin file get file_7hq2mx9pkr4stz8w --output photo.jpg
@@ -216,6 +219,11 @@ and the redirect it failed to follow is still in the address bar.
216
219
  On WSL the browser is opened through Windows, by `wslview`, then PowerShell, then
217
220
  `explorer.exe`, because `xdg-open` reaches nothing outside the distribution.
218
221
 
222
+ The browser is only ever sent to an https address, or to this machine. The address comes from
223
+ the authorization server, so one with any other scheme is printed for you to read and never
224
+ opened. The listener takes only a response that carries the state this sign in sent, and
225
+ turns any other request away without ending the sign in.
226
+
219
227
  The port changes every time, so Hardfin matches a loopback redirect by everything except its
220
228
  port, which is what RFC 8252 asks of an authorization server.
221
229
 
@@ -401,6 +409,17 @@ exit codes. Each call has these limits:
401
409
  | Commands running | 4 at once | The next call waits for one to finish |
402
410
  | One argument | 128 KiB | Refused, pointing at the `input` argument |
403
411
  | Standard input | The call's `input` argument, or nothing | Always closed, so `--input -` never waits |
412
+ | One result | 200,000 characters | Cut, with a line saying it is not complete |
413
+
414
+ A failed call also carries the failure as `structuredContent`, with the same `code`,
415
+ `message`, `errors`, `requestId` and `status` the CLI prints, plus `exitCode`. A file that is
416
+ not text is refused through `--output -`, because handing it over as text would change its
417
+ bytes.
418
+
419
+ Renewing a sign in gives the authorization server 15 seconds for its whole answer. That keeps
420
+ a renewal well inside the 120 seconds a call has, so a slow renewal fails as `timed_out` and
421
+ is not stopped partway. A refresh token the server rotated and never finished sending is
422
+ still lost, which no client can prevent.
404
423
 
405
424
  ### What a call may touch on this machine
406
425
 
@@ -416,6 +435,9 @@ held to rules a person at a terminal is not.
416
435
  | Downloads with `--output <path>` | A write, so it runs through `hardfin_write` |
417
436
  | Downloads with no `--output` | Refused, naming `--output -` to read the file in place |
418
437
 
438
+ `hardfin init` is held to the same folder. `hardfin completion install` and `uninstall` are
439
+ refused, along with `login`, `logout` and `mcp`, because a person runs those themselves.
440
+
419
441
  The rules cover every flag that takes a path: `--input`, `--file` and `--output`. A person
420
442
  runs the command themselves for a file the server will not reach.
421
443
 
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c2c38fd6-1925-561f-baf9-51faf8c4c4b8")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="641b3aa6-1d59-5f9c-b2f3-0bf132c35831")}catch(e){}}();
4
4
  import module from "node:module";
5
5
  //#region src/cli.ts
6
6
  module.enableCompileCache?.();
@@ -14,7 +14,7 @@ process.on("unhandledRejection", (error) => void toCrash(error));
14
14
  try {
15
15
  const answer = await toQuickAnswer(process.argv.slice(2));
16
16
  if (answer === void 0) {
17
- const { toCli, toParseExitCode, wasReportAttempted } = await import("./program-CqO1St9H.js");
17
+ const { toCli, toParseExitCode, wasReportAttempted } = await import("./program-CgT1Kr8L.js");
18
18
  try {
19
19
  await toCli().parseAsync(process.argv);
20
20
  } catch (error) {
@@ -33,7 +33,7 @@ async function toCrash(error) {
33
33
  isCrashing = true;
34
34
  process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
35
35
  try {
36
- const { toReported } = await import("./program-CqO1St9H.js");
36
+ const { toReported } = await import("./program-CgT1Kr8L.js");
37
37
  await toReported(error, false, void 0, "fatal");
38
38
  } finally {
39
39
  process.exit(1);
@@ -51,4 +51,4 @@ async function toQuickAnswer(args) {
51
51
  export {};
52
52
 
53
53
  //# sourceMappingURL=cli.js.map
54
- //# debugId=c2c38fd6-1925-561f-baf9-51faf8c4c4b8
54
+ //# debugId=641b3aa6-1d59-5f9c-b2f3-0bf132c35831
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0a793a7f-2311-5aaf-b27d-0877574e2d18")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="2c607b02-61ea-5376-957f-d434d5692ac5")}catch(e){}}();
4
4
  import { a as defineCommand, i as GLOBAL_FLAGS, r as ExitCode, t as toInvocation } from "./scrub-BN3gyBpR.js";
5
5
  import { n as toAnswer$1, r as toCompletion, t as PATH_VALUES } from "./completion-tree-DLC6ARLR.js";
6
6
  import { createRequire } from "node:module";
@@ -342,6 +342,12 @@ const REASONS = {
342
342
  detail: "not an HTTPS server"
343
343
  }
344
344
  };
345
+ /**
346
+ * How long the authorization server may take over its whole answer. Renewing a sign in comes
347
+ * before the request a command was asked to make, so a slow renewal has to end, and say so,
348
+ * well before a caller's own limit stops the command without a word.
349
+ */
350
+ const SIGN_IN_TIMEOUT_MS = 15e3;
345
351
  /** How long a server may take to begin its answer, before the request is given up on. */
346
352
  const ANSWER_TIMEOUT_MS = 6e4;
347
353
  /**
@@ -350,15 +356,34 @@ const ANSWER_TIMEOUT_MS = 6e4;
350
356
  * would otherwise wait on a stalled connection for as long as it stayed open.
351
357
  */
352
358
  async function send(url, init, timeoutMs = ANSWER_TIMEOUT_MS) {
359
+ return await withDeadline(url, init, timeoutMs, (response) => Promise.resolve(response));
360
+ }
361
+ /**
362
+ * sendForJSON asks the authorization server, and holds its whole answer to one short deadline.
363
+ * A server that sends its headers and then stalls would otherwise keep a command waiting on
364
+ * the body for as long as the connection stayed open.
365
+ */
366
+ async function sendForJSON(url, init, timeoutMs = SIGN_IN_TIMEOUT_MS) {
367
+ return await withDeadline(url, init, timeoutMs, async (response, isOver) => {
368
+ return {
369
+ response,
370
+ body: await response.json().catch((error) => {
371
+ if (isOver()) throw error;
372
+ })
373
+ };
374
+ });
375
+ }
376
+ /** withDeadline makes one request, and gives up on it unless the work on its response ends in time. */
377
+ async function withDeadline(url, init, timeoutMs, work) {
353
378
  const deadline = new AbortController();
354
379
  const timer = setTimeout(() => {
355
380
  deadline.abort();
356
381
  }, timeoutMs);
357
382
  try {
358
- return await fetch(url, {
383
+ return await work(await fetch(url, {
359
384
  ...init,
360
385
  signal: init?.signal ? AbortSignal.any([init.signal, deadline.signal]) : deadline.signal
361
- });
386
+ }), () => deadline.signal.aborted);
362
387
  } catch (error) {
363
388
  if (deadline.signal.aborted) throw toTimedOut(url, init?.method ?? "GET", timeoutMs);
364
389
  throw toUnreachable(url, error);
@@ -440,11 +465,11 @@ async function revoke(revocationUrl, clientId, token) {
440
465
  token,
441
466
  token_type_hint: "refresh_token"
442
467
  })
443
- });
468
+ }, SIGN_IN_TIMEOUT_MS);
444
469
  }
445
470
  /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
446
471
  async function toTokens(url, form) {
447
- const response = await send(url, {
472
+ const answered = await sendForJSON(url, {
448
473
  method: "POST",
449
474
  headers: {
450
475
  "Content-Type": "application/x-www-form-urlencoded",
@@ -452,7 +477,8 @@ async function toTokens(url, form) {
452
477
  },
453
478
  body: new URLSearchParams(form)
454
479
  });
455
- const body = await response.json().catch(() => void 0);
480
+ const response = answered.response;
481
+ const body = toRecord$1(answered.body);
456
482
  if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
457
483
  const parsed = TokenResponse.safeParse(body);
458
484
  if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
@@ -464,6 +490,10 @@ async function toTokens(url, form) {
464
490
  expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
465
491
  };
466
492
  }
493
+ /** toRecord reads a body as the object an OAuth answer is, and nothing for any other JSON. */
494
+ function toRecord$1(body) {
495
+ return typeof body === "object" && body !== null && !Array.isArray(body) ? body : void 0;
496
+ }
467
497
  //#endregion
468
498
  //#region src/credential/store.ts
469
499
  const load = createRequire(import.meta.url);
@@ -622,12 +652,12 @@ async function toMetadata(issuer, signal) {
622
652
  const cached = toCached(issuer);
623
653
  if (cached) return cached;
624
654
  const url = `${issuer.replace(/\/+$/, "")}${METADATA_PATH}`;
625
- const response = await send(url, {
655
+ const { response, body } = await sendForJSON(url, {
626
656
  headers: { Accept: "application/json" },
627
657
  signal
628
658
  });
629
659
  if (!response.ok) throw new DiscoveryFailure(`${url} answered ${response.status}, so this host publishes no authorization server`);
630
- const parsed = AuthorizationServerMetadata.safeParse(await response.json().catch(() => void 0));
660
+ const parsed = AuthorizationServerMetadata.safeParse(body);
631
661
  if (!parsed.success) throw new DiscoveryFailure(`${url} does not describe an authorization server`);
632
662
  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`);
633
663
  keep(issuer, parsed.data);
@@ -996,7 +1026,12 @@ async function request$1(options) {
996
1026
  /** toFileName reads the name a download was offered under, when the server names one. */
997
1027
  function toFileName(disposition) {
998
1028
  const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition ?? "");
999
- return matched?.[1] ? decodeURIComponent(matched[1]) : void 0;
1029
+ if (!matched?.[1]) return;
1030
+ try {
1031
+ return decodeURIComponent(matched[1]);
1032
+ } catch {
1033
+ return;
1034
+ }
1000
1035
  }
1001
1036
  function toLeadingSlash(path) {
1002
1037
  return path.startsWith("/") ? path : `/${path}`;
@@ -1193,7 +1228,7 @@ function toCommandLines(command, parents) {
1193
1228
  }
1194
1229
  async function runAgentGuide(input) {
1195
1230
  if (input.flags["json"] === true) {
1196
- writeData(input.commands.map(toSummary));
1231
+ writeData(input.commands.filter((command) => !command.hidden).map(toSummary));
1197
1232
  return ExitCode.OK;
1198
1233
  }
1199
1234
  writeData(toGuide(input.commands, version$1));
@@ -1374,26 +1409,41 @@ function isWSL() {
1374
1409
  return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
1375
1410
  }
1376
1411
  /**
1377
- * toOpeners lists the ways to open a URL here, best first. WSL needs Windows to do it,
1412
+ * toOpenable answers the address a browser may be sent to, and nothing for anything else. The
1413
+ * address comes from the authorization server, which a file in the working directory can
1414
+ * name, so it is parsed and written out again before any program is handed it.
1415
+ */
1416
+ function toOpenable(url) {
1417
+ let parsed;
1418
+ try {
1419
+ parsed = new URL(url);
1420
+ } catch {
1421
+ return;
1422
+ }
1423
+ const isLocal = parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
1424
+ return parsed.protocol === "https:" || isLocal ? parsed.href : void 0;
1425
+ }
1426
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1427
+ "127.0.0.1",
1428
+ "[::1]",
1429
+ "localhost"
1430
+ ]);
1431
+ /**
1432
+ * toOpenerList lists the ways to open a URL here, best first. WSL needs Windows to do it,
1378
1433
  * because the browser the person is looking at is the Windows one, and xdg-open reaches
1379
1434
  * nothing outside the distribution.
1380
1435
  */
1381
- function toOpeners(url) {
1382
- if (process.platform === "darwin") return [["open", [url]]];
1383
- if (process.platform === "win32") return [["cmd", [
1384
- "/c",
1385
- "start",
1386
- "",
1387
- url
1388
- ]]];
1389
- if (isWSL()) return [
1436
+ function toOpenerList(url, platform = process.platform, isInsideWSL = isWSL()) {
1437
+ if (platform === "darwin") return [["open", [url]]];
1438
+ if (platform === "win32") return [["rundll32.exe", ["url.dll,FileProtocolHandler", url]]];
1439
+ if (isInsideWSL) return [
1390
1440
  ["wslview", [url]],
1391
1441
  ["powershell.exe", [
1392
1442
  "-NoProfile",
1393
1443
  "-NonInteractive",
1394
1444
  "-Command",
1395
1445
  "Start-Process",
1396
- `'${url}'`
1446
+ `'${url.replaceAll("'", "''")}'`
1397
1447
  ]],
1398
1448
  ["explorer.exe", [url]]
1399
1449
  ];
@@ -1401,7 +1451,9 @@ function toOpeners(url) {
1401
1451
  }
1402
1452
  /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1403
1453
  function openBrowser(url, launch = spawnSync) {
1404
- for (const [command, args] of toOpeners(url)) if (launch(command, args, {
1454
+ const openable = toOpenable(url);
1455
+ if (openable === void 0) return false;
1456
+ for (const [command, args] of toOpenerList(openable)) if (launch(command, args, {
1405
1457
  stdio: "ignore",
1406
1458
  cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1407
1459
  timeout: 1e4
@@ -2229,7 +2281,7 @@ const DeviceResponse = z.looseObject({
2229
2281
  });
2230
2282
  /** toDeviceAuthorization asks for a code a person can type on another machine. */
2231
2283
  async function toDeviceAuthorization(endpoint, clientId, scope) {
2232
- const response = await send(endpoint, {
2284
+ const answered = await sendForJSON(endpoint, {
2233
2285
  method: "POST",
2234
2286
  headers: {
2235
2287
  "Content-Type": "application/x-www-form-urlencoded",
@@ -2240,7 +2292,8 @@ async function toDeviceAuthorization(endpoint, clientId, scope) {
2240
2292
  scope
2241
2293
  })
2242
2294
  });
2243
- const body = await response.json().catch(() => void 0);
2295
+ const response = answered.response;
2296
+ const body = toRecord$1(answered.body);
2244
2297
  if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
2245
2298
  const parsed = DeviceResponse.safeParse(body);
2246
2299
  if (!parsed.success) throw new GrantFailure("invalid_response", "the device endpoint did not answer with a code");
@@ -2291,7 +2344,7 @@ const HOST = "127.0.0.1";
2291
2344
  * the machine hands out, which is why the server matches a loopback redirect by everything
2292
2345
  * except its port.
2293
2346
  */
2294
- async function toListener(timeoutMs) {
2347
+ async function toListener(timeoutMs, state) {
2295
2348
  let settle = () => {};
2296
2349
  let fail = () => {};
2297
2350
  const callback = new Promise((resolve, reject) => {
@@ -2304,7 +2357,11 @@ async function toListener(timeoutMs) {
2304
2357
  response.writeHead(404).end();
2305
2358
  return;
2306
2359
  }
2307
- writePage(response, answered);
2360
+ if (!isOurs(answered, state)) {
2361
+ writePage(response, 400, "Not this sign in", "This page did not come from the sign in your terminal started.");
2362
+ return;
2363
+ }
2364
+ writeOutcome(response, answered);
2308
2365
  settle(answered);
2309
2366
  });
2310
2367
  await new Promise((resolve, reject) => {
@@ -2337,10 +2394,33 @@ function toCallback(request) {
2337
2394
  errorDescription: url.searchParams.get("error_description") ?? void 0
2338
2395
  };
2339
2396
  }
2340
- function writePage(response, callback) {
2341
- const title = callback.error ? "Sign in refused" : "Signed in";
2342
- const detail = callback.error ? `${callback.error}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}` : "You can close this tab and return to your terminal.";
2343
- response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }).end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui, sans-serif; padding: 3rem; max-width: 34rem"><h1 style="font-size: 1.25rem">${title}</h1><p>${detail}</p></body></html>`);
2397
+ /**
2398
+ * isOurs answers whether a response belongs to this sign in. A refusal with no state at all
2399
+ * is still taken, because not every authorization server sends the state back with one.
2400
+ */
2401
+ function isOurs(callback, state) {
2402
+ if (callback.state !== void 0) return callback.state === state;
2403
+ return callback.code === void 0 && callback.error !== void 0;
2404
+ }
2405
+ function writeOutcome(response, callback) {
2406
+ if (!callback.error) {
2407
+ writePage(response, 200, "Signed in", "You can close this tab and return to your terminal.");
2408
+ return;
2409
+ }
2410
+ writePage(response, 200, "Sign in refused", `${callback.error}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}`);
2411
+ }
2412
+ /** toEscaped makes text safe to put in a page, because the words of a refusal come from the URL. */
2413
+ function toEscaped(text) {
2414
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
2415
+ }
2416
+ function writePage(response, status, heading, words) {
2417
+ const title = toEscaped(heading);
2418
+ const detail = toEscaped(words);
2419
+ response.writeHead(status, {
2420
+ "Content-Type": "text/html; charset=utf-8",
2421
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
2422
+ "X-Content-Type-Options": "nosniff"
2423
+ }).end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family: system-ui, sans-serif; padding: 3rem; max-width: 34rem"><h1 style="font-size: 1.25rem">${title}</h1><p>${detail}</p></body></html>`);
2344
2424
  }
2345
2425
  function close(server, timer) {
2346
2426
  clearTimeout(timer);
@@ -2528,9 +2608,9 @@ async function runLogin(input) {
2528
2608
  const metadata = await toMetadata(settings.issuerUrl);
2529
2609
  const scope = toText(input.flags["scope"] ?? DEFAULT_SCOPES);
2530
2610
  if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
2531
- const listener = await toListener(WAIT_MS);
2532
- const pkce = toPkce();
2533
2611
  const state = toState();
2612
+ const listener = await toListener(WAIT_MS, state);
2613
+ const pkce = toPkce();
2534
2614
  const url = toAuthorizationUrl(metadata.authorization_endpoint, {
2535
2615
  clientId,
2536
2616
  redirectUri: listener.redirectUri,
@@ -2787,7 +2867,7 @@ function defineOperation(operation) {
2787
2867
  async function runOperation(operation, input) {
2788
2868
  const path = toPath(operation, input.args);
2789
2869
  if (path === void 0) {
2790
- writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
2870
+ writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s), each the key of a record`, input.isJSON);
2791
2871
  return ExitCode.USAGE;
2792
2872
  }
2793
2873
  const given = toGivenBody(input.flags["input"]);
@@ -2850,7 +2930,12 @@ async function runOperation(operation, input) {
2850
2930
  */
2851
2931
  function toWritten(download, input) {
2852
2932
  const asked = input.flags["output"];
2853
- const path = typeof asked === "string" ? asked : download.fileName ?? "-";
2933
+ const offered = typeof asked === "string" ? void 0 : download.fileName;
2934
+ const path = typeof asked === "string" ? asked : offered === void 0 ? "-" : toLocalName(offered);
2935
+ if (path === void 0 || offered !== void 0 && existsSync(path)) {
2936
+ writeFailure("output_required", path === void 0 ? `this file is offered as ${JSON.stringify(offered)}, which is not a name to write here, so name where to write it with --output` : `${path} already exists, so name where to write this file with --output`, input.isJSON);
2937
+ return ExitCode.USAGE;
2938
+ }
2854
2939
  if (path === "-") {
2855
2940
  if (process.stdout.isTTY) {
2856
2941
  writeFailure("output_required", "this answer is a file, so name where to write it with --output, or send it on with a pipe", input.isJSON);
@@ -2867,9 +2952,20 @@ function toWritten(download, input) {
2867
2952
  });
2868
2953
  return ExitCode.OK;
2869
2954
  }
2955
+ /** toLocalName reduces a name the API offered to one file in the working directory, or to nothing. */
2956
+ function toLocalName(offered) {
2957
+ const name = offered.split(/[\\/]/).pop() ?? "";
2958
+ return name === "" || name.startsWith(".") ? void 0 : name;
2959
+ }
2960
+ /** The arguments a URL reads as "this folder" and "the one above", and one that names nothing. */
2961
+ const NOT_A_KEY = /* @__PURE__ */ new Set([
2962
+ "",
2963
+ ".",
2964
+ ".."
2965
+ ]);
2870
2966
  /** toPath fills the path template from the positional arguments, in order. */
2871
2967
  function toPath(operation, args) {
2872
- if (args.length !== operation.pathParameters.length) return;
2968
+ if (args.length !== operation.pathParameters.length || args.some((argument) => NOT_A_KEY.has(argument))) return;
2873
2969
  let path = operation.path;
2874
2970
  for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
2875
2971
  return path;
@@ -3007,6 +3103,10 @@ function toRefusedPath(flag, path, isWritten, root = process.cwd()) {
3007
3103
  if (isWithin(toReal(dirname(toCredentialPath())), target)) return refuse("it is where the sign in is kept");
3008
3104
  if (isWritten && existsSync(target)) return refuse("it already exists, and a call does not overwrite a file");
3009
3105
  }
3106
+ /** isInsideFolder answers whether a path is the folder the server runs in, or sits inside it. */
3107
+ function isInsideFolder(path, root = process.cwd()) {
3108
+ return isWithin(toReal(root), toReal(resolve(root, path)));
3109
+ }
3010
3110
  /** toEnvFile names the env file a person pointed the CLI at, when they did. */
3011
3111
  function toEnvFile(root) {
3012
3112
  const named = process.env["HARDFIN_ENV_FILE"];
@@ -3621,7 +3721,7 @@ function toResource(uri, commands) {
3621
3721
  }
3622
3722
  /** toTree names every command and what it takes, without the schemas a tool list would carry. */
3623
3723
  function toTree(commands) {
3624
- return commands.map((command) => ({
3724
+ return commands.filter((command) => !command.hidden).map((command) => ({
3625
3725
  name: command.name,
3626
3726
  summary: command.summary,
3627
3727
  arguments: command.arguments.map((argument) => argument.name),
@@ -3629,11 +3729,16 @@ function toTree(commands) {
3629
3729
  subcommands: command.subcommands ? toTree(command.subcommands) : void 0
3630
3730
  }));
3631
3731
  }
3632
- /** Signing in needs a browser and a person, neither of which an agent's session has. */
3633
- const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
3634
- "login",
3635
- "logout",
3636
- "mcp"
3732
+ /**
3733
+ * The commands a person runs themselves, each by the words that run it. Signing in needs a
3734
+ * browser and a person, and installing completion changes the startup file of their shell.
3735
+ */
3736
+ const REFUSED_COMMANDS = /* @__PURE__ */ new Map([
3737
+ ["login", "login"],
3738
+ ["logout", "logout"],
3739
+ ["mcp", "mcp"],
3740
+ ["install", "completion install"],
3741
+ ["uninstall", "completion uninstall"]
3637
3742
  ]);
3638
3743
  /**
3639
3744
  * toParsed asks the CLI's own parser what a call would run, without running it. The server
@@ -3672,7 +3777,8 @@ function toFixedOption(parsed) {
3672
3777
  */
3673
3778
  function toRefusedFile(parsed) {
3674
3779
  if (!parsed.ran) return;
3675
- const { command, flags } = parsed.ran;
3780
+ const { command, args, flags } = parsed.ran;
3781
+ if (command.name === "init" && args[0] !== void 0 && !isInsideFolder(args[0])) return `init cannot set up ${args[0]}, because it is outside the folder this server runs in. A person can run hardfin init there themselves`;
3676
3782
  for (const flag of command.flags.filter((entry) => PATH_VALUES.has(entry.valueName ?? ""))) {
3677
3783
  const path = flags[toOptionKey(flag.name)];
3678
3784
  const isWritten = flag.name === "output";
@@ -3697,18 +3803,44 @@ async function toToolResult(request, commands, run) {
3697
3803
  if (fixed) return toError(fixed);
3698
3804
  const confined = toRefusedFile(parsed);
3699
3805
  if (confined) return toError(confined);
3700
- const command = parsed.ran?.command.name ?? "";
3701
- if (REFUSED_COMMANDS.has(command)) return toError(`${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`);
3806
+ const command = REFUSED_COMMANDS.get(parsed.ran?.command.name ?? "");
3807
+ if (command !== void 0) return toError(`${command} is run by a person at a terminal, not through this server. Run hardfin ${command} yourself, then call this tool again`);
3702
3808
  if (name === READ_TOOL.name && toAccessOf(parsed) === "write") return toError(`this command changes data, so it runs through ${WRITE_TOOL.name}`);
3703
3809
  const outcome = await run(asked, called?.input);
3810
+ const text = toBounded([outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n"));
3811
+ const failure = outcome.code === 0 ? void 0 : toFailure(outcome);
3704
3812
  return {
3705
3813
  content: [{
3706
3814
  type: "text",
3707
- text: [outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n") || `hardfin exited ${outcome.code}`
3815
+ text: text || `hardfin exited ${outcome.code}`
3708
3816
  }],
3709
- isError: outcome.code !== 0
3817
+ isError: outcome.code !== 0,
3818
+ ...failure ? { structuredContent: failure } : {}
3710
3819
  };
3711
3820
  }
3821
+ /** The most text one result carries, because every character of it sits in the agent's context. */
3822
+ const RESULT_CHARACTERS = 2e5;
3823
+ /** toBounded cuts a result that is too long to be useful, and says that it did. */
3824
+ function toBounded(text, limit = RESULT_CHARACTERS) {
3825
+ if (text.length <= limit) return text;
3826
+ return `${text.slice(0, limit)}\n\n[This result was cut at ${limit} of ${text.length} characters, so it is not complete and may not parse. Ask for less, with --limit, --page or a filter]`;
3827
+ }
3828
+ /**
3829
+ * toFailure reads the failure a command printed, so a client can branch on its code without
3830
+ * parsing text. A command that printed anything else beside it answers with the text alone.
3831
+ */
3832
+ function toFailure(outcome) {
3833
+ try {
3834
+ const parsed = JSON.parse(outcome.stderr);
3835
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.code !== "string") return;
3836
+ return {
3837
+ ...parsed,
3838
+ exitCode: outcome.code
3839
+ };
3840
+ } catch {
3841
+ return;
3842
+ }
3843
+ }
3712
3844
  function toError(text) {
3713
3845
  return {
3714
3846
  content: [{
@@ -3792,35 +3924,72 @@ function toRun(entry, args, input, timeoutMs) {
3792
3924
  });
3793
3925
  return;
3794
3926
  }
3795
- let stdout = "";
3796
- let stderr = "";
3927
+ const stdout = toCollector();
3928
+ const stderr = toCollector();
3797
3929
  let isTimedOut = false;
3798
3930
  const timer = setTimeout(() => {
3799
3931
  isTimedOut = true;
3800
3932
  child.kill("SIGKILL");
3801
3933
  }, timeoutMs);
3802
- child.stdout.on("data", (chunk) => {
3803
- stdout += chunk.toString();
3804
- });
3805
- child.stderr.on("data", (chunk) => {
3806
- stderr += chunk.toString();
3807
- });
3934
+ child.stdout.on("data", stdout.take);
3935
+ child.stderr.on("data", stderr.take);
3808
3936
  child.on("error", (error) => {
3809
- stderr += `the command could not start: ${error.message}\n`;
3937
+ stderr.take(Buffer.from(`the command could not start: ${error.message}\n`));
3810
3938
  });
3811
3939
  child.on("close", (code) => {
3812
3940
  clearTimeout(timer);
3813
- if (isTimedOut) stderr += `this command took longer than ${timeoutMs / 1e3} seconds, so it was stopped\n`;
3941
+ if (isTimedOut) stderr.take(Buffer.from(`this command took longer than ${timeoutMs / 1e3} seconds, so it was stopped\n`));
3942
+ const printed = stdout.toText();
3943
+ const said = stderr.toText() ?? "";
3944
+ const ended = isTimedOut ? 1 : code ?? 1;
3945
+ if (printed !== void 0) {
3946
+ resolve({
3947
+ stdout: printed,
3948
+ stderr: said,
3949
+ code: ended
3950
+ });
3951
+ return;
3952
+ }
3814
3953
  resolve({
3815
- stdout,
3816
- stderr,
3817
- code: isTimedOut ? 1 : code ?? 1
3954
+ stdout: "",
3955
+ stderr: ended === 0 ? `${said}this answer is ${stdout.bytes()} bytes that are not text, so it cannot be read here. Keep it with --output and a new file name, through ${WRITE_TOOL.name}\n` : said,
3956
+ code: ended || 1
3818
3957
  });
3819
3958
  });
3820
3959
  child.stdin.on("error", () => void 0);
3821
3960
  child.stdin.end(input ?? "");
3822
3961
  });
3823
3962
  }
3963
+ /** The most output kept from one command, well past what a result carries, so memory stays bounded. */
3964
+ const KEPT_BYTES = 4 * RESULT_CHARACTERS;
3965
+ /**
3966
+ * toCollector gathers what a command prints as bytes. Decoding each chunk on its own would
3967
+ * split a character that straddles two chunks, and would hide that a file is not text.
3968
+ */
3969
+ function toCollector() {
3970
+ const chunks = [];
3971
+ let kept = 0;
3972
+ let seen = 0;
3973
+ return {
3974
+ take: (chunk) => {
3975
+ seen += chunk.length;
3976
+ if (kept < KEPT_BYTES) {
3977
+ chunks.push(chunk);
3978
+ kept += chunk.length;
3979
+ }
3980
+ },
3981
+ bytes: () => seen,
3982
+ toText: () => {
3983
+ let text;
3984
+ try {
3985
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks), { stream: true });
3986
+ } catch {
3987
+ return;
3988
+ }
3989
+ return seen > kept ? `${text}\n[${seen - kept} more bytes were not kept]` : text;
3990
+ }
3991
+ };
3992
+ }
3824
3993
  /** serve answers requests on stdin until the client closes it. */
3825
3994
  async function serve(commands, run = toCliRunner()) {
3826
3995
  const lines = createInterface$1({ input: process.stdin });
@@ -6689,5 +6858,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
6689
6858
  //#endregion
6690
6859
  export { toCli, toParseExitCode, toReported, wasReportAttempted };
6691
6860
 
6692
- //# sourceMappingURL=program-CqO1St9H.js.map
6693
- //# debugId=0a793a7f-2311-5aaf-b27d-0877574e2d18
6861
+ //# sourceMappingURL=program-CgT1Kr8L.js.map
6862
+ //# debugId=2c607b02-61ea-5376-957f-d434d5692ac5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.1.1-dev.42",
3
+ "version": "0.1.1-dev.44",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",