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