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

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
@@ -109,6 +109,17 @@ stderr, so a pipe still carries only the result.
109
109
  | `requestId` | The API's request ID, or null when the request never reached it |
110
110
  | `status` | The HTTP status, or null for a failure caught before any request is made |
111
111
 
112
+ Three codes cover failures that are easy to mistake for something else.
113
+
114
+ | `code` | Happens when |
115
+ | --------------------- | ------------------------------------------------------------------------------------------- |
116
+ | `unexpected_response` | A 200 whose body is not the API's JSON, such as a proxy's page. It is never an empty result |
117
+ | `timed_out` | The server did not begin to answer within 60 seconds, or 300 for an upload |
118
+ | `file_error` | This machine refused a path the command was given |
119
+
120
+ A change that timed out may still have been made, and the message says so. Read the record
121
+ before sending the change again.
122
+
112
123
  An agent branches on `code`. A code is never renamed and never changes meaning, while the
113
124
  message may be reworded at any time. `hardfin agent-guide` lists every code with what it
114
125
  means, and `src/output/failure.ts` defines them.
@@ -442,6 +453,10 @@ Nothing secret is printed. An API key and a refresh token are each reported as a
442
453
  `sha256:` fingerprint, which identifies a credential across two machines without disclosing
443
454
  it.
444
455
 
456
+ `api.authenticated` is `true` only when the API answered who the credential is. A 401 or a
457
+ 403 makes it `false`, and `hardfin status` then exits with code 4. It is `null` when the API
458
+ could not be asked, because no answer says nothing about the credential.
459
+
445
460
  `refreshExpiresAt` is what the server said when it issued the token. A sign in stored before
446
461
  the server reported one falls back to Hardfin's 90-day rule, and `refreshExpiryIsEstimated`
447
462
  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]="c2c38fd6-1925-561f-baf9-51faf8c4c4b8")}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-CqO1St9H.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-CqO1St9H.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=c2c38fd6-1925-561f-baf9-51faf8c4c4b8
@@ -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]="0a793a7f-2311-5aaf-b27d-0877574e2d18")}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,208 @@ 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
+ };
345
+ /** How long a server may take to begin its answer, before the request is given up on. */
346
+ const ANSWER_TIMEOUT_MS = 6e4;
265
347
  /**
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.
348
+ * send is fetch, with a failure to connect reported in words anyone can act on. It gives up
349
+ * on a server that has not begun to answer in time, because a caller with no person watching
350
+ * would otherwise wait on a stalled connection for as long as it stayed open.
268
351
  */
269
- function toClaims(token) {
270
- const payload = token.split(".")[1];
271
- if (!payload) return {};
352
+ async function send(url, init, timeoutMs = ANSWER_TIMEOUT_MS) {
353
+ const deadline = new AbortController();
354
+ const timer = setTimeout(() => {
355
+ deadline.abort();
356
+ }, timeoutMs);
272
357
  try {
273
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
274
- 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
279
- };
280
- } catch {
281
- return {};
358
+ return await fetch(url, {
359
+ ...init,
360
+ signal: init?.signal ? AbortSignal.any([init.signal, deadline.signal]) : deadline.signal
361
+ });
362
+ } catch (error) {
363
+ if (deadline.signal.aborted) throw toTimedOut(url, init?.method ?? "GET", timeoutMs);
364
+ throw toUnreachable(url, error);
365
+ } finally {
366
+ clearTimeout(timer);
282
367
  }
283
368
  }
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;
369
+ /** toTimedOut describes a request given up on, and says when it may have gone through anyway. */
370
+ function toTimedOut(url, method, timeoutMs) {
371
+ const waited = `${new URL(url).host} did not answer within ${timeoutMs / 1e3} seconds`;
372
+ 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`);
373
+ }
374
+ /**
375
+ * toUnreachable describes a request that could not connect. Fetch reports every such failure
376
+ * as "fetch failed", and the reason is on the error it wraps. The person reads a plain
377
+ * message, and the line after it names the reason and host for whoever helps them.
378
+ */
379
+ function toUnreachable(url, error) {
380
+ const host = new URL(url).host;
381
+ const cause = error instanceof Error ? error.cause : void 0;
382
+ const code = cause instanceof Error ? cause.code : void 0;
383
+ const reason = (error instanceof Error && error.name === "TimeoutError" ? REASONS["ETIMEDOUT"] : code === void 0 ? void 0 : REASONS[code]) ?? {
384
+ message: OFFLINE,
385
+ detail: cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error)
386
+ };
387
+ return new Unreachable(`${reason.message}\nDetails: ${reason.detail} (${host})`);
388
+ }
389
+ //#endregion
390
+ //#region src/auth/grant.ts
391
+ const TokenResponse = z.looseObject({
392
+ access_token: z.string(),
393
+ token_type: z.string(),
394
+ expires_in: z.number().optional(),
395
+ refresh_token: z.string().optional(),
396
+ refresh_token_expires_in: z.number().optional(),
397
+ scope: z.string().optional()
398
+ });
399
+ /** GrantFailure is a token request the authorization server refused. */
400
+ var GrantFailure = class extends ExpectedFailure {
401
+ code;
402
+ constructor(code, description) {
403
+ super(description ? `${code}: ${description}` : code);
404
+ this.name = "GrantFailure";
405
+ this.code = code;
406
+ }
407
+ };
408
+ /** SignInStopped is a sign in the person ended themselves, in a way other than Ctrl-C. */
409
+ var SignInStopped = class extends ExpectedFailure {
410
+ constructor(message) {
411
+ super(message);
412
+ this.name = "SignInStopped";
413
+ }
414
+ };
415
+ /** toTokensFromCode trades an authorization code for tokens. */
416
+ async function toTokensFromCode(tokenUrl, clientId, code, redirectUri, pkce) {
417
+ return await toTokens(tokenUrl, {
418
+ grant_type: "authorization_code",
419
+ client_id: clientId,
420
+ code,
421
+ redirect_uri: redirectUri,
422
+ code_verifier: pkce.verifier
423
+ });
424
+ }
425
+ /** toTokensFromRefresh trades a refresh token for a fresh pair. */
426
+ async function toTokensFromRefresh(tokenUrl, clientId, refreshToken) {
427
+ return await toTokens(tokenUrl, {
428
+ grant_type: "refresh_token",
429
+ client_id: clientId,
430
+ refresh_token: refreshToken
431
+ });
432
+ }
433
+ /** revoke tells the server to forget a token, so signing out reaches every machine. */
434
+ async function revoke(revocationUrl, clientId, token) {
435
+ await send(revocationUrl, {
436
+ method: "POST",
437
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
438
+ body: new URLSearchParams({
439
+ client_id: clientId,
440
+ token,
441
+ token_type_hint: "refresh_token"
442
+ })
443
+ });
444
+ }
445
+ /** toTokens asks the token endpoint, which answers a flat RFC 6749 body, not the envelope. */
446
+ async function toTokens(url, form) {
447
+ const response = await send(url, {
448
+ method: "POST",
449
+ headers: {
450
+ "Content-Type": "application/x-www-form-urlencoded",
451
+ Accept: "application/json"
452
+ },
453
+ body: new URLSearchParams(form)
454
+ });
455
+ const body = await response.json().catch(() => void 0);
456
+ if (!response.ok) throw new GrantFailure(toText(body?.["error"] ?? `http_${response.status}`), body?.["error_description"] === void 0 ? void 0 : toText(body["error_description"]));
457
+ const parsed = TokenResponse.safeParse(body);
458
+ if (!parsed.success) throw new GrantFailure("invalid_response", "the token endpoint did not answer with a token");
459
+ return {
460
+ accessToken: parsed.data.access_token,
461
+ refreshToken: parsed.data.refresh_token,
462
+ refreshExpiresAt: parsed.data.refresh_token_expires_in === void 0 ? void 0 : Date.now() + parsed.data.refresh_token_expires_in * 1e3,
463
+ scope: parsed.data.scope,
464
+ expiresAt: parsed.data.expires_in === void 0 ? void 0 : Date.now() + parsed.data.expires_in * 1e3
465
+ };
288
466
  }
289
467
  //#endregion
290
468
  //#region src/credential/store.ts
@@ -418,111 +596,6 @@ function writeFile(held) {
418
596
  renameSync(pending, path);
419
597
  }
420
598
  //#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
599
  //#region src/auth/metadata.ts
527
600
  const METADATA_PATH = "/.well-known/oauth-authorization-server";
528
601
  const AuthorizationServerMetadata = z.looseObject({
@@ -591,75 +664,30 @@ function keep(issuer, document) {
591
664
  } catch {}
592
665
  }
593
666
  //#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;
667
+ //#region src/auth/jwt.ts
668
+ /**
669
+ * toClaims reads an access token's payload for display. Nothing here verifies the
670
+ * signature, because the API is what decides whether a token is good.
671
+ */
672
+ function toClaims(token) {
673
+ const payload = token.split(".")[1];
674
+ if (!payload) return {};
675
+ try {
676
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
677
+ return {
678
+ expiresAt: typeof decoded["exp"] === "number" ? decoded["exp"] * 1e3 : void 0,
679
+ issuedAt: typeof decoded["iat"] === "number" ? decoded["iat"] * 1e3 : void 0,
680
+ scopes: toScopes(decoded),
681
+ subject: typeof decoded["sub"] === "string" ? decoded["sub"] : void 0
682
+ };
683
+ } catch {
684
+ return {};
610
685
  }
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
- }
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
686
  }
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
- };
687
+ /** toScopes reads scp, which is where Hardfin puts an access token's scopes. */
688
+ function toScopes(decoded) {
689
+ if (Array.isArray(decoded["scp"])) return decoded["scp"].map(String);
690
+ return typeof decoded["scope"] === "string" ? decoded["scope"].split(" ") : void 0;
663
691
  }
664
692
  //#endregion
665
693
  //#region src/credential/lock.ts
@@ -916,6 +944,18 @@ var RequestFailure = class extends ExpectedFailure {
916
944
  this.requestId = requestId;
917
945
  }
918
946
  };
947
+ /**
948
+ * UnexpectedAnswer is a successful response that is not the API's envelope. A captive portal
949
+ * or a proxy answers 200 with a page of its own, which must not pass for an empty result.
950
+ */
951
+ var UnexpectedAnswer = class extends ExpectedFailure {
952
+ constructor(host, contentType) {
953
+ 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`);
954
+ this.name = "UnexpectedAnswer";
955
+ }
956
+ };
957
+ /** How long an upload may take to be answered, which is longer than any other request. */
958
+ const UPLOAD_TIMEOUT_MS = 3e5;
919
959
  /** request calls one /v2 endpoint and returns the envelope it answered with. */
920
960
  async function request$1(options) {
921
961
  const url = new URL(`${options.apiUrl}${toLeadingSlash(options.path)}`);
@@ -931,13 +971,14 @@ async function request$1(options) {
931
971
  signal: options.signal,
932
972
  headers,
933
973
  body: options.form ?? (options.body === void 0 ? void 0 : JSON.stringify(options.body))
934
- });
974
+ }, options.form ? UPLOAD_TIMEOUT_MS : void 0);
935
975
  if (options.downloads && response.ok) return { data: {
936
976
  bytes: Buffer.from(await response.arrayBuffer()),
937
977
  contentType: response.headers.get("content-type"),
938
978
  fileName: toFileName(response.headers.get("content-disposition"))
939
979
  } };
940
- const envelope = toEnvelope(await response.text());
980
+ const text = await response.text();
981
+ const envelope = toEnvelope(text);
941
982
  if (!response.ok && envelope === void 0) throw new RequestFailure(response.status, [{
942
983
  error: toStatusMessage(response.status),
943
984
  statusCode: response.status
@@ -949,6 +990,7 @@ async function request$1(options) {
949
990
  statusCode: response.status
950
991
  }], envelope?.metadata?.requestId);
951
992
  }
993
+ if (envelope === void 0 && text.trim() !== "") throw new UnexpectedAnswer(url.host, response.headers.get("content-type"));
952
994
  return envelope ?? { data: null };
953
995
  }
954
996
  /** toFileName reads the name a download was offered under, when the server names one. */
@@ -962,7 +1004,8 @@ function toLeadingSlash(path) {
962
1004
  function toEnvelope(text) {
963
1005
  if (text.trim() === "") return;
964
1006
  try {
965
- return JSON.parse(text);
1007
+ const parsed = JSON.parse(text);
1008
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
966
1009
  } catch {
967
1010
  return;
968
1011
  }
@@ -973,6 +1016,36 @@ function toStatusMessage(status) {
973
1016
  return `the request failed with status ${status}`;
974
1017
  }
975
1018
  //#endregion
1019
+ //#region src/report/classify.ts
1020
+ /**
1021
+ * The codes Node gives when the machine refuses something: permissions, a full or read-only
1022
+ * disk, a path that is missing or of the wrong kind, and a reader that closed the pipe.
1023
+ */
1024
+ const ENVIRONMENT_CODES = /* @__PURE__ */ new Set([
1025
+ "EACCES",
1026
+ "EPERM",
1027
+ "ENOSPC",
1028
+ "EROFS",
1029
+ "ENOENT",
1030
+ "EISDIR",
1031
+ "ENOTDIR",
1032
+ "EEXIST",
1033
+ "EPIPE"
1034
+ ]);
1035
+ /**
1036
+ * isReportable answers whether an error is a defect in the CLI. Everything the CLI anticipated
1037
+ * extends ExpectedFailure, so a failure nobody has classified yet is reported until someone
1038
+ * decides it is expected.
1039
+ */
1040
+ function isReportable(error) {
1041
+ return !(error instanceof ExpectedFailure) && !isMachineRefusal(error);
1042
+ }
1043
+ /** isMachineRefusal answers whether an error is the machine refusing a file or a folder. */
1044
+ function isMachineRefusal(error) {
1045
+ const code = error instanceof Error ? error.code : void 0;
1046
+ return typeof code === "string" && ENVIRONMENT_CODES.has(code);
1047
+ }
1048
+ //#endregion
976
1049
  //#region src/output/failure.ts
977
1050
  /** FAILURE_CODES is every code a failure can carry, and none is ever renamed or given a new meaning. */
978
1051
  const FAILURE_CODES = {
@@ -986,6 +1059,7 @@ const FAILURE_CODES = {
986
1059
  not_confirmed: "The change was not confirmed. Pass --yes to confirm it without a terminal",
987
1060
  shell_unavailable: "The shell did not say where its startup file is",
988
1061
  invalid_config: "A config file or setting cannot be read",
1062
+ file_error: "This machine refused a file or folder the command needed, such as a path that does not exist",
989
1063
  not_signed_in: "No usable credential. Run hardfin login, or set HARDFIN_API_KEY",
990
1064
  sign_in_failed: "Signing in did not finish",
991
1065
  unreachable: "The CLI could not connect to the server",
@@ -1001,6 +1075,7 @@ const FAILURE_CODES = {
1001
1075
  rate_limited: "Too many requests. Wait, then try again (HTTP 429)",
1002
1076
  server_error: "The API failed to answer (HTTP 5xx)",
1003
1077
  api_error: "The API refused the request with another status",
1078
+ unexpected_response: "The answer was not the API's JSON, as when a proxy or a sign-in page answers in its place",
1004
1079
  internal_error: "A problem in hardfin itself, which has been reported"
1005
1080
  };
1006
1081
  /** toStatusCode names a refusal by the HTTP status the API answered with. */
@@ -1025,6 +1100,9 @@ function toErrorCode(error) {
1025
1100
  if (error instanceof Unreachable) return "unreachable";
1026
1101
  if (error instanceof TimedOut) return "timed_out";
1027
1102
  if (error instanceof LockHeld) return "busy";
1103
+ if (error instanceof UnexpectedAnswer) return "unexpected_response";
1104
+ if (error instanceof GrantFailure || error instanceof DiscoveryFailure || error instanceof SignInStopped) return "sign_in_failed";
1105
+ if (isMachineRefusal(error)) return "file_error";
1028
1106
  return "internal_error";
1029
1107
  }
1030
1108
  //#endregion
@@ -2496,7 +2574,7 @@ async function runLogin(input) {
2496
2574
  }
2497
2575
  return await toSignedIn(input, metadata, await toTokensFromCode(metadata.token_endpoint, clientId, callback.code, listener.redirectUri, pkce));
2498
2576
  } catch (error) {
2499
- if (error instanceof DiscoveryFailure || error instanceof GrantFailure) {
2577
+ if (error instanceof DiscoveryFailure || error instanceof GrantFailure || error instanceof SignInStopped) {
2500
2578
  writeFailure("sign_in_failed", error.message, input.isJSON);
2501
2579
  return ExitCode.ERROR;
2502
2580
  }
@@ -2542,7 +2620,7 @@ async function runDeviceLogin(input, metadata, scope) {
2542
2620
  */
2543
2621
  async function toDeviceTokens(approved, typed) {
2544
2622
  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.");
2623
+ throw new SignInStopped("Sign in stopped. The code goes on the page in your browser, not here. Run hardfin login to try again.");
2546
2624
  })]);
2547
2625
  }
2548
2626
  /** toSignedIn stores what a sign in issued, whichever flow issued it. */
@@ -3210,6 +3288,8 @@ function toServerReport(metadata) {
3210
3288
  scopes: metadata.scopes_supported ?? []
3211
3289
  };
3212
3290
  }
3291
+ /** The statuses the API answers when it will not take the credential. */
3292
+ const REFUSED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
3213
3293
  /** toApiReport asks the API who this credential is, which is the only authoritative answer. */
3214
3294
  async function toApiReport(resolved, options) {
3215
3295
  const settings = resolved.settings;
@@ -3222,6 +3302,7 @@ async function toApiReport(resolved, options) {
3222
3302
  };
3223
3303
  const claims = credential.kind === "access token" ? toClaims(credential.value.replace(/^Bearer /, "")) : {};
3224
3304
  let isReachable = true;
3305
+ let isAuthenticated = true;
3225
3306
  const identity = await request$1({
3226
3307
  apiUrl: settings.apiUrl,
3227
3308
  credential,
@@ -3229,12 +3310,13 @@ async function toApiReport(resolved, options) {
3229
3310
  path: "/token",
3230
3311
  signal: options.signal
3231
3312
  }).then((envelope) => envelope.data).catch((error) => {
3232
- isReachable = !(error instanceof Unreachable);
3313
+ isReachable = !(error instanceof Unreachable || error instanceof TimedOut);
3314
+ isAuthenticated = error instanceof RequestFailure && REFUSED_STATUSES.has(error.status) ? false : null;
3233
3315
  return { unavailable: error instanceof Error ? error.message : String(error) };
3234
3316
  });
3235
3317
  return {
3236
3318
  reachable: isReachable,
3237
- authenticated: true,
3319
+ authenticated: isAuthenticated,
3238
3320
  url: settings.apiUrl,
3239
3321
  accessTokenExpiresAt: claims.expiresAt === void 0 ? null : new Date(claims.expiresAt).toISOString(),
3240
3322
  accessTokenScopes: claims.scopes ?? [],
@@ -3300,33 +3382,6 @@ function toLines(report, depth = 0) {
3300
3382
  return lines;
3301
3383
  }
3302
3384
  //#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
3385
  //#region src/report/report.ts
3331
3386
  /** True once a report has been sent or tried, which the entry point reads before it exits. */
3332
3387
  let isAttempted = false;
@@ -6634,5 +6689,5 @@ async function toReported(error, isJSON, resolved, level = "error") {
6634
6689
  //#endregion
6635
6690
  export { toCli, toParseExitCode, toReported, wasReportAttempted };
6636
6691
 
6637
- //# sourceMappingURL=program-C1-jZp6K.js.map
6638
- //# debugId=8cef3750-26c4-5378-89b9-1b3b5b7aaaa7
6692
+ //# sourceMappingURL=program-CqO1St9H.js.map
6693
+ //# debugId=0a793a7f-2311-5aaf-b27d-0877574e2d18
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.42",
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.