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

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
@@ -16,6 +16,8 @@ server, and every command also accepts an API key.
16
16
  npm install -g @hardfin/cli
17
17
  ```
18
18
 
19
+ It needs Node 20.12 or later.
20
+
19
21
  Previews of unreleased work are published from the `dev` branch.
20
22
 
21
23
  ```sh
@@ -71,6 +73,9 @@ decimal such as `--salvage-value 1500.00` stays a string, which is how the API t
71
73
  without losing precision.
72
74
 
73
75
  A download is written where you name it, and a terminal is never filled with a file's bytes.
76
+ With no `--output`, the file takes the name it was uploaded under, in the working directory.
77
+ That name never chooses a folder, a hidden file, or a file that is already there. The command
78
+ asks for `--output` in those cases.
74
79
 
75
80
  ```sh
76
81
  hardfin file get file_7hq2mx9pkr4stz8w --output photo.jpg
@@ -180,7 +185,9 @@ hardfin logout # forget this machine, and ask Hardfin to revoke it
180
185
  ```
181
186
 
182
187
  An API key is still the way to run unattended. `--api-key`, or `HARDFIN_API_KEY`, wins over
183
- a stored sign in, so CI and an agent sandbox need no browser. The flag beats the variable.
188
+ a stored sign in, so CI and an agent sandbox need no browser. The flag beats the variable. Prefer the
189
+ variable, because a command line is visible to other programs on the machine and is kept in
190
+ an agent's transcript.
184
191
 
185
192
  An API key is held to two rules before it is sent:
186
193
 
@@ -199,7 +206,8 @@ how a local build is set up. Pass `--api-url` to send a key you set to that fold
199
206
  2. Opens a loopback listener on `127.0.0.1`, over IPv4, on whatever port the machine hands out
200
207
  3. Opens the browser to approve this CLI, with a PKCE challenge and a state value
201
208
  4. Prints the URL, and waits at the keyboard as well as on the listener
202
- 5. Checks the state and the `iss` it came back with, then trades the code for tokens
209
+ 5. Checks the state and the `iss` it came back with, then trades the code for tokens. Every
210
+ endpoint the authorization server named must be https, or this machine
203
211
  6. Keeps the refresh token, and nothing else
204
212
 
205
213
  While it waits, the terminal takes two things.
@@ -216,6 +224,11 @@ and the redirect it failed to follow is still in the address bar.
216
224
  On WSL the browser is opened through Windows, by `wslview`, then PowerShell, then
217
225
  `explorer.exe`, because `xdg-open` reaches nothing outside the distribution.
218
226
 
227
+ The browser is only ever sent to an https address, or to this machine. The address comes from
228
+ the authorization server, so one with any other scheme is printed for you to read and never
229
+ opened. The listener takes only a response that carries the state this sign in sent, and
230
+ turns any other request away without ending the sign in.
231
+
219
232
  The port changes every time, so Hardfin matches a loopback redirect by everything except its
220
233
  port, which is what RFC 8252 asks of an authorization server.
221
234
 
@@ -401,6 +414,17 @@ exit codes. Each call has these limits:
401
414
  | Commands running | 4 at once | The next call waits for one to finish |
402
415
  | One argument | 128 KiB | Refused, pointing at the `input` argument |
403
416
  | Standard input | The call's `input` argument, or nothing | Always closed, so `--input -` never waits |
417
+ | One result | 200,000 characters | Cut, with a line saying it is not complete |
418
+
419
+ A failed call also carries the failure as `structuredContent`, with the same `code`,
420
+ `message`, `errors`, `requestId` and `status` the CLI prints, plus `exitCode`. A file that is
421
+ not text is refused through `--output -`, because handing it over as text would change its
422
+ bytes.
423
+
424
+ Renewing a sign in gives the authorization server 15 seconds for its whole answer. That keeps
425
+ a renewal well inside the 120 seconds a call has, so a slow renewal fails as `timed_out` and
426
+ is not stopped partway. A refresh token the server rotated and never finished sending is
427
+ still lost, which no client can prevent.
404
428
 
405
429
  ### What a call may touch on this machine
406
430
 
@@ -416,6 +440,9 @@ held to rules a person at a terminal is not.
416
440
  | Downloads with `--output <path>` | A write, so it runs through `hardfin_write` |
417
441
  | Downloads with no `--output` | Refused, naming `--output -` to read the file in place |
418
442
 
443
+ `hardfin init` is held to the same folder. `hardfin completion install` and `uninstall` are
444
+ refused, along with `login`, `logout` and `mcp`, because a person runs those themselves.
445
+
419
446
  The rules cover every flag that takes a path: `--input`, `--file` and `--output`. A person
420
447
  runs the command themselves for a file the server will not reach.
421
448
 
@@ -472,7 +499,8 @@ Only defects are reported. None of these are:
472
499
  - a network failure, or a server that cannot be reached
473
500
  - a mistake on the command line or in a settings file
474
501
  - being signed out, or a sign in that was cancelled or refused
475
- - the machine refusing a file, such as a full disk or a missing permission
502
+ - the machine refusing a file, such as a full disk or a missing permission, unless the file
503
+ is one the CLI installs, which makes it a broken package
476
504
  - anything from a run with no API key and no stored sign in, except a defect in `hardfin login`
477
505
 
478
506
  The last rule is there because package scanners install the CLI and run it in sandboxes. They
@@ -510,9 +538,11 @@ settings, and the one nearest the top wins.
510
538
 
511
539
  An exported variable beats `.env`, because the CLI leaves a variable that is already set alone.
512
540
 
513
- An env file sets only variables that begin with `HARDFIN_`. Anything else in it is ignored,
514
- so a `.env` in a folder you cloned cannot set a variable such as `NODE_OPTIONS` for the
515
- programs the CLI starts.
541
+ An env file sets the four settings: `HARDFIN_API_URL`, `HARDFIN_API_KEY`, `HARDFIN_CLIENT_ID`
542
+ and `HARDFIN_ISSUER_URL`. It may also turn error reports off, with `DO_NOT_TRACK=1` or
543
+ `HARDFIN_TELEMETRY=0`. Anything else in it is ignored, so a `.env` in a folder you cloned
544
+ cannot set `NODE_OPTIONS` for the programs the CLI starts, or send error reports elsewhere
545
+ with `HARDFIN_SENTRY_DSN`. Export a variable yourself to set it.
516
546
 
517
547
  ### What a local build writes
518
548
 
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]="7a26fe4e-1a9f-538e-af63-a17134f6e803")}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-Bz-DwZDJ.js");
18
18
  try {
19
19
  await toCli().parseAsync(process.argv);
20
20
  } catch (error) {
@@ -31,10 +31,13 @@ try {
31
31
  async function toCrash(error) {
32
32
  if (isCrashing) return;
33
33
  isCrashing = true;
34
- process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
34
+ const isJSON = process.argv.includes("--json") || !process.stdout.isTTY;
35
35
  try {
36
- const { toReported } = await import("./program-CqO1St9H.js");
37
- await toReported(error, false, void 0, "fatal");
36
+ const { toReported, writeCrash } = await import("./program-Bz-DwZDJ.js");
37
+ writeCrash(error, isJSON);
38
+ await toReported(error, isJSON, void 0, "fatal");
39
+ } catch {
40
+ process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`);
38
41
  } finally {
39
42
  process.exit(1);
40
43
  }
@@ -51,4 +54,4 @@ async function toQuickAnswer(args) {
51
54
  export {};
52
55
 
53
56
  //# sourceMappingURL=cli.js.map
54
- //# debugId=c2c38fd6-1925-561f-baf9-51faf8c4c4b8
57
+ //# debugId=7a26fe4e-1a9f-538e-af63-a17134f6e803
@@ -7,7 +7,7 @@
7
7
  },
8
8
  {
9
9
  "name": "api-key",
10
- "description": "An API key to call with, in place of HARDFIN_API_KEY or a sign in",
10
+ "description": "An API key to call with. Prefer HARDFIN_API_KEY, because a command line is visible to other programs",
11
11
  "valueName": "key"
12
12
  },
13
13
  {
@@ -1,7 +1,7 @@
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){}}();
4
- import { a as defineCommand, i as GLOBAL_FLAGS, r as ExitCode, t as toInvocation } from "./scrub-BN3gyBpR.js";
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]="8bcae06e-d313-56ed-bc3e-49b505487fa3")}catch(e){}}();
4
+ import { a as defineCommand, i as GLOBAL_FLAGS, r as ExitCode, t as toInvocation } from "./scrub-BZ0egWxC.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";
7
7
  import { Command, CommanderError, Option } from "commander";
@@ -9,11 +9,11 @@ import { z } from "zod";
9
9
  import { accessSync, chmodSync, closeSync, constants, existsSync, mkdirSync, openAsBlob, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
10
10
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
11
  import { parseEnv } from "node:util";
12
+ import { fileURLToPath } from "node:url";
12
13
  import { createHash, randomBytes, randomUUID } from "node:crypto";
13
14
  import { arch, cpus, homedir, release, totalmem, type, version } from "node:os";
14
15
  import { spawn, spawnSync } from "node:child_process";
15
16
  import { createInterface } from "node:readline/promises";
16
- import { fileURLToPath } from "node:url";
17
17
  import { createServer } from "node:http";
18
18
  import { createInterface as createInterface$1 } from "node:readline";
19
19
  //#region src/output/writer.ts
@@ -27,10 +27,21 @@ function toText(value) {
27
27
  if (value === void 0 || value === null) return "";
28
28
  return JSON.stringify(value);
29
29
  }
30
+ /** Every control character but a newline and a tab, which are the two a line of text needs. */
31
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
32
+ /**
33
+ * toPrintable makes text safe to show at a terminal. A name or a message from the API can
34
+ * hold an escape sequence, which a terminal would act on. Each such character is written
35
+ * as its code, so the text reads as what it holds.
36
+ */
37
+ function toPrintable(text) {
38
+ return text.replace(CONTROL, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
39
+ }
30
40
  /** writeData prints a command's result on stdout. */
31
41
  function writeData(value) {
32
42
  if (typeof value === "string") {
33
- process.stdout.write(value.endsWith("\n") ? value : `${value}\n`);
43
+ const text = toPrintable(value);
44
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
34
45
  return;
35
46
  }
36
47
  process.stdout.write(`${JSON.stringify(value ?? null, null, 2)}\n`);
@@ -38,9 +49,9 @@ function writeData(value) {
38
49
  /** writeFailure prints why a command failed on stderr, with a code only a machine reading JSON sees. */
39
50
  function writeFailure(code, message, isJSON, errors, requestId, status) {
40
51
  if (!isJSON) {
41
- process.stderr.write(`error: ${message}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
42
- for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${entry.error} (${entry.statusCode})\n`);
43
- if (requestId) process.stderr.write(` request ${requestId}\n`);
52
+ process.stderr.write(`error: ${toPrintable(message)}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
53
+ for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${toPrintable(entry.error)} (${entry.statusCode})\n`);
54
+ if (requestId) process.stderr.write(` request ${toPrintable(requestId)}\n`);
44
55
  return;
45
56
  }
46
57
  const payload = {
@@ -155,6 +166,77 @@ var TimedOut = class extends ExpectedFailure {
155
166
  this.name = "TimedOut";
156
167
  }
157
168
  };
169
+ /** The values that mean no, for a variable that turns something on or off. */
170
+ const OFF = /* @__PURE__ */ new Set([
171
+ "0",
172
+ "false",
173
+ "off",
174
+ "no"
175
+ ]);
176
+ /**
177
+ * toTelemetry decides whether defects are reported. A person's opt out wins over everything,
178
+ * and a CLI run from a checkout reports only when its developer asks it to.
179
+ */
180
+ function toTelemetry(env = process.env, isCheckout = isRunFromCheckout()) {
181
+ const dsn = env["HARDFIN_SENTRY_DSN"] || "https://a4819e652e9fa63c0ab67e9febe44565@o1388102.ingest.us.sentry.io/4512176532684800";
182
+ const base = {
183
+ dsn,
184
+ environment: isCheckout ? "local" : version$1.includes("-dev.") ? "preview" : "production",
185
+ release: toRelease(version$1)
186
+ };
187
+ if (env["DO_NOT_TRACK"] && !OFF.has(env["DO_NOT_TRACK"].toLowerCase())) return {
188
+ ...base,
189
+ enabled: false,
190
+ from: "DO_NOT_TRACK"
191
+ };
192
+ const asked = env["HARDFIN_TELEMETRY"]?.toLowerCase();
193
+ if (asked !== void 0 && OFF.has(asked)) return {
194
+ ...base,
195
+ enabled: false,
196
+ from: "HARDFIN_TELEMETRY"
197
+ };
198
+ if (!isDsn(dsn)) return {
199
+ ...base,
200
+ enabled: false,
201
+ from: "HARDFIN_SENTRY_DSN is not a valid DSN"
202
+ };
203
+ if (asked !== void 0) return {
204
+ ...base,
205
+ enabled: true,
206
+ from: "HARDFIN_TELEMETRY"
207
+ };
208
+ return isCheckout ? {
209
+ ...base,
210
+ enabled: false,
211
+ from: "run from a checkout"
212
+ } : {
213
+ ...base,
214
+ enabled: true,
215
+ from: "default"
216
+ };
217
+ }
218
+ /**
219
+ * isRunFromCheckout answers whether this CLI runs from a clone of its repository, as npm link
220
+ * and node dist/cli.js do. The published package holds dist and nothing beside it.
221
+ */
222
+ function isRunFromCheckout() {
223
+ return existsSync(fileURLToPath(new URL("../src", import.meta.url)));
224
+ }
225
+ /** isDsn answers whether a DSN can be read as the URL of a Sentry project. */
226
+ function isDsn(dsn) {
227
+ try {
228
+ return new URL(dsn).host !== "";
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+ /**
234
+ * toRelease names a version as Sentry knows it. Sentry refuses a slash in a release name, so
235
+ * the scoped package name @hardfin/cli cannot be used, and hardfin-cli stands in for it.
236
+ */
237
+ function toRelease(of) {
238
+ return `hardfin-cli@${of}`;
239
+ }
158
240
  //#endregion
159
241
  //#region src/config/settings.ts
160
242
  /** The API version this build was written against, sent on every request. */
@@ -225,8 +307,23 @@ function toFileSettings(directory) {
225
307
  }
226
308
  return result.data;
227
309
  }
228
- /** The prefix of every variable this CLI reads, which is all an env file may set. */
229
- const VARIABLE_PREFIX = "HARDFIN_";
310
+ /** The variables an env file may set, which are the settings and nothing else the CLI reads. */
311
+ const SETTING_VARIABLES = /* @__PURE__ */ new Set([
312
+ "HARDFIN_API_URL",
313
+ "HARDFIN_API_KEY",
314
+ "HARDFIN_CLIENT_ID",
315
+ "HARDFIN_ISSUER_URL"
316
+ ]);
317
+ /**
318
+ * isTakenFromFile answers whether an env file may set a variable. A file in a folder supplies
319
+ * the settings, and may turn error reports off. Where reports go, where a sign in is kept,
320
+ * and anything a started program reads are for the person to set themselves.
321
+ */
322
+ function isTakenFromFile(name, value) {
323
+ if (SETTING_VARIABLES.has(name)) return true;
324
+ if (name === "DO_NOT_TRACK") return !OFF.has(value.toLowerCase());
325
+ return name === "HARDFIN_TELEMETRY" && OFF.has(value.toLowerCase());
326
+ }
230
327
  /**
231
328
  * loadEnvFile reads a .env beside the command, so a local build needs no exports, and
232
329
  * answers which variables it supplied. An exported variable is left alone, so a shell
@@ -243,7 +340,7 @@ function loadEnvFile(directory) {
243
340
  }
244
341
  const supplied = /* @__PURE__ */ new Set();
245
342
  for (const [name, value] of Object.entries(held)) {
246
- if (!name.startsWith(VARIABLE_PREFIX) || value === void 0 || process.env[name] !== void 0) continue;
343
+ if (value === void 0 || process.env[name] !== void 0 || !isTakenFromFile(name, value)) continue;
247
344
  process.env[name] = value;
248
345
  supplied.add(name);
249
346
  }
@@ -269,6 +366,19 @@ var Unreachable = class extends ExpectedFailure {
269
366
  this.name = "Unreachable";
270
367
  }
271
368
  };
369
+ /** isLoopback reports whether a host is this machine. */
370
+ function isLoopback(hostname) {
371
+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
372
+ }
373
+ /** isSecure answers whether a URL is https, or is this machine, where nothing sits in between. */
374
+ function isSecure(url) {
375
+ try {
376
+ const parsed = new URL(url);
377
+ return parsed.protocol === "https:" || parsed.protocol === "http:" && isLoopback(parsed.hostname);
378
+ } catch {
379
+ return false;
380
+ }
381
+ }
272
382
  const OFFLINE = "Couldn't connect to Hardfin. Check your internet connection and try again.";
273
383
  const INSECURE = "Couldn't connect to Hardfin securely, so nothing was sent. A work network or security software may be blocking the connection. If this keeps happening, contact your IT team.";
274
384
  /** The reasons Node gives, by the code on the error behind fetch's own. */
@@ -342,6 +452,12 @@ const REASONS = {
342
452
  detail: "not an HTTPS server"
343
453
  }
344
454
  };
455
+ /**
456
+ * How long the authorization server may take over its whole answer. Renewing a sign in comes
457
+ * before the request a command was asked to make, so a slow renewal has to end, and say so,
458
+ * well before a caller's own limit stops the command without a word.
459
+ */
460
+ const SIGN_IN_TIMEOUT_MS = 15e3;
345
461
  /** How long a server may take to begin its answer, before the request is given up on. */
346
462
  const ANSWER_TIMEOUT_MS = 6e4;
347
463
  /**
@@ -350,15 +466,34 @@ const ANSWER_TIMEOUT_MS = 6e4;
350
466
  * would otherwise wait on a stalled connection for as long as it stayed open.
351
467
  */
352
468
  async function send(url, init, timeoutMs = ANSWER_TIMEOUT_MS) {
469
+ return await withDeadline(url, init, timeoutMs, (response) => Promise.resolve(response));
470
+ }
471
+ /**
472
+ * sendForJSON asks the authorization server, and holds its whole answer to one short deadline.
473
+ * A server that sends its headers and then stalls would otherwise keep a command waiting on
474
+ * the body for as long as the connection stayed open.
475
+ */
476
+ async function sendForJSON(url, init, timeoutMs = SIGN_IN_TIMEOUT_MS) {
477
+ return await withDeadline(url, init, timeoutMs, async (response, isOver) => {
478
+ return {
479
+ response,
480
+ body: await response.json().catch((error) => {
481
+ if (isOver()) throw error;
482
+ })
483
+ };
484
+ });
485
+ }
486
+ /** withDeadline makes one request, and gives up on it unless the work on its response ends in time. */
487
+ async function withDeadline(url, init, timeoutMs, work) {
353
488
  const deadline = new AbortController();
354
489
  const timer = setTimeout(() => {
355
490
  deadline.abort();
356
491
  }, timeoutMs);
357
492
  try {
358
- return await fetch(url, {
493
+ return await work(await fetch(url, {
359
494
  ...init,
360
495
  signal: init?.signal ? AbortSignal.any([init.signal, deadline.signal]) : deadline.signal
361
- });
496
+ }), () => deadline.signal.aborted);
362
497
  } catch (error) {
363
498
  if (deadline.signal.aborted) throw toTimedOut(url, init?.method ?? "GET", timeoutMs);
364
499
  throw toUnreachable(url, error);
@@ -440,11 +575,11 @@ async function revoke(revocationUrl, clientId, token) {
440
575
  token,
441
576
  token_type_hint: "refresh_token"
442
577
  })
443
- });
578
+ }, SIGN_IN_TIMEOUT_MS);
444
579
  }
445
580
  /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
446
581
  async function toTokens(url, form) {
447
- const response = await send(url, {
582
+ const answered = await sendForJSON(url, {
448
583
  method: "POST",
449
584
  headers: {
450
585
  "Content-Type": "application/x-www-form-urlencoded",
@@ -452,7 +587,8 @@ async function toTokens(url, form) {
452
587
  },
453
588
  body: new URLSearchParams(form)
454
589
  });
455
- const body = await response.json().catch(() => void 0);
590
+ const response = answered.response;
591
+ const body = toRecord$1(answered.body);
456
592
  if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
457
593
  const parsed = TokenResponse.safeParse(body);
458
594
  if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
@@ -464,6 +600,10 @@ async function toTokens(url, form) {
464
600
  expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
465
601
  };
466
602
  }
603
+ /** toRecord reads a body as the object an OAuth answer is, and nothing for any other JSON. */
604
+ function toRecord$1(body) {
605
+ return typeof body === "object" && body !== null && !Array.isArray(body) ? body : void 0;
606
+ }
467
607
  //#endregion
468
608
  //#region src/credential/store.ts
469
609
  const load = createRequire(import.meta.url);
@@ -622,17 +762,31 @@ async function toMetadata(issuer, signal) {
622
762
  const cached = toCached(issuer);
623
763
  if (cached) return cached;
624
764
  const url = `${issuer.replace(/\/+$/, "")}${METADATA_PATH}`;
625
- const response = await send(url, {
765
+ const { response, body } = await sendForJSON(url, {
626
766
  headers: { Accept: "application/json" },
627
767
  signal
628
768
  });
629
769
  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));
770
+ const parsed = AuthorizationServerMetadata.safeParse(body);
631
771
  if (!parsed.success) throw new DiscoveryFailure(`${url} does not describe an authorization server`);
632
772
  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`);
773
+ const plain = toPlainEndpoint(parsed.data);
774
+ if (plain) throw new DiscoveryFailure(`${url} names ${plain}, which is not https, so nothing is sent to it`);
633
775
  keep(issuer, parsed.data);
634
776
  return parsed.data;
635
777
  }
778
+ /**
779
+ * toPlainEndpoint names an endpoint that would carry a code or a refresh token over a plain
780
+ * connection. The document chooses every endpoint, so each is checked before any is used.
781
+ */
782
+ function toPlainEndpoint(metadata) {
783
+ return [
784
+ metadata.authorization_endpoint,
785
+ metadata.token_endpoint,
786
+ metadata.revocation_endpoint,
787
+ metadata.device_authorization_endpoint
788
+ ].find((endpoint) => endpoint !== void 0 && !isSecure(endpoint));
789
+ }
636
790
  /** toCachePath names where one server's document is kept, beside the credentials. */
637
791
  function toCachePath(issuer) {
638
792
  const name = createHash("sha256").update(issuer).digest("hex").slice(0, 16);
@@ -645,7 +799,7 @@ function toCached(issuer) {
645
799
  const held = JSON.parse(readFileSync(path, "utf8"));
646
800
  if ((held.readAt ?? 0) + CACHE_MS < Date.now()) return;
647
801
  const parsed = AuthorizationServerMetadata.safeParse(held.document);
648
- return parsed.success ? parsed.data : void 0;
802
+ return parsed.success && !toPlainEndpoint(parsed.data) ? parsed.data : void 0;
649
803
  } catch {
650
804
  return;
651
805
  }
@@ -899,10 +1053,6 @@ function toHost$1(url) {
899
1053
  return;
900
1054
  }
901
1055
  }
902
- /** isLoopback reports whether a host is this machine. */
903
- function isLoopback(hostname) {
904
- return hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]" || hostname === "localhost";
905
- }
906
1056
  /**
907
1057
  * toHeldCredential answers a credential without asking the server for one: the API key, or
908
1058
  * an access token this process already holds. An error report uses it, because a refresh
@@ -996,7 +1146,12 @@ async function request$1(options) {
996
1146
  /** toFileName reads the name a download was offered under, when the server names one. */
997
1147
  function toFileName(disposition) {
998
1148
  const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition ?? "");
999
- return matched?.[1] ? decodeURIComponent(matched[1]) : void 0;
1149
+ if (!matched?.[1]) return;
1150
+ try {
1151
+ return decodeURIComponent(matched[1]);
1152
+ } catch {
1153
+ return;
1154
+ }
1000
1155
  }
1001
1156
  function toLeadingSlash(path) {
1002
1157
  return path.startsWith("/") ? path : `/${path}`;
@@ -1016,6 +1171,35 @@ function toStatusMessage(status) {
1016
1171
  return `the request failed with status ${status}`;
1017
1172
  }
1018
1173
  //#endregion
1174
+ //#region src/system/package.ts
1175
+ /** The folders this package ships, beside its manifest. */
1176
+ const SHIPPED = [
1177
+ "dist",
1178
+ "skills",
1179
+ "templates"
1180
+ ];
1181
+ /** toPackageDirectory finds this package's own folder, which holds the skills and templates it ships. */
1182
+ function toPackageDirectory(from = dirname(fileURLToPath(import.meta.url))) {
1183
+ for (let directory = from;; directory = dirname(directory)) {
1184
+ const manifest = join(directory, "package.json");
1185
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === "@hardfin/cli") return directory;
1186
+ if (dirname(directory) === directory) throw new Error("the @hardfin/cli package folder could not be found, so its skills cannot be copied");
1187
+ }
1188
+ }
1189
+ /**
1190
+ * isShipped answers whether a path is one of the files this package installs. A file of the
1191
+ * CLI's own that cannot be read is a broken install, not the person's machine refusing them.
1192
+ */
1193
+ function isShipped(path, packageDirectory) {
1194
+ let directory;
1195
+ try {
1196
+ directory = packageDirectory ?? toPackageDirectory();
1197
+ } catch {
1198
+ return false;
1199
+ }
1200
+ return path === join(directory, "package.json") || SHIPPED.some((folder) => path === join(directory, folder) || path.startsWith(join(directory, folder) + sep));
1201
+ }
1202
+ //#endregion
1019
1203
  //#region src/report/classify.ts
1020
1204
  /**
1021
1205
  * The codes Node gives when the machine refuses something: permissions, a full or read-only
@@ -1042,8 +1226,9 @@ function isReportable(error) {
1042
1226
  }
1043
1227
  /** isMachineRefusal answers whether an error is the machine refusing a file or a folder. */
1044
1228
  function isMachineRefusal(error) {
1045
- const code = error instanceof Error ? error.code : void 0;
1046
- return typeof code === "string" && ENVIRONMENT_CODES.has(code);
1229
+ const { code, path } = error instanceof Error ? error : {};
1230
+ if (typeof code !== "string" || !ENVIRONMENT_CODES.has(code)) return false;
1231
+ return !(typeof path === "string" && isShipped(path));
1047
1232
  }
1048
1233
  //#endregion
1049
1234
  //#region src/output/failure.ts
@@ -1135,7 +1320,7 @@ function toGuide(commands, version) {
1135
1320
  "",
1136
1321
  "## Before calling",
1137
1322
  "",
1138
- "- Authenticate with `--api-key`, by setting `HARDFIN_API_KEY`, or by running `hardfin login`",
1323
+ "- Authenticate by running `hardfin login`, or by setting `HARDFIN_API_KEY` in the environment. Do not pass a key as `--api-key`, because a command line is kept in transcripts and shown to other programs",
1139
1324
  "- `hardfin login` writes one JSON line on stderr before it waits. Show the person its `url`, or its `userCode` and `verificationUri`, and let the command run for `waitSeconds`",
1140
1325
  "- Every command prints JSON on stdout and diagnostics on stderr",
1141
1326
  "- Output is JSON whenever stdout is not a terminal, so no flag is needed when calling from code",
@@ -1193,7 +1378,7 @@ function toCommandLines(command, parents) {
1193
1378
  }
1194
1379
  async function runAgentGuide(input) {
1195
1380
  if (input.flags["json"] === true) {
1196
- writeData(input.commands.map(toSummary));
1381
+ writeData(input.commands.filter((command) => !command.hidden).map(toSummary));
1197
1382
  return ExitCode.OK;
1198
1383
  }
1199
1384
  writeData(toGuide(input.commands, version$1));
@@ -1374,26 +1559,41 @@ function isWSL() {
1374
1559
  return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
1375
1560
  }
1376
1561
  /**
1377
- * toOpeners lists the ways to open a URL here, best first. WSL needs Windows to do it,
1562
+ * toOpenable answers the address a browser may be sent to, and nothing for anything else. The
1563
+ * address comes from the authorization server, which a file in the working directory can
1564
+ * name, so it is parsed and written out again before any program is handed it.
1565
+ */
1566
+ function toOpenable(url) {
1567
+ let parsed;
1568
+ try {
1569
+ parsed = new URL(url);
1570
+ } catch {
1571
+ return;
1572
+ }
1573
+ const isLocal = parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
1574
+ return parsed.protocol === "https:" || isLocal ? parsed.href : void 0;
1575
+ }
1576
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
1577
+ "127.0.0.1",
1578
+ "[::1]",
1579
+ "localhost"
1580
+ ]);
1581
+ /**
1582
+ * toOpenerList lists the ways to open a URL here, best first. WSL needs Windows to do it,
1378
1583
  * because the browser the person is looking at is the Windows one, and xdg-open reaches
1379
1584
  * nothing outside the distribution.
1380
1585
  */
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 [
1586
+ function toOpenerList(url, platform = process.platform, isInsideWSL = isWSL()) {
1587
+ if (platform === "darwin") return [["open", [url]]];
1588
+ if (platform === "win32") return [["rundll32.exe", ["url.dll,FileProtocolHandler", url]]];
1589
+ if (isInsideWSL) return [
1390
1590
  ["wslview", [url]],
1391
1591
  ["powershell.exe", [
1392
1592
  "-NoProfile",
1393
1593
  "-NonInteractive",
1394
1594
  "-Command",
1395
1595
  "Start-Process",
1396
- `'${url}'`
1596
+ `'${url.replaceAll("'", "''")}'`
1397
1597
  ]],
1398
1598
  ["explorer.exe", [url]]
1399
1599
  ];
@@ -1401,7 +1601,9 @@ function toOpeners(url) {
1401
1601
  }
1402
1602
  /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1403
1603
  function openBrowser(url, launch = spawnSync) {
1404
- for (const [command, args] of toOpeners(url)) if (launch(command, args, {
1604
+ const openable = toOpenable(url);
1605
+ if (openable === void 0) return false;
1606
+ for (const [command, args] of toOpenerList(openable)) if (launch(command, args, {
1405
1607
  stdio: "ignore",
1406
1608
  cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1407
1609
  timeout: 1e4
@@ -2161,14 +2363,6 @@ function toFiles(directory) {
2161
2363
  return statSync(path).isDirectory() ? toFiles(path) : [path];
2162
2364
  });
2163
2365
  }
2164
- /** toPackageDirectory finds this package's own folder, which holds the skills and templates it ships. */
2165
- function toPackageDirectory(from = dirname(fileURLToPath(import.meta.url))) {
2166
- for (let directory = from;; directory = dirname(directory)) {
2167
- const manifest = join(directory, "package.json");
2168
- if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === "@hardfin/cli") return directory;
2169
- if (dirname(directory) === directory) throw new Error("the @hardfin/cli package folder could not be found, so its skills cannot be copied");
2170
- }
2171
- }
2172
2366
  //#endregion
2173
2367
  //#region src/command/types.generated.ts
2174
2368
  /** API_TYPES is the TypeScript hardfin types prints. */
@@ -2229,7 +2423,7 @@ const DeviceResponse = z.looseObject({
2229
2423
  });
2230
2424
  /** toDeviceAuthorization asks for a code a person can type on another machine. */
2231
2425
  async function toDeviceAuthorization(endpoint, clientId, scope) {
2232
- const response = await send(endpoint, {
2426
+ const answered = await sendForJSON(endpoint, {
2233
2427
  method: "POST",
2234
2428
  headers: {
2235
2429
  "Content-Type": "application/x-www-form-urlencoded",
@@ -2240,7 +2434,8 @@ async function toDeviceAuthorization(endpoint, clientId, scope) {
2240
2434
  scope
2241
2435
  })
2242
2436
  });
2243
- const body = await response.json().catch(() => void 0);
2437
+ const response = answered.response;
2438
+ const body = toRecord$1(answered.body);
2244
2439
  if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
2245
2440
  const parsed = DeviceResponse.safeParse(body);
2246
2441
  if (!parsed.success) throw new GrantFailure("invalid_response", "the device endpoint did not answer with a code");
@@ -2291,7 +2486,7 @@ const HOST = "127.0.0.1";
2291
2486
  * the machine hands out, which is why the server matches a loopback redirect by everything
2292
2487
  * except its port.
2293
2488
  */
2294
- async function toListener(timeoutMs) {
2489
+ async function toListener(timeoutMs, state) {
2295
2490
  let settle = () => {};
2296
2491
  let fail = () => {};
2297
2492
  const callback = new Promise((resolve, reject) => {
@@ -2304,7 +2499,11 @@ async function toListener(timeoutMs) {
2304
2499
  response.writeHead(404).end();
2305
2500
  return;
2306
2501
  }
2307
- writePage(response, answered);
2502
+ if (!isOurs(answered, state)) {
2503
+ writePage(response, 400, "Not this sign in", "This page did not come from the sign in your terminal started.");
2504
+ return;
2505
+ }
2506
+ writeOutcome(response, answered);
2308
2507
  settle(answered);
2309
2508
  });
2310
2509
  await new Promise((resolve, reject) => {
@@ -2337,10 +2536,33 @@ function toCallback(request) {
2337
2536
  errorDescription: url.searchParams.get("error_description") ?? void 0
2338
2537
  };
2339
2538
  }
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>`);
2539
+ /**
2540
+ * isOurs answers whether a response belongs to this sign in. A refusal with no state at all
2541
+ * is still taken, because not every authorization server sends the state back with one.
2542
+ */
2543
+ function isOurs(callback, state) {
2544
+ if (callback.state !== void 0) return callback.state === state;
2545
+ return callback.code === void 0 && callback.error !== void 0;
2546
+ }
2547
+ function writeOutcome(response, callback) {
2548
+ if (!callback.error) {
2549
+ writePage(response, 200, "Signed in", "You can close this tab and return to your terminal.");
2550
+ return;
2551
+ }
2552
+ writePage(response, 200, "Sign in refused", `${callback.error}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}`);
2553
+ }
2554
+ /** toEscaped makes text safe to put in a page, because the words of a refusal come from the URL. */
2555
+ function toEscaped(text) {
2556
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
2557
+ }
2558
+ function writePage(response, status, heading, words) {
2559
+ const title = toEscaped(heading);
2560
+ const detail = toEscaped(words);
2561
+ response.writeHead(status, {
2562
+ "Content-Type": "text/html; charset=utf-8",
2563
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
2564
+ "X-Content-Type-Options": "nosniff"
2565
+ }).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
2566
  }
2345
2567
  function close(server, timer) {
2346
2568
  clearTimeout(timer);
@@ -2528,9 +2750,9 @@ async function runLogin(input) {
2528
2750
  const metadata = await toMetadata(settings.issuerUrl);
2529
2751
  const scope = toText(input.flags["scope"] ?? DEFAULT_SCOPES);
2530
2752
  if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
2531
- const listener = await toListener(WAIT_MS);
2532
- const pkce = toPkce();
2533
2753
  const state = toState();
2754
+ const listener = await toListener(WAIT_MS, state);
2755
+ const pkce = toPkce();
2534
2756
  const url = toAuthorizationUrl(metadata.authorization_endpoint, {
2535
2757
  clientId,
2536
2758
  redirectUri: listener.redirectUri,
@@ -2601,8 +2823,8 @@ async function runDeviceLogin(input, metadata, scope) {
2601
2823
  });
2602
2824
  else process.stderr.write([
2603
2825
  "",
2604
- ` Code ${device.userCode}`,
2605
- ` At ${device.verificationUri}`,
2826
+ ` Code ${toPrintable(device.userCode)}`,
2827
+ ` At ${toPrintable(device.verificationUri)}`,
2606
2828
  "",
2607
2829
  opened ? "Opened your browser there. Waiting for approval\n" : "Open that page on any machine, and enter the code. Waiting for approval\n"
2608
2830
  ].join("\n"));
@@ -2787,7 +3009,7 @@ function defineOperation(operation) {
2787
3009
  async function runOperation(operation, input) {
2788
3010
  const path = toPath(operation, input.args);
2789
3011
  if (path === void 0) {
2790
- writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s)`, input.isJSON);
3012
+ writeFailure("invalid_arguments", `this command takes ${operation.pathParameters.length} argument(s), each the key of a record`, input.isJSON);
2791
3013
  return ExitCode.USAGE;
2792
3014
  }
2793
3015
  const given = toGivenBody(input.flags["input"]);
@@ -2850,7 +3072,12 @@ async function runOperation(operation, input) {
2850
3072
  */
2851
3073
  function toWritten(download, input) {
2852
3074
  const asked = input.flags["output"];
2853
- const path = typeof asked === "string" ? asked : download.fileName ?? "-";
3075
+ const offered = typeof asked === "string" ? void 0 : download.fileName;
3076
+ const path = typeof asked === "string" ? asked : offered === void 0 ? "-" : toLocalName(offered);
3077
+ if (path === void 0 || offered !== void 0 && existsSync(path)) {
3078
+ 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);
3079
+ return ExitCode.USAGE;
3080
+ }
2854
3081
  if (path === "-") {
2855
3082
  if (process.stdout.isTTY) {
2856
3083
  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 +3094,20 @@ function toWritten(download, input) {
2867
3094
  });
2868
3095
  return ExitCode.OK;
2869
3096
  }
3097
+ /** toLocalName reduces a name the API offered to one file in the working directory, or to nothing. */
3098
+ function toLocalName(offered) {
3099
+ const name = offered.split(/[\\/]/).pop() ?? "";
3100
+ return name === "" || name.startsWith(".") ? void 0 : name;
3101
+ }
3102
+ /** The arguments a URL reads as "this folder" and "the one above", and one that names nothing. */
3103
+ const NOT_A_KEY = /* @__PURE__ */ new Set([
3104
+ "",
3105
+ ".",
3106
+ ".."
3107
+ ]);
2870
3108
  /** toPath fills the path template from the positional arguments, in order. */
2871
3109
  function toPath(operation, args) {
2872
- if (args.length !== operation.pathParameters.length) return;
3110
+ if (args.length !== operation.pathParameters.length || args.some((argument) => NOT_A_KEY.has(argument))) return;
2873
3111
  let path = operation.path;
2874
3112
  for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
2875
3113
  return path;
@@ -3007,6 +3245,10 @@ function toRefusedPath(flag, path, isWritten, root = process.cwd()) {
3007
3245
  if (isWithin(toReal(dirname(toCredentialPath())), target)) return refuse("it is where the sign in is kept");
3008
3246
  if (isWritten && existsSync(target)) return refuse("it already exists, and a call does not overwrite a file");
3009
3247
  }
3248
+ /** isInsideFolder answers whether a path is the folder the server runs in, or sits inside it. */
3249
+ function isInsideFolder(path, root = process.cwd()) {
3250
+ return isWithin(toReal(root), toReal(resolve(root, path)));
3251
+ }
3010
3252
  /** toEnvFile names the env file a person pointed the CLI at, when they did. */
3011
3253
  function toEnvFile(root) {
3012
3254
  const named = process.env["HARDFIN_ENV_FILE"];
@@ -3026,76 +3268,6 @@ function toReal(path) {
3026
3268
  const parent = dirname(path);
3027
3269
  return parent === path ? path : join(toReal(parent), basename(path));
3028
3270
  }
3029
- const OFF = /* @__PURE__ */ new Set([
3030
- "0",
3031
- "false",
3032
- "off",
3033
- "no"
3034
- ]);
3035
- /**
3036
- * toTelemetry decides whether defects are reported. A person's opt out wins over everything,
3037
- * and a CLI run from a checkout reports only when its developer asks it to.
3038
- */
3039
- function toTelemetry(env = process.env, isCheckout = isRunFromCheckout()) {
3040
- const dsn = env["HARDFIN_SENTRY_DSN"] || "https://a4819e652e9fa63c0ab67e9febe44565@o1388102.ingest.us.sentry.io/4512176532684800";
3041
- const base = {
3042
- dsn,
3043
- environment: isCheckout ? "local" : version$1.includes("-dev.") ? "preview" : "production",
3044
- release: toRelease(version$1)
3045
- };
3046
- if (env["DO_NOT_TRACK"] && !OFF.has(env["DO_NOT_TRACK"].toLowerCase())) return {
3047
- ...base,
3048
- enabled: false,
3049
- from: "DO_NOT_TRACK"
3050
- };
3051
- const asked = env["HARDFIN_TELEMETRY"]?.toLowerCase();
3052
- if (asked !== void 0 && OFF.has(asked)) return {
3053
- ...base,
3054
- enabled: false,
3055
- from: "HARDFIN_TELEMETRY"
3056
- };
3057
- if (!isDsn(dsn)) return {
3058
- ...base,
3059
- enabled: false,
3060
- from: "HARDFIN_SENTRY_DSN is not a valid DSN"
3061
- };
3062
- if (asked !== void 0) return {
3063
- ...base,
3064
- enabled: true,
3065
- from: "HARDFIN_TELEMETRY"
3066
- };
3067
- return isCheckout ? {
3068
- ...base,
3069
- enabled: false,
3070
- from: "run from a checkout"
3071
- } : {
3072
- ...base,
3073
- enabled: true,
3074
- from: "default"
3075
- };
3076
- }
3077
- /**
3078
- * isRunFromCheckout answers whether this CLI runs from a clone of its repository, as npm link
3079
- * and node dist/cli.js do. The published package holds dist and nothing beside it.
3080
- */
3081
- function isRunFromCheckout() {
3082
- return existsSync(fileURLToPath(new URL("../src", import.meta.url)));
3083
- }
3084
- /** isDsn answers whether a DSN can be read as the URL of a Sentry project. */
3085
- function isDsn(dsn) {
3086
- try {
3087
- return new URL(dsn).host !== "";
3088
- } catch {
3089
- return false;
3090
- }
3091
- }
3092
- /**
3093
- * toRelease names a version as Sentry knows it. Sentry refuses a slash in a release name, so
3094
- * the scoped package name @hardfin/cli cannot be used, and hardfin-cli stands in for it.
3095
- */
3096
- function toRelease(of) {
3097
- return `hardfin-cli@${of}`;
3098
- }
3099
3271
  //#endregion
3100
3272
  //#region src/system/host.ts
3101
3273
  const BYTES_PER_GB = 1024 ** 3;
@@ -3402,7 +3574,7 @@ async function report(error, occasion, resolved) {
3402
3574
  try {
3403
3575
  const status = settings ? await toStatus(settings) : void 0;
3404
3576
  const secrets = [settings?.settings.apiKey, settings ? toCredential(settings.settings.issuerUrl)?.refreshToken : void 0];
3405
- return await (await import("./sentry-BM9Rvhhy.js")).send({
3577
+ return await (await import("./sentry-e3Tum2kz.js")).send({
3406
3578
  error,
3407
3579
  occasion,
3408
3580
  telemetry,
@@ -3621,7 +3793,7 @@ function toResource(uri, commands) {
3621
3793
  }
3622
3794
  /** toTree names every command and what it takes, without the schemas a tool list would carry. */
3623
3795
  function toTree(commands) {
3624
- return commands.map((command) => ({
3796
+ return commands.filter((command) => !command.hidden).map((command) => ({
3625
3797
  name: command.name,
3626
3798
  summary: command.summary,
3627
3799
  arguments: command.arguments.map((argument) => argument.name),
@@ -3629,11 +3801,16 @@ function toTree(commands) {
3629
3801
  subcommands: command.subcommands ? toTree(command.subcommands) : void 0
3630
3802
  }));
3631
3803
  }
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"
3804
+ /**
3805
+ * The commands a person runs themselves, each by the words that run it. Signing in needs a
3806
+ * browser and a person, and installing completion changes the startup file of their shell.
3807
+ */
3808
+ const REFUSED_COMMANDS = /* @__PURE__ */ new Map([
3809
+ ["login", "login"],
3810
+ ["logout", "logout"],
3811
+ ["mcp", "mcp"],
3812
+ ["install", "completion install"],
3813
+ ["uninstall", "completion uninstall"]
3637
3814
  ]);
3638
3815
  /**
3639
3816
  * toParsed asks the CLI's own parser what a call would run, without running it. The server
@@ -3672,7 +3849,8 @@ function toFixedOption(parsed) {
3672
3849
  */
3673
3850
  function toRefusedFile(parsed) {
3674
3851
  if (!parsed.ran) return;
3675
- const { command, flags } = parsed.ran;
3852
+ const { command, args, flags } = parsed.ran;
3853
+ 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
3854
  for (const flag of command.flags.filter((entry) => PATH_VALUES.has(entry.valueName ?? ""))) {
3677
3855
  const path = flags[toOptionKey(flag.name)];
3678
3856
  const isWritten = flag.name === "output";
@@ -3697,18 +3875,44 @@ async function toToolResult(request, commands, run) {
3697
3875
  if (fixed) return toError(fixed);
3698
3876
  const confined = toRefusedFile(parsed);
3699
3877
  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`);
3878
+ const command = REFUSED_COMMANDS.get(parsed.ran?.command.name ?? "");
3879
+ 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
3880
  if (name === READ_TOOL.name && toAccessOf(parsed) === "write") return toError(`this command changes data, so it runs through ${WRITE_TOOL.name}`);
3703
3881
  const outcome = await run(asked, called?.input);
3882
+ const text = toBounded([outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n"));
3883
+ const failure = outcome.code === 0 ? void 0 : toFailure(outcome);
3704
3884
  return {
3705
3885
  content: [{
3706
3886
  type: "text",
3707
- text: [outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n") || `hardfin exited ${outcome.code}`
3887
+ text: text || `hardfin exited ${outcome.code}`
3708
3888
  }],
3709
- isError: outcome.code !== 0
3889
+ isError: outcome.code !== 0,
3890
+ ...failure ? { structuredContent: failure } : {}
3710
3891
  };
3711
3892
  }
3893
+ /** The most text one result carries, because every character of it sits in the agent's context. */
3894
+ const RESULT_CHARACTERS = 2e5;
3895
+ /** toBounded cuts a result that is too long to be useful, and says that it did. */
3896
+ function toBounded(text, limit = RESULT_CHARACTERS) {
3897
+ if (text.length <= limit) return text;
3898
+ 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]`;
3899
+ }
3900
+ /**
3901
+ * toFailure reads the failure a command printed, so a client can branch on its code without
3902
+ * parsing text. A command that printed anything else beside it answers with the text alone.
3903
+ */
3904
+ function toFailure(outcome) {
3905
+ try {
3906
+ const parsed = JSON.parse(outcome.stderr);
3907
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.code !== "string") return;
3908
+ return {
3909
+ ...parsed,
3910
+ exitCode: outcome.code
3911
+ };
3912
+ } catch {
3913
+ return;
3914
+ }
3915
+ }
3712
3916
  function toError(text) {
3713
3917
  return {
3714
3918
  content: [{
@@ -3792,35 +3996,72 @@ function toRun(entry, args, input, timeoutMs) {
3792
3996
  });
3793
3997
  return;
3794
3998
  }
3795
- let stdout = "";
3796
- let stderr = "";
3999
+ const stdout = toCollector();
4000
+ const stderr = toCollector();
3797
4001
  let isTimedOut = false;
3798
4002
  const timer = setTimeout(() => {
3799
4003
  isTimedOut = true;
3800
4004
  child.kill("SIGKILL");
3801
4005
  }, timeoutMs);
3802
- child.stdout.on("data", (chunk) => {
3803
- stdout += chunk.toString();
3804
- });
3805
- child.stderr.on("data", (chunk) => {
3806
- stderr += chunk.toString();
3807
- });
4006
+ child.stdout.on("data", stdout.take);
4007
+ child.stderr.on("data", stderr.take);
3808
4008
  child.on("error", (error) => {
3809
- stderr += `the command could not start: ${error.message}\n`;
4009
+ stderr.take(Buffer.from(`the command could not start: ${error.message}\n`));
3810
4010
  });
3811
4011
  child.on("close", (code) => {
3812
4012
  clearTimeout(timer);
3813
- if (isTimedOut) stderr += `this command took longer than ${timeoutMs / 1e3} seconds, so it was stopped\n`;
4013
+ if (isTimedOut) stderr.take(Buffer.from(`this command took longer than ${timeoutMs / 1e3} seconds, so it was stopped\n`));
4014
+ const printed = stdout.toText();
4015
+ const said = stderr.toText() ?? "";
4016
+ const ended = isTimedOut ? 1 : code ?? 1;
4017
+ if (printed !== void 0) {
4018
+ resolve({
4019
+ stdout: printed,
4020
+ stderr: said,
4021
+ code: ended
4022
+ });
4023
+ return;
4024
+ }
3814
4025
  resolve({
3815
- stdout,
3816
- stderr,
3817
- code: isTimedOut ? 1 : code ?? 1
4026
+ stdout: "",
4027
+ 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,
4028
+ code: ended || 1
3818
4029
  });
3819
4030
  });
3820
4031
  child.stdin.on("error", () => void 0);
3821
4032
  child.stdin.end(input ?? "");
3822
4033
  });
3823
4034
  }
4035
+ /** The most output kept from one command, well past what a result carries, so memory stays bounded. */
4036
+ const KEPT_BYTES = 4 * RESULT_CHARACTERS;
4037
+ /**
4038
+ * toCollector gathers what a command prints as bytes. Decoding each chunk on its own would
4039
+ * split a character that straddles two chunks, and would hide that a file is not text.
4040
+ */
4041
+ function toCollector() {
4042
+ const chunks = [];
4043
+ let kept = 0;
4044
+ let seen = 0;
4045
+ return {
4046
+ take: (chunk) => {
4047
+ seen += chunk.length;
4048
+ if (kept < KEPT_BYTES) {
4049
+ chunks.push(chunk);
4050
+ kept += chunk.length;
4051
+ }
4052
+ },
4053
+ bytes: () => seen,
4054
+ toText: () => {
4055
+ let text;
4056
+ try {
4057
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks), { stream: true });
4058
+ } catch {
4059
+ return;
4060
+ }
4061
+ return seen > kept ? `${text}\n[${seen - kept} more bytes were not kept]` : text;
4062
+ }
4063
+ };
4064
+ }
3824
4065
  /** serve answers requests on stdin until the client closes it. */
3825
4066
  async function serve(commands, run = toCliRunner()) {
3826
4067
  const lines = createInterface$1({ input: process.stdin });
@@ -6674,6 +6915,10 @@ async function toExitCode(globals, command, args, flags) {
6674
6915
  return error instanceof ConfigFailure ? ExitCode.USAGE : ExitCode.ERROR;
6675
6916
  }
6676
6917
  }
6918
+ /** writeCrash answers an error nothing caught, in the form the caller reads. */
6919
+ function writeCrash(error, isJSON) {
6920
+ writeFailure(toErrorCode(error), error instanceof Error ? error.message : String(error), isJSON);
6921
+ }
6677
6922
  /** toReported reports a defect, and tells a person at a terminal that it was reported. */
6678
6923
  async function toReported(error, isJSON, resolved, level = "error") {
6679
6924
  const invocation = toInvocation(commands, process.argv.slice(2));
@@ -6687,7 +6932,7 @@ async function toReported(error, isJSON, resolved, level = "error") {
6687
6932
  if (id && !isJSON) process.stderr.write(`This looks like a problem in hardfin itself, and it has been reported to the Hardfin team. Reference: ${id}\n`);
6688
6933
  }
6689
6934
  //#endregion
6690
- export { toCli, toParseExitCode, toReported, wasReportAttempted };
6935
+ export { toCli, toParseExitCode, toReported, wasReportAttempted, writeCrash };
6691
6936
 
6692
- //# sourceMappingURL=program-CqO1St9H.js.map
6693
- //# debugId=0a793a7f-2311-5aaf-b27d-0877574e2d18
6937
+ //# sourceMappingURL=program-Bz-DwZDJ.js.map
6938
+ //# debugId=8bcae06e-d313-56ed-bc3e-49b505487fa3
@@ -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]="3af5060a-b6fe-5f2e-9661-01f4460a7b04")}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]="76640861-61e5-5004-ae78-dee93e00338f")}catch(e){}}();
4
4
  import { z } from "zod";
5
5
  import { homedir } from "node:os";
6
6
  //#region src/command/registry.ts
@@ -26,7 +26,7 @@ const GLOBAL_FLAGS = [
26
26
  },
27
27
  {
28
28
  name: "api-key",
29
- description: "An API key to call with, in place of HARDFIN_API_KEY or a sign in",
29
+ description: "An API key to call with. Prefer HARDFIN_API_KEY, because a command line is visible to other programs",
30
30
  valueName: "key",
31
31
  schema: z.string()
32
32
  },
@@ -118,5 +118,5 @@ function toFlag(flags, word) {
118
118
  //#endregion
119
119
  export { defineCommand as a, GLOBAL_FLAGS as i, toScrubbed as n, ExitCode as r, toInvocation as t };
120
120
 
121
- //# sourceMappingURL=scrub-BN3gyBpR.js.map
122
- //# debugId=3af5060a-b6fe-5f2e-9661-01f4460a7b04
121
+ //# sourceMappingURL=scrub-BZ0egWxC.js.map
122
+ //# debugId=76640861-61e5-5004-ae78-dee93e00338f
@@ -1,7 +1,7 @@
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]="35c80804-dcd8-5662-9f00-9c8ff04f286a")}catch(e){}}();
4
- import { n as toScrubbed } from "./scrub-BN3gyBpR.js";
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]="12bc3b97-183e-5e0e-84a2-4353d0ade50b")}catch(e){}}();
4
+ import { n as toScrubbed } from "./scrub-BZ0egWxC.js";
5
5
  import { dirname } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { request } from "node:http";
@@ -4385,5 +4385,5 @@ function toTags(delivery) {
4385
4385
  //#endregion
4386
4386
  export { send };
4387
4387
 
4388
- //# sourceMappingURL=sentry-BM9Rvhhy.js.map
4389
- //# debugId=35c80804-dcd8-5662-9f00-9c8ff04f286a
4388
+ //# sourceMappingURL=sentry-e3Tum2kz.js.map
4389
+ //# debugId=12bc3b97-183e-5e0e-84a2-4353d0ade50b
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.45",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",
@@ -29,7 +29,7 @@
29
29
  "templates"
30
30
  ],
31
31
  "engines": {
32
- "node": ">=20"
32
+ "node": ">=20.12"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
@@ -17,7 +17,7 @@ flags, so check `--help` before relying on a flag this skill does not show.
17
17
  hardfin --version || npm install -g @hardfin/cli
18
18
  ```
19
19
 
20
- It needs Node 20 or later. If `npm install -g` is refused, run commands as
20
+ It needs Node 20.12 or later. If `npm install -g` is refused, run commands as
21
21
  `npx -y @hardfin/cli <command>` instead.
22
22
 
23
23
  ## 2. Make sure it is signed in
@@ -28,11 +28,11 @@ hardfin status --offline --json
28
28
 
29
29
  `credential.kind` is `none` when nobody is signed in. Then sign in one of three ways.
30
30
 
31
- | Situation | Do this |
32
- | ---------------------------------------------------------- | ------------------------------------------------ |
33
- | You run on the person's own machine | Run `hardfin login` in the foreground |
34
- | You run in a cloud sandbox, away from the person's browser | Run `hardfin login --device` |
35
- | The person gave you an API key | Pass `--api-key <key>`, or set `HARDFIN_API_KEY` |
31
+ | Situation | Do this |
32
+ | ---------------------------------------------------------- | ---------------------------------------- |
33
+ | You run on the person's own machine | Run `hardfin login` in the foreground |
34
+ | You run in a cloud sandbox, away from the person's browser | Run `hardfin login --device` |
35
+ | The person gave you an API key | Set `HARDFIN_API_KEY` in the environment |
36
36
 
37
37
  `hardfin login` writes one JSON line on stderr before it waits:
38
38
 
@@ -44,7 +44,9 @@ hardfin status --offline --json
44
44
  It then waits up to `waitSeconds` (300) for the person to approve. Give the command that
45
45
  long to finish. Do not run it in the background and move on.
46
46
 
47
- Never write an API key into a file in the repository, and never echo it back.
47
+ Never write an API key into a file in the repository, and never echo it back. Never pass it
48
+ as `--api-key` either, because a command line is kept in transcripts and shown to other
49
+ programs on the machine.
48
50
 
49
51
  ## 3. Find the command
50
52
 
@@ -4,7 +4,7 @@ This project works with Hardfin data through the Hardfin CLI, `hardfin`.
4
4
 
5
5
  | Task | Command |
6
6
  | ---------------------------------- | ---------------------------------------------------------------------------- |
7
- | Install | `npm install -g @hardfin/cli`, with Node 20 or later |
7
+ | Install | `npm install -g @hardfin/cli`, with Node 20.12 or later |
8
8
  | Sign in | `hardfin login`, or `--device` away from a browser, or set `HARDFIN_API_KEY` |
9
9
  | Check what it is connected to | `hardfin status` |
10
10
  | Find a command | `hardfin --help`, then `hardfin <noun> --help` |