@n42/cli 0.3.8 → 0.3.9

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.
@@ -0,0 +1,775 @@
1
+ class Terminal {
2
+ constructor(opts = {}) {
3
+ this.opts = {
4
+ title: "Request",
5
+ brand: "curl",
6
+ ...opts,
7
+ };
8
+
9
+ this._active = false;
10
+ this._controller = null;
11
+ this._lastFocused = null;
12
+
13
+ this._history = [];
14
+ this._historyIndex = -1;
15
+ }
16
+
17
+ async openAndRun(req) {
18
+ this.open(req);
19
+ try { await this.run(req); }
20
+ catch (e) {
21
+ // errors already printed by run(); rethrow if you want
22
+ }
23
+ }
24
+
25
+ open(req) {
26
+ if (!this.overlay) {
27
+ this._ensureDom();
28
+ this._printHelp();
29
+ }
30
+
31
+ if (this._active) return;
32
+ this._active = true;
33
+
34
+ this._lastFocused = document.activeElement;
35
+
36
+ this.overlay.classList.remove("hidden");
37
+ this.dialog.classList.remove("hidden");
38
+
39
+ this.input.value = `curl -X ${req.method} ${req.url}`;
40
+ this.input.focus();
41
+
42
+ // prevent background scroll
43
+ this._prevOverflow = document.documentElement.style.overflow;
44
+ document.documentElement.style.overflow = "hidden";
45
+ }
46
+
47
+ close() {
48
+ if (!this._active) return;
49
+ this._active = false;
50
+
51
+ this.abort("Modal closed");
52
+
53
+ this.overlay.classList.add("hidden");
54
+ this.dialog.classList.add("hidden");
55
+
56
+ document.documentElement.style.overflow = this._prevOverflow || "";
57
+
58
+ // restore focus to trigger
59
+ if (this._lastFocused && typeof this._lastFocused.focus === "function") {
60
+ this._lastFocused.focus();
61
+ }
62
+ }
63
+
64
+ abort(reason = "Aborted") {
65
+ if (this._controller) {
66
+ try { this._controller.abort(reason); } catch {}
67
+ }
68
+ this._controller = null;
69
+ this._setBusy(false);
70
+ }
71
+
72
+ async run(req) {
73
+ const normalized = this._normalizeReq(req);
74
+ this._resetTerminal();
75
+
76
+ const cmd = `$ ${this.opts.brand} ${this._toCurlArgs(normalized)}`;
77
+ this._addHistory(cmd)
78
+
79
+ this._printLine(cmd);
80
+ this._printLine("");
81
+
82
+ this._setBusy(true);
83
+
84
+ const t0 = performance.now();
85
+ const controller = new AbortController();
86
+ this._controller = controller;
87
+
88
+ // Timeout: use AbortSignal.timeout() if available; else manual.
89
+ let timeoutId = null;
90
+ const timeoutMs = normalized.timeoutMs;
91
+
92
+ let signal = controller.signal;
93
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" && timeoutMs > 0) {
94
+ // combine signals: abort either manual or timeout
95
+ signal = this._anySignal([controller.signal, AbortSignal.timeout(timeoutMs)]);
96
+ } else if (timeoutMs > 0) {
97
+ timeoutId = setTimeout(() => controller.abort("Timeout"), timeoutMs);
98
+ }
99
+
100
+ try {
101
+ const fetchInit = {
102
+ method: normalized.method,
103
+ headers: normalized.headers,
104
+ body: normalized.body,
105
+ signal,
106
+ credentials: normalized.credentials,
107
+ redirect: normalized.redirect,
108
+ cache: "no-store",
109
+ mode: normalized.mode,
110
+ };
111
+
112
+ this._printLine(`> ${normalized.method} ${normalized.url}`);
113
+ for (const [k, v] of Object.entries(normalized.headers || {})) {
114
+ this._printLine(`> ${k}: ${v}`);
115
+ }
116
+ if (normalized.body) this._printLine(`> (body ${this._byteLen(normalized.body)} bytes)`);
117
+ this._printLine("");
118
+
119
+ const res = await fetch(normalized.url, fetchInit);
120
+
121
+ const dt = (performance.now() - t0);
122
+ this._printLine(`< HTTP ${res.status} ${res.statusText} (${dt.toFixed(0)} ms)`);
123
+
124
+ res.headers.forEach((v, k) => this._printLine(`< ${k}: ${v}`));
125
+ this._printLine("");
126
+
127
+ const { text, truncated, bytesRead } = await this._readTextWithLimit(res, normalized.maxBytes);
128
+ if (truncated) {
129
+ this._printLine(`... [truncated after ${bytesRead} bytes (maxBytes=${normalized.maxBytes})] ...`);
130
+ this._printLine("");
131
+ }
132
+ this._printRaw(text);
133
+
134
+ this._printLine("");
135
+ this._printLine(`[done]`);
136
+ this._setBusy(false);
137
+ return { status: res.status, headers: res.headers, body: text, truncated };
138
+ }
139
+ catch (err) {
140
+ const name = err?.name || "Error";
141
+ const msg = err?.message || String(err);
142
+
143
+ this._printLine(`[${name}] ${msg}`);
144
+ this._setBusy(false);
145
+
146
+ return { status: 0, headers: new Headers(), body: "", error: msg };
147
+ }
148
+ finally {
149
+ if (timeoutId) clearTimeout(timeoutId);
150
+ this._controller = null;
151
+ }
152
+ }
153
+
154
+ // ---------------- private ----------------
155
+
156
+ _normalizeReq(req) {
157
+ if (!req || typeof req.url !== "string") throw new Error("Missing url");
158
+
159
+ const method = (req.method || "GET").toUpperCase();
160
+ const headers = { ...(req.headers || {}) };
161
+
162
+ // If body is object, default JSON
163
+ let body = req.body ?? null;
164
+ if (body && typeof body === "object" && !(body instanceof FormData) && !(body instanceof Blob)) {
165
+ if (!headers["Content-Type"] && !headers["content-type"]) {
166
+ headers["Content-Type"] = "application/json";
167
+ }
168
+ body = JSON.stringify(body);
169
+ }
170
+
171
+ return {
172
+ url: req.url,
173
+ method,
174
+ headers,
175
+ body,
176
+ timeoutMs: Number.isFinite(req.timeoutMs) ? req.timeoutMs : 15000,
177
+ maxBytes: Number.isFinite(req.maxBytes) ? req.maxBytes : 500_000,
178
+ credentials: req.credentials || "same-origin", // set "include" if cookies are needed
179
+ redirect: req.redirect || "follow",
180
+ mode: req.mode || "cors",
181
+ };
182
+ }
183
+
184
+ _addHistory(cmd) {
185
+ cmd = cmd.replace("$", "");
186
+ cmd = cmd.replace(/'/g, "");
187
+
188
+ // push to history
189
+ this._history.push(cmd.trim());
190
+ this._historyIndex = this._history.length;
191
+ }
192
+
193
+ _handleCommand(cmd) {
194
+ this._printLine(`$ ${cmd}`);
195
+
196
+ if (cmd === "clear") {
197
+ this._addHistory(cmd);
198
+ this._resetTerminal();
199
+ return;
200
+ }
201
+
202
+ if (cmd === "help") {
203
+ this._addHistory(cmd);
204
+ this._printHelp();
205
+ return;
206
+ }
207
+
208
+ if (cmd.startsWith("curl ")) {
209
+ // Parse: curl [-X METHOD] <url>
210
+ const m = cmd.match(/^curl\s+(?:-X\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+)?(https?:\/\/\S+)$/i);
211
+
212
+ if (m) {
213
+ const method = m[1] ? m[1].toUpperCase() : "GET";
214
+ const url = m[2];
215
+
216
+ this.openAndRun({ url, method });
217
+ return;
218
+ }
219
+ }
220
+
221
+ if (cmd === "trace") {
222
+ this._addHistory(cmd);
223
+
224
+ if (discoveryTrace) {
225
+ this._resetTerminal();
226
+ this._printLine(JSON.stringify(discoveryTrace, null, 2));
227
+ } else {
228
+ this._printLine("[error]: Discovery trace missing");
229
+ }
230
+ return;
231
+ }
232
+
233
+ this._printLine("[error]: Unknown command");
234
+ }
235
+
236
+ _createImgEl(src, size, alt) {
237
+ const img = document.createElement("img");
238
+ img.src = src;
239
+ img.width = size;
240
+ img.height = size;
241
+ img.alt = alt;
242
+ return img;
243
+ }
244
+
245
+ _ensureDom() {
246
+ if (this.overlay) return;
247
+
248
+ const textColor = this.opts.textColor;
249
+ const terminalTextColor = this.opts.terminalTextColor;
250
+
251
+ // Styles (namespaced)
252
+ const style = document.createElement("style");
253
+ style.id = "terminal-style";
254
+ style.textContent = `
255
+ .cm-overlay {
256
+ position: fixed; inset: 0;
257
+ background: rgba(0,0,0,.55);
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ padding: 24px;
262
+ z-index: 2147483647;
263
+ }
264
+ .cm-overlay.hidden {
265
+ display: none;
266
+ }
267
+ .cm-dialog {
268
+ width: min(980px, 100%);
269
+ height: min(720px, 100%);
270
+ background: #0b0f14;
271
+ color: ${textColor};
272
+ border: 1px solid rgba(255,255,255,.12);
273
+ border-radius: 12px;
274
+ box-shadow: 0 18px 60px rgba(0,0,0,.55);
275
+ display: flex;
276
+ flex-direction: column;
277
+ overflow: hidden;
278
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
279
+ }
280
+ .cm-display.hidden {
281
+ display: none;
282
+ }
283
+ .cm-head {
284
+ display: flex; align-items: center; gap: 12px;
285
+ padding: 10px 11px 10px 14px;
286
+ border-bottom: 1px solid rgba(255,255,255,.10);
287
+ background: rgba(255,255,255,.03);
288
+ }
289
+ .cm-title {
290
+ font-weight: 600;
291
+ font-size: 14px;
292
+ opacity: .95;
293
+ }
294
+ .cm-spacer { flex: 1; }
295
+ .cm-btn {
296
+ appearance: none;
297
+ border: 1px solid rgba(255,255,255,.18);
298
+ background: transparent;
299
+ color: inherit;
300
+ padding: 2px 6px 0 6px;
301
+ border-radius: 6px;
302
+ font-size: 12px;
303
+ cursor: pointer;
304
+ }
305
+ .cm-btn img {
306
+ width: 17px;
307
+ }
308
+ .cm-btn:hover {
309
+ border: 1px solid rgba(255,255,255,.38);
310
+ }
311
+ .cm-btn:disabled { opacity: .55; cursor: not-allowed; }
312
+ .cm-body {
313
+ flex: 1;
314
+ overflow: auto;
315
+ padding: 12px;
316
+
317
+ scrollbar-color: #323232 #0b0f14;
318
+ scrollbar-width: thin;
319
+ }
320
+ .cm-body::-webkit-scrollbar {
321
+ width: 10px;
322
+ }
323
+ .cm-body::-webkit-scrollbar-track {
324
+ background: #0b0f14;
325
+ }
326
+ .cm-body::-webkit-scrollbar-thumb {
327
+ background: #323232;
328
+ border-radius: 6px;
329
+ }
330
+ .cm-body::-webkit-scrollbar-thumb:hover {
331
+ background: #656565;
332
+ }
333
+ .cm-term {
334
+ white-space: pre-wrap;
335
+ word-break: break-word;
336
+ color: ${terminalTextColor};
337
+ line-height: 1.35;
338
+ font-size: 12px;
339
+ }
340
+ .cm-foot {
341
+ padding: 10px 12px;
342
+ border-top: 1px solid rgba(255,255,255,.10);
343
+ display: flex; align-items: center; gap: 10px;
344
+ background: rgba(255,255,255,.02);
345
+ }
346
+ .cm-status { font-size: 12px; opacity: .85; }
347
+ .cm-kbd {
348
+ font-size: 11px; opacity: .7;
349
+ border: 1px solid rgba(255,255,255,.16);
350
+ padding: 3px 6px 2px 6px;
351
+ border-radius: 6px;
352
+ }
353
+ .cm-input-row {
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 8px;
357
+ padding: 8px 12px;
358
+ border-top: 1px solid rgba(255,255,255,.10);
359
+ background: rgba(255,255,255,.02);
360
+ }
361
+ .cm-prompt {
362
+ opacity: .8;
363
+ font-size: 12px;
364
+ }
365
+ .cm-input {
366
+ flex: 1;
367
+ background: transparent;
368
+ color: ${terminalTextColor};
369
+ border: none;
370
+ outline: none;
371
+ font-family: inherit;
372
+ font-size: 12px;
373
+ }
374
+ `;
375
+
376
+ if (!document.getElementById("terminal-style")) {
377
+ //document.head.appendChild(style);
378
+ }
379
+
380
+ // Overlay
381
+ const overlay = document.createElement("div");
382
+ overlay.className = "cm-overlay";
383
+ overlay.hidden = true;
384
+
385
+ // Dialog
386
+ const dialog = document.createElement("div");
387
+ dialog.className = "cm-dialog";
388
+ dialog.hidden = true;
389
+ dialog.setAttribute("role", "dialog");
390
+ dialog.setAttribute("aria-modal", "true"); // per aria-modal guidance
391
+ dialog.setAttribute("aria-label", this.opts.title);
392
+
393
+ // Input row (terminal prompt)
394
+ const inputRow = document.createElement("div");
395
+ inputRow.className = "cm-input-row";
396
+
397
+ const prompt = document.createElement("span");
398
+ prompt.className = "cm-prompt";
399
+ prompt.textContent = ">";
400
+
401
+ const input = document.createElement("input");
402
+ input.className = "cm-input";
403
+ input.placeholder = "type command...";
404
+ input.addEventListener("keydown", (e) => {
405
+ if (e.key === "Enter") {
406
+ const cmd = input.value.trim();
407
+ input.value = "";
408
+
409
+ if (!cmd) return;
410
+
411
+ this._handleCommand(cmd);
412
+ }
413
+
414
+ // ---- HISTORY NAVIGATION ----
415
+ if (e.key === "ArrowUp") {
416
+ e.preventDefault();
417
+
418
+ if (this._history.length === 0) return;
419
+
420
+ this._historyIndex = Math.max(0, this._historyIndex - 1);
421
+ input.value = this._history[this._historyIndex];
422
+ return;
423
+ }
424
+
425
+ if (e.key === "ArrowDown") {
426
+ e.preventDefault();
427
+
428
+ if (this._history.length === 0) return;
429
+
430
+ this._historyIndex = Math.min(
431
+ this._history.length,
432
+ this._historyIndex + 1
433
+ );
434
+
435
+ input.value =
436
+ this._historyIndex < this._history.length
437
+ ? this._history[this._historyIndex]
438
+ : "";
439
+ return;
440
+ }
441
+ });
442
+
443
+ inputRow.append(prompt, input);
444
+
445
+ // Header
446
+ const head = document.createElement("div");
447
+ head.className = "cm-head";
448
+
449
+ const title = document.createElement("div");
450
+ title.className = "cm-title";
451
+ title.textContent = this.opts.title;
452
+
453
+ const spacer = document.createElement("div");
454
+ spacer.className = "cm-spacer";
455
+
456
+ const copyImg = this._createImgEl("../../assets/copy-light.svg", 24, "Copy");
457
+ const btnCopy = document.createElement("button");
458
+ btnCopy.className = "cm-btn copy";
459
+ btnCopy.type = "button";
460
+ btnCopy.title = "Copy";
461
+ //btnCopy.textContent = "Copy";
462
+ btnCopy.prepend(copyImg);
463
+ btnCopy.addEventListener("click", () => this._copy());
464
+
465
+ const downloadImg = this._createImgEl("../../assets/download-light.svg", 24, "Download");
466
+ const btnDownload = document.createElement("button");
467
+ btnDownload.className = "cm-btn";
468
+ btnDownload.type = "button";
469
+ btnDownload.title = "Download";
470
+ //btnDownload.textContent = "Download";
471
+ btnDownload.prepend(downloadImg);
472
+ btnDownload.addEventListener("click", () => this._download());
473
+
474
+ const openExtImg = this._createImgEl("../../assets/open-external-light.svg", 24, "Open");
475
+ const btnOpenExt = document.createElement("button");
476
+ btnOpenExt.className = "cm-btn";
477
+ btnOpenExt.type = "button";
478
+ btnOpenExt.title = "Open";
479
+ //btnOpenExt.textContent = "Open";
480
+ btnOpenExt.prepend(openExtImg);
481
+ btnOpenExt.addEventListener("click", () => this._openExt());
482
+
483
+ /*
484
+ const btnClose = document.createElement("button");
485
+ btnClose.className = "cm-btn";
486
+ btnClose.type = "button";
487
+ btnClose.textContent = "Close";
488
+ btnClose.addEventListener("click", () => this.close());
489
+ */
490
+
491
+ head.append(title, spacer, btnCopy, btnDownload, btnOpenExt);
492
+
493
+ // Body
494
+ const body = document.createElement("div");
495
+ body.className = "cm-body";
496
+
497
+ const term = document.createElement("div");
498
+ term.className = "cm-term";
499
+ term.setAttribute("role", "log");
500
+ term.setAttribute("aria-live", "polite");
501
+
502
+ body.appendChild(term);
503
+
504
+ // Footer
505
+ const foot = document.createElement("div");
506
+ foot.className = "cm-foot";
507
+
508
+ const status = document.createElement("div");
509
+ status.className = "cm-status";
510
+ status.textContent = "Ready";
511
+
512
+ const hint = document.createElement("div");
513
+ hint.className = "cm-spacer";
514
+ hint.textContent = "";
515
+
516
+ const kbdEsc = document.createElement("span");
517
+ kbdEsc.className = "cm-kbd";
518
+ kbdEsc.textContent = "ESC";
519
+
520
+ const hint2 = document.createElement("div");
521
+ hint2.style.opacity = ".75";
522
+ hint2.style.fontSize = "12px";
523
+ hint2.textContent = "to close";
524
+
525
+ foot.append(status, hint, kbdEsc, hint2);
526
+
527
+ dialog.append(head, body, inputRow, foot);
528
+ overlay.appendChild(dialog);
529
+ document.body.appendChild(overlay);
530
+
531
+ // Store refs
532
+ this.overlay = overlay;
533
+ this.dialog = dialog;
534
+ this.input = input;
535
+ this.term = term;
536
+ this.statusEl = status;
537
+
538
+ this.btnCopy = btnCopy;
539
+ this.btnDownload = btnDownload;
540
+ this.btnOpenExt = btnOpenExt;
541
+ //this.btnClose = btnClose;
542
+
543
+ // Close when clicking outside (optional)
544
+ overlay.addEventListener("click", (e) => {
545
+ if (e.target === overlay) this.close();
546
+ });
547
+
548
+ // Key handling + focus trap
549
+ document.addEventListener("keydown", (e) => {
550
+ if (!this._active) return;
551
+
552
+ if (e.key === "Escape") {
553
+ e.preventDefault();
554
+ this.close();
555
+ return;
556
+ }
557
+
558
+ if (e.key === "Tab") {
559
+ this._trapTab(e);
560
+ }
561
+ });
562
+ }
563
+
564
+ _trapTab(e) {
565
+ // Basic focus trap: keep tabbing inside dialog controls.
566
+ const focusables = this.dialog.querySelectorAll(
567
+ 'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'
568
+ );
569
+ const list = Array.from(focusables).filter(el => !el.disabled && el.offsetParent !== null);
570
+ if (list.length === 0) return;
571
+
572
+ const first = list[0];
573
+ const last = list[list.length - 1];
574
+ const active = document.activeElement;
575
+
576
+ if (e.shiftKey && active === first) {
577
+ e.preventDefault();
578
+ last.focus();
579
+ } else if (!e.shiftKey && active === last) {
580
+ e.preventDefault();
581
+ first.focus();
582
+ }
583
+ }
584
+
585
+ _setBusy(isBusy) {
586
+ this.statusEl.textContent = isBusy ? "Running…" : "Ready";
587
+ }
588
+
589
+ _openExt() {
590
+ const url = this.input.value.replace(/^.*?(?=https?:\/\/)/, "")
591
+ window.open(url, '_blank');
592
+ }
593
+
594
+ _resetTerminal() {
595
+ this.term.textContent = "";
596
+ }
597
+
598
+ _printHelp() {
599
+ this._resetTerminal();
600
+
601
+ this._printLine("Node42 Interactive Terminal (basic emulator)");
602
+ this._printLine("--------------------------------------------------");
603
+ this._printLine("This is a minimal command runner used inside the");
604
+ this._printLine("interactive SVG to test and follow discovery links.");
605
+ this._printLine("Only a small subset of commands is supported.");
606
+ this._printLine("");
607
+
608
+ this._printLine("Available commands:");
609
+ this._printLine(" curl [-X METHOD] <url> – send HTTP request to a URL");
610
+ this._printLine(" trace – output discovery trace");
611
+ this._printLine(" clear – clear terminal output");
612
+ this._printLine(" help – show this help text");
613
+ this._printLine("");
614
+
615
+ this._printLine("Keyboard support:");
616
+ this._printLine(" • ENTER – execute current command");
617
+ this._printLine(" • ARROW UP – previous command in history");
618
+ this._printLine(" • ARROW DOWN – next command in history");
619
+ this._printLine("");
620
+
621
+ this._printLine("Notes:");
622
+ this._printLine(" • Output is read-only");
623
+ this._printLine(" • No shell features (pipes, variables, etc.)");
624
+ this._printLine(" • Designed only for quick API testing from diagrams");
625
+ this._printLine("");
626
+
627
+ this._printLine("Examples:");
628
+ this._printLine(" curl https://api.node42.dev/health");
629
+ this._printLine(" curl -X POST https://api.node42.dev/health");
630
+ this._printLine("");
631
+ }
632
+
633
+ _printLine(s) {
634
+ this.term.textContent += `${s}\n`;
635
+ this._scrollToBottom();
636
+ }
637
+
638
+ _printRaw(s) {
639
+ this.term.textContent += s;
640
+ if (!s.endsWith("\n")) this.term.textContent += "\n";
641
+ this._scrollToBottom();
642
+ }
643
+
644
+ _scrollToBottom() {
645
+ const body = this.term.parentElement;
646
+ body.scrollTop = body.scrollHeight;
647
+ }
648
+
649
+ async _copy() {
650
+ const text = this.term.textContent || "";
651
+ try {
652
+ await navigator.clipboard.writeText(text);
653
+ this.statusEl.textContent = "Copied";
654
+ } catch {
655
+ // fallback
656
+ const ta = document.createElement("textarea");
657
+ ta.value = text;
658
+ ta.style.position = "fixed";
659
+ ta.style.left = "-9999px";
660
+ document.body.appendChild(ta);
661
+ ta.select();
662
+ try { document.execCommand("copy"); this.statusEl.textContent = "Copied"; }
663
+ catch { this.statusEl.textContent = "Copy failed"; }
664
+ ta.remove();
665
+ }
666
+ setTimeout(() => (this.statusEl.textContent = "Ready"), 900);
667
+ }
668
+
669
+ _download() {
670
+ const text = this.term.textContent || "";
671
+ const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
672
+ const url = URL.createObjectURL(blob);
673
+ const a = document.createElement("a");
674
+ a.href = url;
675
+ a.download = `curl-output-${new Date().toISOString().slice(0,19).replace(/[:T]/g,"-")}.txt`;
676
+ document.body.appendChild(a);
677
+ a.click();
678
+ a.remove();
679
+ URL.revokeObjectURL(url);
680
+ }
681
+
682
+ _toCurlArgs(req) {
683
+ // Presentational: escapes minimally for display (not execution-safe for all shells).
684
+ const parts = [];
685
+ parts.push(`-X ${req.method}`);
686
+ for (const [k, v] of Object.entries(req.headers || {})) {
687
+ parts.push(`-H ${this._shQuote(`${k}: ${v}`)}`);
688
+ }
689
+ if (req.body) parts.push(`--data ${this._shQuote(typeof req.body === "string" ? req.body : String(req.body))}`);
690
+ parts.push(this._shQuote(req.url));
691
+ return parts.join(" ");
692
+ }
693
+
694
+ _shQuote(s) {
695
+ // single-quote shell style: ' -> '"'"'
696
+ const str = String(s);
697
+ return `'${str.replace(/'/g, `'"'"'`)}'`;
698
+ }
699
+
700
+ _byteLen(s) {
701
+ if (typeof s !== "string") s = String(s);
702
+ return new TextEncoder().encode(s).length;
703
+ }
704
+
705
+ async _readTextWithLimit(res, maxBytes) {
706
+ // Reads response body safely with byte limit.
707
+ // If no stream support, fall back to res.text() then truncate.
708
+ const bytesLimit = Math.max(0, maxBytes | 0);
709
+
710
+ if (!res.body || !res.body.getReader) {
711
+ const full = await res.text();
712
+ const enc = new TextEncoder().encode(full);
713
+ if (enc.length <= bytesLimit) return { text: full, truncated: false, bytesRead: enc.length };
714
+ // truncate bytes -> decode
715
+ const cut = enc.slice(0, bytesLimit);
716
+ return { text: new TextDecoder().decode(cut), truncated: true, bytesRead: bytesLimit };
717
+ }
718
+
719
+ const reader = res.body.getReader();
720
+ const chunks = [];
721
+ let bytesRead = 0;
722
+ let truncated = false;
723
+
724
+ while (true) {
725
+ const { value, done } = await reader.read();
726
+ if (done) break;
727
+ if (!value) continue;
728
+
729
+ if (bytesRead + value.byteLength > bytesLimit) {
730
+ const allowed = bytesLimit - bytesRead;
731
+ if (allowed > 0) chunks.push(value.slice(0, allowed));
732
+ truncated = true;
733
+ bytesRead = bytesLimit;
734
+ try { reader.cancel("maxBytes reached"); } catch {}
735
+ break;
736
+ }
737
+
738
+ chunks.push(value);
739
+ bytesRead += value.byteLength;
740
+ }
741
+
742
+ const merged = this._concatU8(chunks, bytesRead);
743
+ const text = new TextDecoder().decode(merged);
744
+ return { text, truncated, bytesRead };
745
+ }
746
+
747
+ _concatU8(chunks, total) {
748
+ const out = new Uint8Array(total);
749
+ let offset = 0;
750
+ for (const c of chunks) {
751
+ out.set(c, offset);
752
+ offset += c.byteLength;
753
+ if (offset >= total) break;
754
+ }
755
+ return out;
756
+ }
757
+
758
+ _anySignal(signals) {
759
+ // Combine multiple AbortSignals into one.
760
+ const controller = new AbortController();
761
+ const onAbort = (sig) => {
762
+ if (controller.signal.aborted) return;
763
+ controller.abort(sig.reason || "Aborted");
764
+ };
765
+ for (const sig of signals) {
766
+ if (!sig) continue;
767
+ if (sig.aborted) {
768
+ onAbort(sig);
769
+ break;
770
+ }
771
+ sig.addEventListener("abort", () => onAbort(sig), { once: true });
772
+ }
773
+ return controller.signal;
774
+ }
775
+ }