@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 +37 -0
- package/dist/cli.js +4 -4
- package/dist/{program-C1-jZp6K.js → program-CgT1Kr8L.js} +504 -280
- package/package.json +1 -1
- package/skills/hardfin-cli/SKILL.md +12 -8
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]="
|
|
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-
|
|
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-
|
|
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=
|
|
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]="
|
|
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/
|
|
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
|
-
*
|
|
267
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
369
|
+
response,
|
|
370
|
+
body: await response.json().catch((error) => {
|
|
371
|
+
if (isOver()) throw error;
|
|
372
|
+
})
|
|
279
373
|
};
|
|
280
|
-
}
|
|
281
|
-
|
|
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
|
-
/**
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
return
|
|
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
|
|
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(
|
|
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/
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
/**
|
|
623
|
-
|
|
624
|
-
return
|
|
625
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
1304
|
-
if (
|
|
1305
|
-
if (
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
3741
|
-
|
|
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",
|
|
3748
|
-
|
|
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
|
|
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
|
|
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:
|
|
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-
|
|
6638
|
-
//# debugId=
|
|
6861
|
+
//# sourceMappingURL=program-CgT1Kr8L.js.map
|
|
6862
|
+
//# debugId=2c607b02-61ea-5376-957f-d434d5692ac5
|
package/package.json
CHANGED
|
@@ -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.
|