@hardfin/cli 0.1.1-dev.41 → 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
@@ -109,6 +112,17 @@ stderr, so a pipe still carries only the result.
109
112
  | `requestId` | The API's request ID, or null when the request never reached it |
110
113
  | `status` | The HTTP status, or null for a failure caught before any request is made |
111
114
 
115
+ Three codes cover failures that are easy to mistake for something else.
116
+
117
+ | `code` | Happens when |
118
+ | --------------------- | ------------------------------------------------------------------------------------------- |
119
+ | `unexpected_response` | A 200 whose body is not the API's JSON, such as a proxy's page. It is never an empty result |
120
+ | `timed_out` | The server did not begin to answer within 60 seconds, or 300 for an upload |
121
+ | `file_error` | This machine refused a path the command was given |
122
+
123
+ A change that timed out may still have been made, and the message says so. Read the record
124
+ before sending the change again.
125
+
112
126
  An agent branches on `code`. A code is never renamed and never changes meaning, while the
113
127
  message may be reworded at any time. `hardfin agent-guide` lists every code with what it
114
128
  means, and `src/output/failure.ts` defines them.
@@ -205,6 +219,11 @@ and the redirect it failed to follow is still in the address bar.
205
219
  On WSL the browser is opened through Windows, by `wslview`, then PowerShell, then
206
220
  `explorer.exe`, because `xdg-open` reaches nothing outside the distribution.
207
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
+
208
227
  The port changes every time, so Hardfin matches a loopback redirect by everything except its
209
228
  port, which is what RFC 8252 asks of an authorization server.
210
229
 
@@ -390,6 +409,17 @@ exit codes. Each call has these limits:
390
409
  | Commands running | 4 at once | The next call waits for one to finish |
391
410
  | One argument | 128 KiB | Refused, pointing at the `input` argument |
392
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.
393
423
 
394
424
  ### What a call may touch on this machine
395
425
 
@@ -405,6 +435,9 @@ held to rules a person at a terminal is not.
405
435
  | Downloads with `--output <path>` | A write, so it runs through `hardfin_write` |
406
436
  | Downloads with no `--output` | Refused, naming `--output -` to read the file in place |
407
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
+
408
441
  The rules cover every flag that takes a path: `--input`, `--file` and `--output`. A person
409
442
  runs the command themselves for a file the server will not reach.
410
443
 
@@ -442,6 +475,10 @@ Nothing secret is printed. An API key and a refresh token are each reported as a
442
475
  `sha256:` fingerprint, which identifies a credential across two machines without disclosing
443
476
  it.
444
477
 
478
+ `api.authenticated` is `true` only when the API answered who the credential is. A 401 or a
479
+ 403 makes it `false`, and `hardfin status` then exits with code 4. It is `null` when the API
480
+ could not be asked, because no answer says nothing about the credential.
481
+
445
482
  `refreshExpiresAt` is what the server said when it issued the token. A sign in stored before
446
483
  the server reported one falls back to Hardfin's 90-day rule, and `refreshExpiryIsEstimated`
447
484
  says which of the two you are looking at.
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]="12877cfa-09aa-5239-8a24-ecc77a951641")}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-C1-jZp6K.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-C1-jZp6K.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=12877cfa-09aa-5239-8a24-ecc77a951641
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]="8cef3750-26c4-5378-89b9-1b3b5b7aaaa7")}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";
@@ -261,30 +261,238 @@ function toOrigin(apiUrl) {
261
261
  }
262
262
  }
263
263
  //#endregion
264
- //#region src/auth/jwt.ts
264
+ //#region src/http/reach.ts
265
+ /** Unreachable is a request that never reached a server, so there is no answer to report. */
266
+ var Unreachable = class extends ExpectedFailure {
267
+ constructor(message) {
268
+ super(message);
269
+ this.name = "Unreachable";
270
+ }
271
+ };
272
+ const OFFLINE = "Couldn't connect to Hardfin. Check your internet connection and try again.";
273
+ 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
+ /** The reasons Node gives, by the code on the error behind fetch's own. */
275
+ const REASONS = {
276
+ ECONNREFUSED: {
277
+ message: OFFLINE,
278
+ detail: "connection refused"
279
+ },
280
+ ENOTFOUND: {
281
+ message: OFFLINE,
282
+ detail: "address not found"
283
+ },
284
+ EAI_AGAIN: {
285
+ message: OFFLINE,
286
+ detail: "address lookup failed"
287
+ },
288
+ ETIMEDOUT: {
289
+ message: OFFLINE,
290
+ detail: "timed out"
291
+ },
292
+ UND_ERR_CONNECT_TIMEOUT: {
293
+ message: OFFLINE,
294
+ detail: "timed out"
295
+ },
296
+ EHOSTUNREACH: {
297
+ message: OFFLINE,
298
+ detail: "no route to host"
299
+ },
300
+ ENETUNREACH: {
301
+ message: OFFLINE,
302
+ detail: "network unreachable"
303
+ },
304
+ ECONNRESET: {
305
+ message: OFFLINE,
306
+ detail: "connection closed"
307
+ },
308
+ UND_ERR_SOCKET: {
309
+ message: OFFLINE,
310
+ detail: "connection closed"
311
+ },
312
+ DEPTH_ZERO_SELF_SIGNED_CERT: {
313
+ message: INSECURE,
314
+ detail: "certificate not trusted"
315
+ },
316
+ SELF_SIGNED_CERT_IN_CHAIN: {
317
+ message: INSECURE,
318
+ detail: "certificate not trusted"
319
+ },
320
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: {
321
+ message: INSECURE,
322
+ detail: "certificate not trusted"
323
+ },
324
+ UNABLE_TO_GET_ISSUER_CERT_LOCALLY: {
325
+ message: INSECURE,
326
+ detail: "certificate not trusted"
327
+ },
328
+ CERT_HAS_EXPIRED: {
329
+ message: INSECURE,
330
+ detail: "certificate expired"
331
+ },
332
+ ERR_TLS_CERT_ALTNAME_INVALID: {
333
+ message: INSECURE,
334
+ detail: "certificate is for another host"
335
+ },
336
+ ERR_SSL_WRONG_VERSION_NUMBER: {
337
+ message: OFFLINE,
338
+ detail: "not an HTTPS server"
339
+ },
340
+ EPROTO: {
341
+ message: OFFLINE,
342
+ detail: "not an HTTPS server"
343
+ }
344
+ };
265
345
  /**
266
- * toClaims reads an access token's payload for display. Nothing here verifies the
267
- * signature, because the API is what decides whether a token is good.
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.
268
349
  */
269
- function toClaims(token) {
270
- const payload = token.split(".")[1];
271
- if (!payload) return {};
272
- try {
273
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
350
+ const SIGN_IN_TIMEOUT_MS = 15e3;
351
+ /** How long a server may take to begin its answer, before the request is given up on. */
352
+ const ANSWER_TIMEOUT_MS = 6e4;
353
+ /**
354
+ * send is fetch, with a failure to connect reported in words anyone can act on. It gives up
355
+ * on a server that has not begun to answer in time, because a caller with no person watching
356
+ * would otherwise wait on a stalled connection for as long as it stayed open.
357
+ */
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) => {
274
368
  return {
275
- expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
276
- issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
277
- scopes: toScopes(decoded),
278
- subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
369
+ response,
370
+ body: await response.json().catch((error) => {
371
+ if (isOver()) throw error;
372
+ })
279
373
  };
280
- } catch {
281
- return {};
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) {
378
+ const deadline = new AbortController();
379
+ const timer = setTimeout(() => {
380
+ deadline.abort();
381
+ }, timeoutMs);
382
+ try {
383
+ return await work(await fetch(url, {
384
+ ...init,
385
+ signal: init?.signal ? AbortSignal.any([init.signal, deadline.signal]) : deadline.signal
386
+ }), () => deadline.signal.aborted);
387
+ } catch (error) {
388
+ if (deadline.signal.aborted) throw toTimedOut(url, init?.method ?? "GET", timeoutMs);
389
+ throw toUnreachable(url, error);
390
+ } finally {
391
+ clearTimeout(timer);
282
392
  }
283
393
  }
284
- /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
285
- function toScopes(decoded) {
286
- if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
287
- return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
394
+ /** toTimedOut describes a request given up on, and says when it may have gone through anyway. */
395
+ function toTimedOut(url, method, timeoutMs) {
396
+ const waited = `${new URL(url).host} did not answer within ${timeoutMs / 1e3} seconds`;
397
+ return new TimedOut(["GET", "HEAD"].includes(method.toUpperCase()) ? waited : `${waited}. The change may still have been made, so read the record before sending it again`);
398
+ }
399
+ /**
400
+ * toUnreachable describes a request that could not connect. Fetch reports every such failure
401
+ * as "fetch failed", and the reason is on the error it wraps. The person reads a plain
402
+ * message, and the line after it names the reason and host for whoever helps them.
403
+ */
404
+ function toUnreachable(url, error) {
405
+ const host = new URL(url).host;
406
+ const cause = error instanceof Error ? error.cause : void 0;
407
+ const code = cause instanceof Error ? cause.code : void 0;
408
+ const reason = (error instanceof Error && error.name === "TimeoutError" ? REASONS["ETIMEDOUT"] : code === void 0 ? void 0 : REASONS[code]) ?? {
409
+ message: OFFLINE,
410
+ detail: cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error)
411
+ };
412
+ return new Unreachable(`${reason.message}\nDetails: ${reason.detail} (${host})`);
413
+ }
414
+ //#endregion
415
+ //#region src/auth/grant.ts
416
+ const TokenResponse = z.looseObject({
417
+ access_token: z.string(),
418
+ token_type: z.string(),
419
+ expires_in: z.number().optional(),
420
+ refresh_token: z.string().optional(),
421
+ refresh_token_expires_in: z.number().optional(),
422
+ scope: z.string().optional()
423
+ });
424
+ /** GrantFailure is a token request the authorization server refused. */
425
+ var GrantFailure = class extends ExpectedFailure {
426
+ code;
427
+ constructor(code, description) {
428
+ super(description ? `${code}: ${description}` : code);
429
+ this.name = "GrantFailure";
430
+ this.code = code;
431
+ }
432
+ };
433
+ /** SignInStopped is a sign in the person ended themselves, in a way other than Ctrl-C. */
434
+ var SignInStopped = class extends ExpectedFailure {
435
+ constructor(message) {
436
+ super(message);
437
+ this.name = "SignInStopped";
438
+ }
439
+ };
440
+ /** toTokensFromCode trades an authorization code for tokens. */
441
+ async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
442
+ return await toTokens(tokenUrl, {
443
+ grant_type: "authorization_code",
444
+ client_id: clientId,
445
+ code,
446
+ redirect_uri: redirectUri,
447
+ code_verifier: pkce.verifier
448
+ });
449
+ }
450
+ /** toTokensFromRefresh trades a refresh token for a fresh pair. */
451
+ async function toTokensFromRefresh(tokenUrl, clientId, refreshToken) {
452
+ return await toTokens(tokenUrl, {
453
+ grant_type: "refresh_token",
454
+ client_id: clientId,
455
+ refresh_token: refreshToken
456
+ });
457
+ }
458
+ /** revoke tells the server to forget a token, so signing out reaches every machine. */
459
+ async function revoke(revocationUrl, clientId, token) {
460
+ await send(revocationUrl, {
461
+ method: "POST",
462
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
463
+ body: new URLSearchParams({
464
+ client_id: clientId,
465
+ token,
466
+ token_type_hint: "refresh_token"
467
+ })
468
+ }, SIGN_IN_TIMEOUT_MS);
469
+ }
470
+ /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
471
+ async function toTokens(url, form) {
472
+ const answered = await sendForJSON(url, {
473
+ method: "POST",
474
+ headers: {
475
+ "Content-Type": "application/x-www-form-urlencoded",
476
+ Accept: "application/json"
477
+ },
478
+ body: new URLSearchParams(form)
479
+ });
480
+ const response = answered.response;
481
+ const body = toRecord$1(answered.body);
482
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
483
+ const parsed = TokenResponse.safeParse(body);
484
+ if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
485
+ return {
486
+ accessToken: parsed.data.access_token,
487
+ refreshToken: parsed.data.refresh_token,
488
+ refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
489
+ scope: parsed.data.scope,
490
+ expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
491
+ };
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;
288
496
  }
289
497
  //#endregion
290
498
  //#region src/credential/store.ts
@@ -418,111 +626,6 @@ function writeFile(held) {
418
626
  renameSync(pending, path);
419
627
  }
420
628
  //#endregion
421
- //#region src/http/reach.ts
422
- /** Unreachable is a request that never reached a server, so there is no answer to report. */
423
- var Unreachable = class extends ExpectedFailure {
424
- constructor(message) {
425
- super(message);
426
- this.name = "Unreachable";
427
- }
428
- };
429
- const OFFLINE = "Couldn't connect to Hardfin. Check your internet connection and try again.";
430
- 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.";
431
- /** The reasons Node gives, by the code on the error behind fetch's own. */
432
- const REASONS = {
433
- ECONNREFUSED: {
434
- message: OFFLINE,
435
- detail: "connection refused"
436
- },
437
- ENOTFOUND: {
438
- message: OFFLINE,
439
- detail: "address not found"
440
- },
441
- EAI_AGAIN: {
442
- message: OFFLINE,
443
- detail: "address lookup failed"
444
- },
445
- ETIMEDOUT: {
446
- message: OFFLINE,
447
- detail: "timed out"
448
- },
449
- UND_ERR_CONNECT_TIMEOUT: {
450
- message: OFFLINE,
451
- detail: "timed out"
452
- },
453
- EHOSTUNREACH: {
454
- message: OFFLINE,
455
- detail: "no route to host"
456
- },
457
- ENETUNREACH: {
458
- message: OFFLINE,
459
- detail: "network unreachable"
460
- },
461
- ECONNRESET: {
462
- message: OFFLINE,
463
- detail: "connection closed"
464
- },
465
- UND_ERR_SOCKET: {
466
- message: OFFLINE,
467
- detail: "connection closed"
468
- },
469
- DEPTH_ZERO_SELF_SIGNED_CERT: {
470
- message: INSECURE,
471
- detail: "certificate not trusted"
472
- },
473
- SELF_SIGNED_CERT_IN_CHAIN: {
474
- message: INSECURE,
475
- detail: "certificate not trusted"
476
- },
477
- UNABLE_TO_VERIFY_LEAF_SIGNATURE: {
478
- message: INSECURE,
479
- detail: "certificate not trusted"
480
- },
481
- UNABLE_TO_GET_ISSUER_CERT_LOCALLY: {
482
- message: INSECURE,
483
- detail: "certificate not trusted"
484
- },
485
- CERT_HAS_EXPIRED: {
486
- message: INSECURE,
487
- detail: "certificate expired"
488
- },
489
- ERR_TLS_CERT_ALTNAME_INVALID: {
490
- message: INSECURE,
491
- detail: "certificate is for another host"
492
- },
493
- ERR_SSL_WRONG_VERSION_NUMBER: {
494
- message: OFFLINE,
495
- detail: "not an HTTPS server"
496
- },
497
- EPROTO: {
498
- message: OFFLINE,
499
- detail: "not an HTTPS server"
500
- }
501
- };
502
- /** send is fetch, with a failure to connect reported in words anyone can act on. */
503
- async function send(url, init) {
504
- try {
505
- return await fetch(url, init);
506
- } catch (error) {
507
- throw toUnreachable(url, error);
508
- }
509
- }
510
- /**
511
- * toUnreachable describes a request that could not connect. Fetch reports every such failure
512
- * as "fetch failed", and the reason is on the error it wraps. The person reads a plain
513
- * message, and the line after it names the reason and host for whoever helps them.
514
- */
515
- function toUnreachable(url, error) {
516
- const host = new URL(url).host;
517
- const cause = error instanceof Error ? error.cause : void 0;
518
- const code = cause instanceof Error ? cause.code : void 0;
519
- const reason = (error instanceof Error && error.name === "TimeoutError" ? REASONS["ETIMEDOUT"] : code === void 0 ? void 0 : REASONS[code]) ?? {
520
- message: OFFLINE,
521
- detail: cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error)
522
- };
523
- return new Unreachable(`${reason.message}\nDetails: ${reason.detail} (${host})`);
524
- }
525
- //#endregion
526
629
  //#region src/auth/metadata.ts
527
630
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
528
631
  const AuthorizationServerMetadata = z.looseObject({
@@ -549,12 +652,12 @@ async function toMetadata(issuer, signal) {
549
652
  const cached = toCached(issuer);
550
653
  if (cached) return cached;
551
654
  const url = `${issuer.replace(/\/+$/, "")}${METADATA_PATH}`;
552
- const response = await send(url, {
655
+ const { response, body } = await sendForJSON(url, {
553
656
  headers: { Accept: "application/json" },
554
657
  signal
555
658
  });
556
659
  if (!response.ok) throw new DiscoveryFailure(`${url} answered ${response.status}, so this host publishes no authorization server`);
557
- const parsed = AuthorizationServerMetadata.safeParse(await response.json().catch(() => void 0));
660
+ const parsed = AuthorizationServerMetadata.safeParse(body);
558
661
  if (!parsed.success) throw new DiscoveryFailure(`${url} does not describe an authorization server`);
559
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`);
560
663
  keep(issuer, parsed.data);
@@ -591,75 +694,30 @@ function keep(issuer, document) {
591
694
  } catch {}
592
695
  }
593
696
  //#endregion
594
- //#region src/auth/grant.ts
595
- const TokenResponse = z.looseObject({
596
- access_token: z.string(),
597
- token_type: z.string(),
598
- expires_in: z.number().optional(),
599
- refresh_token: z.string().optional(),
600
- refresh_token_expires_in: z.number().optional(),
601
- scope: z.string().optional()
602
- });
603
- /** GrantFailure is a token request the authorization server refused. */
604
- var GrantFailure = class extends ExpectedFailure {
605
- code;
606
- constructor(code, description) {
607
- super(description ? `${code}: ${description}` : code);
608
- this.name = "GrantFailure";
609
- this.code = code;
697
+ //#region src/auth/jwt.ts
698
+ /**
699
+ * toClaims reads an access token's payload for display. Nothing here verifies the
700
+ * signature, because the API is what decides whether a token is good.
701
+ */
702
+ function toClaims(token) {
703
+ const payload = token.split(".")[1];
704
+ if (!payload) return {};
705
+ try {
706
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
707
+ return {
708
+ expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
709
+ issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
710
+ scopes: toScopes(decoded),
711
+ subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
712
+ };
713
+ } catch {
714
+ return {};
610
715
  }
611
- };
612
- /** toTokensFromCode trades an authorization code for tokens. */
613
- async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
614
- return await toTokens(tokenUrl, {
615
- grant_type: "authorization_code",
616
- client_id: clientId,
617
- code,
618
- redirect_uri: redirectUri,
619
- code_verifier: pkce.verifier
620
- });
621
716
  }
622
- /** toTokensFromRefresh trades a refresh token for a fresh pair. */
623
- async function toTokensFromRefresh(tokenUrl, clientId, refreshToken) {
624
- return await toTokens(tokenUrl, {
625
- grant_type: "refresh_token",
626
- client_id: clientId,
627
- refresh_token: refreshToken
628
- });
629
- }
630
- /** revoke tells the server to forget a token, so signing out reaches every machine. */
631
- async function revoke(revocationUrl, clientId, token) {
632
- await send(revocationUrl, {
633
- method: "POST",
634
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
635
- body: new URLSearchParams({
636
- client_id: clientId,
637
- token,
638
- token_type_hint: "refresh_token"
639
- })
640
- });
641
- }
642
- /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
643
- async function toTokens(url, form) {
644
- const response = await send(url, {
645
- method: "POST",
646
- headers: {
647
- "Content-Type": "application/x-www-form-urlencoded",
648
- Accept: "application/json"
649
- },
650
- body: new URLSearchParams(form)
651
- });
652
- const body = await response.json().catch(() => void 0);
653
- if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
654
- const parsed = TokenResponse.safeParse(body);
655
- if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
656
- return {
657
- accessToken: parsed.data.access_token,
658
- refreshToken: parsed.data.refresh_token,
659
- refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
660
- scope: parsed.data.scope,
661
- expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
662
- };
717
+ /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
718
+ function toScopes(decoded) {
719
+ if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
720
+ return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
663
721
  }
664
722
  //#endregion
665
723
  //#region src/credential/lock.ts
@@ -916,6 +974,18 @@ var RequestFailure = class extends ExpectedFailure {
916
974
  this.requestId = requestId;
917
975
  }
918
976
  };
977
+ /**
978
+ * UnexpectedAnswer is a successful response that is not the API's envelope. A captive portal
979
+ * or a proxy answers 200 with a page of its own, which must not pass for an empty result.
980
+ */
981
+ var UnexpectedAnswer = class extends ExpectedFailure {
982
+ constructor(host, contentType) {
983
+ super(`${host} answered with ${contentType ?? "something"} that is not the API's JSON, so nothing can be read from it. A proxy, a firewall or a network sign-in page may be answering in its place`);
984
+ this.name = "UnexpectedAnswer";
985
+ }
986
+ };
987
+ /** How long an upload may take to be answered, which is longer than any other request. */
988
+ const UPLOAD_TIMEOUT_MS = 3e5;
919
989
  /** request calls one /v2 endpoint and returns the envelope it answered with. */
920
990
  async function request$1(options) {
921
991
  const url = new URL(`${options.apiUrl}${toLeadingSlash(options.path)}`);
@@ -931,13 +1001,14 @@ async function request$1(options) {
931
1001
  signal: options.signal,
932
1002
  headers,
933
1003
  body: options.form ?? (options.body === void 0 ? void 0 : JSON.stringify(options.body))
934
- });
1004
+ }, options.form ? UPLOAD_TIMEOUT_MS : void 0);
935
1005
  if (options.downloads && response.ok) return { data: {
936
1006
  bytes: Buffer.from(await response.arrayBuffer()),
937
1007
  contentType: response.headers.get("content-type"),
938
1008
  fileName: toFileName(response.headers.get("content-disposition"))
939
1009
  } };
940
- const envelope = toEnvelope(await response.text());
1010
+ const text = await response.text();
1011
+ const envelope = toEnvelope(text);
941
1012
  if (!response.ok && envelope === void 0) throw new RequestFailure(response.status, [{
942
1013
  error: toStatusMessage(response.status),
943
1014
  statusCode: response.status
@@ -949,12 +1020,18 @@ async function request$1(options) {
949
1020
  statusCode: response.status
950
1021
  }], envelope?.metadata?.requestId);
951
1022
  }
1023
+ if (envelope === void 0 && text.trim() !== "") throw new UnexpectedAnswer(url.host, response.headers.get("content-type"));
952
1024
  return envelope ?? { data: null };
953
1025
  }
954
1026
  /** toFileName reads the name a download was offered under, when the server names one. */
955
1027
  function toFileName(disposition) {
956
1028
  const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition ?? "");
957
- 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
+ }
958
1035
  }
959
1036
  function toLeadingSlash(path) {
960
1037
  return path.startsWith("/") ? path : `/${path}`;
@@ -962,7 +1039,8 @@ function toLeadingSlash(path) {
962
1039
  function toEnvelope(text) {
963
1040
  if (text.trim() === "") return;
964
1041
  try {
965
- return JSON.parse(text);
1042
+ const parsed = JSON.parse(text);
1043
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
966
1044
  } catch {
967
1045
  return;
968
1046
  }
@@ -973,6 +1051,36 @@ function toStatusMessage(status) {
973
1051
  return `the request failed with status ${status}`;
974
1052
  }
975
1053
  //#endregion
1054
+ //#region src/report/classify.ts
1055
+ /**
1056
+ * The codes Node gives when the machine refuses something: permissions, a full or read-only
1057
+ * disk, a path that is missing or of the wrong kind, and a reader that closed the pipe.
1058
+ */
1059
+ const ENVIRONMENT_CODES = /* @__PURE__ */ new Set([
1060
+ "EACCES",
1061
+ "EPERM",
1062
+ "ENOSPC",
1063
+ "EROFS",
1064
+ "ENOENT",
1065
+ "EISDIR",
1066
+ "ENOTDIR",
1067
+ "EEXIST",
1068
+ "EPIPE"
1069
+ ]);
1070
+ /**
1071
+ * isReportable answers whether an error is a defect in the CLI. Everything the CLI anticipated
1072
+ * extends ExpectedFailure, so a failure nobody has classified yet is reported until someone
1073
+ * decides it is expected.
1074
+ */
1075
+ function isReportable(error) {
1076
+ return !(error instanceof ExpectedFailure) && !isMachineRefusal(error);
1077
+ }
1078
+ /** isMachineRefusal answers whether an error is the machine refusing a file or a folder. */
1079
+ function isMachineRefusal(error) {
1080
+ const code = error instanceof Error ? error.code : void 0;
1081
+ return typeof code === "string" && ENVIRONMENT_CODES.has(code);
1082
+ }
1083
+ //#endregion
976
1084
  //#region src/output/failure.ts
977
1085
  /** FAILURE_CODES is every code a failure can carry, and none is ever renamed or given a new meaning. */
978
1086
  const FAILURE_CODES = {
@@ -986,6 +1094,7 @@ const FAILURE_CODES = {
986
1094
  not_confirmed: "The change was not confirmed. Pass --yes to confirm it without a terminal",
987
1095
  shell_unavailable: "The shell did not say where its startup file is",
988
1096
  invalid_config: "A config file or setting cannot be read",
1097
+ file_error: "This machine refused a file or folder the command needed, such as a path that does not exist",
989
1098
  not_signed_in: "No usable credential. Run hardfin login, or set HARDFIN_API_KEY",
990
1099
  sign_in_failed: "Signing in did not finish",
991
1100
  unreachable: "The CLI could not connect to the server",
@@ -1001,6 +1110,7 @@ const FAILURE_CODES = {
1001
1110
  rate_limited: "Too many requests. Wait, then try again (HTTP 429)",
1002
1111
  server_error: "The API failed to answer (HTTP 5xx)",
1003
1112
  api_error: "The API refused the request with another status",
1113
+ unexpected_response: "The answer was not the API's JSON, as when a proxy or a sign-in page answers in its place",
1004
1114
  internal_error: "A problem in hardfin itself, which has been reported"
1005
1115
  };
1006
1116
  /** toStatusCode names a refusal by the HTTP status the API answered with. */
@@ -1025,6 +1135,9 @@ function toErrorCode(error) {
1025
1135
  if (error instanceof Unreachable) return "unreachable";
1026
1136
  if (error instanceof TimedOut) return "timed_out";
1027
1137
  if (error instanceof LockHeld) return "busy";
1138
+ if (error instanceof UnexpectedAnswer) return "unexpected_response";
1139
+ if (error instanceof GrantFailure || error instanceof DiscoveryFailure || error instanceof SignInStopped) return "sign_in_failed";
1140
+ if (isMachineRefusal(error)) return "file_error";
1028
1141
  return "internal_error";
1029
1142
  }
1030
1143
  //#endregion
@@ -1115,7 +1228,7 @@ function toCommandLines(command, parents) {
1115
1228
  }
1116
1229
  async function runAgentGuide(input) {
1117
1230
  if (input.flags["json"] === true) {
1118
- writeData(input.commands.map(toSummary));
1231
+ writeData(input.commands.filter((command) => !command.hidden).map(toSummary));
1119
1232
  return ExitCode.OK;
1120
1233
  }
1121
1234
  writeData(toGuide(input.commands, version$1));
@@ -1296,26 +1409,41 @@ function isWSL() {
1296
1409
  return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
1297
1410
  }
1298
1411
  /**
1299
- * 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,
1300
1433
  * because the browser the person is looking at is the Windows one, and xdg-open reaches
1301
1434
  * nothing outside the distribution.
1302
1435
  */
1303
- function toOpeners(url) {
1304
- if (process.platform === "darwin") return [["open", [url]]];
1305
- if (process.platform === "win32") return [["cmd", [
1306
- "/c",
1307
- "start",
1308
- "",
1309
- url
1310
- ]]];
1311
- 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 [
1312
1440
  ["wslview", [url]],
1313
1441
  ["powershell.exe", [
1314
1442
  "-NoProfile",
1315
1443
  "-NonInteractive",
1316
1444
  "-Command",
1317
1445
  "Start-Process",
1318
- `'${url}'`
1446
+ `'${url.replaceAll("'", "''")}'`
1319
1447
  ]],
1320
1448
  ["explorer.exe", [url]]
1321
1449
  ];
@@ -1323,7 +1451,9 @@ function toOpeners(url) {
1323
1451
  }
1324
1452
  /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1325
1453
  function openBrowser(url, launch = spawnSync) {
1326
- 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, {
1327
1457
  stdio: "ignore",
1328
1458
  cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1329
1459
  timeout: 1e4
@@ -2151,7 +2281,7 @@ const DeviceResponse = z.looseObject({
2151
2281
  });
2152
2282
  /** toDeviceAuthorization asks for a code a person can type on another machine. */
2153
2283
  async function toDeviceAuthorization(endpoint, clientId, scope) {
2154
- const response = await send(endpoint, {
2284
+ const answered = await sendForJSON(endpoint, {
2155
2285
  method: "POST",
2156
2286
  headers: {
2157
2287
  "Content-Type": "application/x-www-form-urlencoded",
@@ -2162,7 +2292,8 @@ async function toDeviceAuthorization(endpoint, clientId, scope) {
2162
2292
  scope
2163
2293
  })
2164
2294
  });
2165
- const body = await response.json().catch(() => void 0);
2295
+ const response = answered.response;
2296
+ const body = toRecord$1(answered.body);
2166
2297
  if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
2167
2298
  const parsed = DeviceResponse.safeParse(body);
2168
2299
  if (!parsed.success) throw new GrantFailure("invalid_response", "the device endpoint did not answer with a code");
@@ -2213,7 +2344,7 @@ const HOST = "127.0.0.1";
2213
2344
  * the machine hands out, which is why the server matches a loopback redirect by everything
2214
2345
  * except its port.
2215
2346
  */
2216
- async function toListener(timeoutMs) {
2347
+ async function toListener(timeoutMs, state) {
2217
2348
  let settle = () => {};
2218
2349
  let fail = () => {};
2219
2350
  const callback = new Promise((resolve, reject) => {
@@ -2226,7 +2357,11 @@ async function toListener(timeoutMs) {
2226
2357
  response.writeHead(404).end();
2227
2358
  return;
2228
2359
  }
2229
- 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);
2230
2365
  settle(answered);
2231
2366
  });
2232
2367
  await new Promise((resolve, reject) => {
@@ -2259,10 +2394,33 @@ function toCallback(request) {
2259
2394
  errorDescription: url.searchParams.get("error_description") ?? void 0
2260
2395
  };
2261
2396
  }
2262
- function writePage(response, callback) {
2263
- const title = callback.error ? "Sign in refused" : "Signed in";
2264
- const detail = callback.error ? `${callback.error}${callback.errorDescription ? `: ${callback.errorDescription}` : ""}` : "You can close this tab and return to your terminal.";
2265
- 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>`);
2266
2424
  }
2267
2425
  function close(server, timer) {
2268
2426
  clearTimeout(timer);
@@ -2450,9 +2608,9 @@ async function runLogin(input) {
2450
2608
  const metadata = await toMetadata(settings.issuerUrl);
2451
2609
  const scope = toText(input.flags["scope"] ?? DEFAULT_SCOPES);
2452
2610
  if (input.flags["device"] === true) return await runDeviceLogin(input, metadata, scope);
2453
- const listener = await toListener(WAIT_MS);
2454
- const pkce = toPkce();
2455
2611
  const state = toState();
2612
+ const listener = await toListener(WAIT_MS, state);
2613
+ const pkce = toPkce();
2456
2614
  const url = toAuthorizationUrl(metadata.authorization_endpoint, {
2457
2615
  clientId,
2458
2616
  redirectUri: listener.redirectUri,
@@ -2496,7 +2654,7 @@ async function runLogin(input) {
2496
2654
  }
2497
2655
  return await toSignedIn(input, metadata, await toTokensFromCode(metadata.token_endpoint, clientId, callback.code, listener.redirectUri, pkce));
2498
2656
  } catch (error) {
2499
- if (error instanceof DiscoveryFailure || error instanceof GrantFailure) {
2657
+ if (error instanceof DiscoveryFailure || error instanceof GrantFailure || error instanceof SignInStopped) {
2500
2658
  writeFailure("sign_in_failed", error.message, input.isJSON);
2501
2659
  return ExitCode.ERROR;
2502
2660
  }
@@ -2542,7 +2700,7 @@ async function runDeviceLogin(input, metadata, scope) {
2542
2700
  */
2543
2701
  async function toDeviceTokens(approved, typed) {
2544
2702
  return await Promise.race([approved, typed.then(() => {
2545
- throw new ExpectedFailure("Sign in stopped. The code goes on the page in your browser, not here. Run hardfin login to try again.");
2703
+ throw new SignInStopped("Sign in stopped. The code goes on the page in your browser, not here. Run hardfin login to try again.");
2546
2704
  })]);
2547
2705
  }
2548
2706
  /** toSignedIn stores what a sign in issued, whichever flow issued it. */
@@ -2709,7 +2867,7 @@ function defineOperation(operation) {
2709
2867
  async function runOperation(operation, input) {
2710
2868
  const path = toPath(operation, input.args);
2711
2869
  if (path === void 0) {
2712
- 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);
2713
2871
  return ExitCode.USAGE;
2714
2872
  }
2715
2873
  const given = toGivenBody(input.flags["input"]);
@@ -2772,7 +2930,12 @@ async function runOperation(operation, input) {
2772
2930
  */
2773
2931
  function toWritten(download, input) {
2774
2932
  const asked = input.flags["output"];
2775
- 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
+ }
2776
2939
  if (path === "-") {
2777
2940
  if (process.stdout.isTTY) {
2778
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);
@@ -2789,9 +2952,20 @@ function toWritten(download, input) {
2789
2952
  });
2790
2953
  return ExitCode.OK;
2791
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
+ ]);
2792
2966
  /** toPath fills the path template from the positional arguments, in order. */
2793
2967
  function toPath(operation, args) {
2794
- if (args.length !== operation.pathParameters.length) return;
2968
+ if (args.length !== operation.pathParameters.length || args.some((argument) => NOT_A_KEY.has(argument))) return;
2795
2969
  let path = operation.path;
2796
2970
  for (const [index, parameter] of operation.pathParameters.entries()) path = path.replace(`{${parameter.name}}`, encodeURIComponent(args[index] ?? ""));
2797
2971
  return path;
@@ -2929,6 +3103,10 @@ function toRefusedPath(flag, path, isWritten, root = process.cwd()) {
2929
3103
  if (isWithin(toReal(dirname(toCredentialPath())), target)) return refuse("it is where the sign in is kept");
2930
3104
  if (isWritten && existsSync(target)) return refuse("it already exists, and a call does not overwrite a file");
2931
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
+ }
2932
3110
  /** toEnvFile names the env file a person pointed the CLI at, when they did. */
2933
3111
  function toEnvFile(root) {
2934
3112
  const named = process.env["HARDFIN_ENV_FILE"];
@@ -3210,6 +3388,8 @@ function toServerReport(metadata) {
3210
3388
  scopes: metadata.scopes_supported ?? []
3211
3389
  };
3212
3390
  }
3391
+ /** The statuses the API answers when it will not take the credential. */
3392
+ const REFUSED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
3213
3393
  /** toApiReport asks the API who this credential is, which is the only authoritative answer. */
3214
3394
  async function toApiReport(resolved, options) {
3215
3395
  const settings = resolved.settings;
@@ -3222,6 +3402,7 @@ async function toApiReport(resolved, options) {
3222
3402
  };
3223
3403
  const claims = credential.kind === "access token" ? toClaims(credential.value.replace(/^Bearer /, "")) : {};
3224
3404
  let isReachable = true;
3405
+ let isAuthenticated = true;
3225
3406
  const identity = await request$1({
3226
3407
  apiUrl: settings.apiUrl,
3227
3408
  credential,
@@ -3229,12 +3410,13 @@ async function toApiReport(resolved, options) {
3229
3410
  path: "/token",
3230
3411
  signal: options.signal
3231
3412
  }).then((envelope) => envelope.data).catch((error) => {
3232
- isReachable = !(error instanceof Unreachable);
3413
+ isReachable = !(error instanceof Unreachable || error instanceof TimedOut);
3414
+ isAuthenticated = error instanceof RequestFailure && REFUSED_STATUSES.has(error.status) ? false : null;
3233
3415
  return { unavailable: error instanceof Error ? error.message : String(error) };
3234
3416
  });
3235
3417
  return {
3236
3418
  reachable: isReachable,
3237
- authenticated: true,
3419
+ authenticated: isAuthenticated,
3238
3420
  url: settings.apiUrl,
3239
3421
  accessTokenExpiresAt: claims.expiresAt === void 0 ? null : new Date(claims.expiresAt).toISOString(),
3240
3422
  accessTokenScopes: claims.scopes ?? [],
@@ -3300,33 +3482,6 @@ function toLines(report, depth = 0) {
3300
3482
  return lines;
3301
3483
  }
3302
3484
  //#endregion
3303
- //#region src/report/classify.ts
3304
- /**
3305
- * The codes Node gives when the machine refuses something: permissions, a full or read-only
3306
- * disk, a path that is missing or of the wrong kind, and a reader that closed the pipe.
3307
- */
3308
- const ENVIRONMENT_CODES = /* @__PURE__ */ new Set([
3309
- "EACCES",
3310
- "EPERM",
3311
- "ENOSPC",
3312
- "EROFS",
3313
- "ENOENT",
3314
- "EISDIR",
3315
- "ENOTDIR",
3316
- "EEXIST",
3317
- "EPIPE"
3318
- ]);
3319
- /**
3320
- * isReportable answers whether an error is a defect in the CLI. Everything the CLI anticipated
3321
- * extends ExpectedFailure, so a failure nobody has classified yet is reported until someone
3322
- * decides it is expected.
3323
- */
3324
- function isReportable(error) {
3325
- if (error instanceof ExpectedFailure) return false;
3326
- const code = error instanceof Error ? error.code : void 0;
3327
- return !(typeof code === "string" && ENVIRONMENT_CODES.has(code));
3328
- }
3329
- //#endregion
3330
3485
  //#region src/report/report.ts
3331
3486
  /** True once a report has been sent or tried, which the entry point reads before it exits. */
3332
3487
  let isAttempted = false;
@@ -3566,7 +3721,7 @@ function toResource(uri, commands) {
3566
3721
  }
3567
3722
  /** toTree names every command and what it takes, without the schemas a tool list would carry. */
3568
3723
  function toTree(commands) {
3569
- return commands.map((command) => ({
3724
+ return commands.filter((command) => !command.hidden).map((command) => ({
3570
3725
  name: command.name,
3571
3726
  summary: command.summary,
3572
3727
  arguments: command.arguments.map((argument) => argument.name),
@@ -3574,11 +3729,16 @@ function toTree(commands) {
3574
3729
  subcommands: command.subcommands ? toTree(command.subcommands) : void 0
3575
3730
  }));
3576
3731
  }
3577
- /** Signing in needs a browser and a person, neither of which an agent's session has. */
3578
- const REFUSED_COMMANDS = /* @__PURE__ */ new Set([
3579
- "login",
3580
- "logout",
3581
- "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"]
3582
3742
  ]);
3583
3743
  /**
3584
3744
  * toParsed asks the CLI's own parser what a call would run, without running it. The server
@@ -3617,7 +3777,8 @@ function toFixedOption(parsed) {
3617
3777
  */
3618
3778
  function toRefusedFile(parsed) {
3619
3779
  if (!parsed.ran) return;
3620
- 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`;
3621
3782
  for (const flag of command.flags.filter((entry) => PATH_VALUES.has(entry.valueName ?? ""))) {
3622
3783
  const path = flags[toOptionKey(flag.name)];
3623
3784
  const isWritten = flag.name === "output";
@@ -3642,18 +3803,44 @@ async function toToolResult(request, commands, run) {
3642
3803
  if (fixed) return toError(fixed);
3643
3804
  const confined = toRefusedFile(parsed);
3644
3805
  if (confined) return toError(confined);
3645
- const command = parsed.ran?.command.name ?? "";
3646
- 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`);
3647
3808
  if (name === READ_TOOL.name && toAccessOf(parsed) === "write") return toError(`this command changes data, so it runs through ${WRITE_TOOL.name}`);
3648
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);
3649
3812
  return {
3650
3813
  content: [{
3651
3814
  type: "text",
3652
- text: [outcome.stdout, outcome.stderr].filter((part) => part.trim() !== "").join("\n") || `hardfin exited ${outcome.code}`
3815
+ text: text || `hardfin exited ${outcome.code}`
3653
3816
  }],
3654
- isError: outcome.code !== 0
3817
+ isError: outcome.code !== 0,
3818
+ ...failure ? { structuredContent: failure } : {}
3655
3819
  };
3656
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
+ }
3657
3844
  function toError(text) {
3658
3845
  return {
3659
3846
  content: [{
@@ -3737,35 +3924,72 @@ function toRun(entry, args, input, timeoutMs) {
3737
3924
  });
3738
3925
  return;
3739
3926
  }
3740
- let stdout = "";
3741
- let stderr = "";
3927
+ const stdout = toCollector();
3928
+ const stderr = toCollector();
3742
3929
  let isTimedOut = false;
3743
3930
  const timer = setTimeout(() => {
3744
3931
  isTimedOut = true;
3745
3932
  child.kill("SIGKILL");
3746
3933
  }, timeoutMs);
3747
- child.stdout.on("data", (chunk) => {
3748
- stdout += chunk.toString();
3749
- });
3750
- child.stderr.on("data", (chunk) => {
3751
- stderr += chunk.toString();
3752
- });
3934
+ child.stdout.on("data", stdout.take);
3935
+ child.stderr.on("data", stderr.take);
3753
3936
  child.on("error", (error) => {
3754
- stderr += `the command could not start: ${error.message}\n`;
3937
+ stderr.take(Buffer.from(`the command could not start: ${error.message}\n`));
3755
3938
  });
3756
3939
  child.on("close", (code) => {
3757
3940
  clearTimeout(timer);
3758
- 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
+ }
3759
3953
  resolve({
3760
- stdout,
3761
- stderr,
3762
- 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
3763
3957
  });
3764
3958
  });
3765
3959
  child.stdin.on("error", () => void 0);
3766
3960
  child.stdin.end(input ?? "");
3767
3961
  });
3768
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
+ }
3769
3993
  /** serve answers requests on stdin until the client closes it. */
3770
3994
  async function serve(commands, run = toCliRunner()) {
3771
3995
  const lines = createInterface$1({ input: process.stdin });
@@ -6634,5 +6858,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
6634
6858
  //#endregion
6635
6859
  export { toCli, toParseExitCode, toReported, wasReportAttempted };
6636
6860
 
6637
- //# sourceMappingURL=program-C1-jZp6K.js.map
6638
- //# debugId=8cef3750-26c4-5378-89b9-1b3b5b7aaaa7
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.41",
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.",
@@ -124,13 +124,17 @@ The exit code says what kind of failure it was.
124
124
 
125
125
  The error is JSON on stderr. Branch on its `code`, never on the message.
126
126
 
127
- | `code` | Do this |
128
- | --------------------------------------------------- | ---------------------------------------------------------------- |
129
- | `not_signed_in`, `unauthorized` | Sign in again |
130
- | `invalid_flag`, `missing_flag`, `invalid_arguments` | Read `--help` for that command |
131
- | `not_found` | Check you passed a key, not an ID, where the command wants a key |
132
- | `bad_request`, `invalid_request`, `conflict` | Read `errors`, which names the field |
133
- | `rate_limited` | Wait a minute, then retry once |
134
- | `unreachable`, `server_error` | Tell the person, and include `requestId` if there is one |
127
+ | `code` | Do this |
128
+ | --------------------------------------------------- | ------------------------------------------------------------------ |
129
+ | `not_signed_in`, `unauthorized` | Sign in again |
130
+ | `invalid_flag`, `missing_flag`, `invalid_arguments` | Read `--help` for that command |
131
+ | `not_found` | Check you passed a key, not an ID, where the command wants a key |
132
+ | `bad_request`, `invalid_request`, `conflict` | Read `errors`, which names the field |
133
+ | `rate_limited` | Wait a minute, then retry once |
134
+ | `unreachable`, `server_error` | Tell the person, and include `requestId` if there is one |
135
+ | `timed_out` | For a change, read the record first, because it may have been made |
136
+ | `unexpected_response` | Tell the person a proxy or network page answered, not Hardfin |
137
+ | `file_error` | Check the path you gave exists and can be read or written |
138
+ | `sign_in_failed` | Tell the person, and have them run `hardfin login` |
135
139
 
136
140
  `hardfin agent-guide` lists every code.