@indigoai-us/hq-cli 5.108.20 → 5.108.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.108.21] — 2026-09-07
6
+
7
+ ### Fixed
8
+
9
+ - A large `hq files cat`/`hq files get`/`hq files browse`/`hq files search`
10
+ download no longer dies part-way through with an unexplained crash (Sentry
11
+ HQ-CLI-5 — 7596554539). Every company-mode vault read goes through a presigned
12
+ GET, and the CLI handed the caller the download's response body *before anyone
13
+ started reading it*. Node's bundled `undici` builds that body stream with a
14
+ zero high-water mark, so the first chunk pauses its HTTP parser until a
15
+ consumer pulls — and the download loop ran a synchronous `mkdirSync` before it
16
+ began pulling. When S3 closed the connection inside that gap, undici's
17
+ socket-end handler tripped an internal `assert(!this.paused)` and threw an
18
+ `AssertionError` from a background tick that no `try/catch` around the download
19
+ could see, so `@sentry/node` filed it as a fatal and the process exited
20
+ mid-download. The presigned GET is now drained into memory before its body is
21
+ handed on whenever the response declares a length at or below 32 MiB (which
22
+ covers every object the CLI reads today), removing the paused-parser window
23
+ entirely; larger or unmeasured responses keep streaming, and the destination
24
+ directory is now created *before* the download is issued so no synchronous
25
+ filesystem call sits inside the read window on that path either. The observable
26
+ output of every `hq files` subcommand is unchanged.
27
+
5
28
  ## [5.108.20] — 2026-09-07
6
29
 
7
30
  ### Fixed
@@ -403,6 +403,28 @@ export interface RunGetResult {
403
403
  * HQ root itself, which is too broad to do implicitly.
404
404
  */
405
405
  export declare function runGet(input: RunGetInput): Promise<RunGetResult>;
406
+ /**
407
+ * Upper bound (bytes) on a presigned download we drain into memory before
408
+ * handing the caller a Body (HQ-CLI-5).
409
+ *
410
+ * fetch()'s response body is an undici ReadableStream with a zero high-water
411
+ * mark: the first chunk drives desiredSize to 0 and undici PAUSES its llhttp
412
+ * parser until a consumer pulls. `getObject` used to hand that still-paused
413
+ * stream straight to the orchestrators, which each run a synchronous fs call
414
+ * before they start pulling. If S3 sends its connection FIN inside that gap,
415
+ * undici's socket-end handler trips `assert(!this.paused)` in Parser.finish and
416
+ * throws an AssertionError from a process tick — outside any try/catch — that
417
+ * @sentry/node files as an uncaught fatal and that kills the CLI mid-download.
418
+ *
419
+ * Draining the body up front removes the pause entirely (the same
420
+ * buffer-then-rewrap posture peekPlanLimitStatus uses in vault-api.ts). We cap
421
+ * it so a very large object still streams and never buffers into a small agent
422
+ * box's memory; a response above the ceiling (or with no declared length) keeps
423
+ * the streaming path, whose residual window is narrowed by the mkdirSync hoist
424
+ * in runGet/runCat. 32 MiB comfortably covers every object hq-cli reads today
425
+ * (vault JSON + images) while staying well under a 4 GB host's headroom.
426
+ */
427
+ export declare const PRESIGN_BUFFER_MAX_BYTES: number;
406
428
  /**
407
429
  * Build a COMPANY-mode browse client backed by the list + presign API. The
408
430
  * access token + companyUid are captured here; the orchestrator just calls
@@ -318,6 +318,15 @@ export async function runCat(input) {
318
318
  // COMPANY mode (HQ-59): GetObject → presign GET. No STS vend, no direct S3.
319
319
  s3 = requireCompanyClient(input.companyClient)({ companyUid: entity.uid });
320
320
  }
321
+ // HQ-CLI-5: when writing to --out, create the parent directory BEFORE issuing
322
+ // the presigned GET, so no synchronous filesystem call sits between receiving
323
+ // the body and the nextTick resume that starts pulling it — that gap is what
324
+ // left undici's HTTP parser paused into the socket FIN. The guard above
325
+ // already validated absOut is outside the protected companies/ tree, so its
326
+ // parent is outside too.
327
+ if (absOut !== undefined) {
328
+ fs.mkdirSync(path.dirname(absOut), { recursive: true });
329
+ }
321
330
  const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: s3Key })));
322
331
  if (!resp.Body) {
323
332
  throw new Error(`GetObject for '${key}' returned no body.`);
@@ -331,10 +340,6 @@ export async function runCat(input) {
331
340
  bytesWritten += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk);
332
341
  });
333
342
  if (absOut !== undefined) {
334
- // Ensure the parent directory exists — but ONLY if it's also outside
335
- // the protected tree (the guard already validated absOut itself; the
336
- // parent of an outside-tree path is by definition outside too).
337
- fs.mkdirSync(path.dirname(absOut), { recursive: true });
338
343
  await pipeline(body, fs.createWriteStream(absOut));
339
344
  return {
340
345
  bytesWritten,
@@ -536,12 +541,17 @@ export async function runGet(input) {
536
541
  else {
537
542
  destAbs = path.join(hqRoot, "companies", slug, key);
538
543
  }
544
+ // HQ-CLI-5: create the destination directory BEFORE issuing the presigned
545
+ // GET, so no synchronous filesystem call sits between receiving the body
546
+ // and starting to pull it — that gap is what left undici's HTTP parser
547
+ // paused into the socket FIN. Buffered downloads no longer keep a live
548
+ // socket at all; this also closes the window on the streaming branch.
549
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
539
550
  const resp = (await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key })));
540
551
  if (!resp.Body) {
541
552
  throw new Error(`GetObject for '${key}' returned no body.`);
542
553
  }
543
554
  const body = resp.Body;
544
- fs.mkdirSync(path.dirname(destAbs), { recursive: true });
545
555
  await pipeline(body, fs.createWriteStream(destAbs));
546
556
  bytesWritten += fs.statSync(destAbs).size;
547
557
  destinations.push(destAbs);
@@ -557,6 +567,28 @@ export async function runGet(input) {
557
567
  }
558
568
  // ── CLI registration ────────────────────────────────────────────────────────
559
569
  const defaultS3Factory = ({ region, credentials }) => new S3Client({ region, credentials });
570
+ /**
571
+ * Upper bound (bytes) on a presigned download we drain into memory before
572
+ * handing the caller a Body (HQ-CLI-5).
573
+ *
574
+ * fetch()'s response body is an undici ReadableStream with a zero high-water
575
+ * mark: the first chunk drives desiredSize to 0 and undici PAUSES its llhttp
576
+ * parser until a consumer pulls. `getObject` used to hand that still-paused
577
+ * stream straight to the orchestrators, which each run a synchronous fs call
578
+ * before they start pulling. If S3 sends its connection FIN inside that gap,
579
+ * undici's socket-end handler trips `assert(!this.paused)` in Parser.finish and
580
+ * throws an AssertionError from a process tick — outside any try/catch — that
581
+ * @sentry/node files as an uncaught fatal and that kills the CLI mid-download.
582
+ *
583
+ * Draining the body up front removes the pause entirely (the same
584
+ * buffer-then-rewrap posture peekPlanLimitStatus uses in vault-api.ts). We cap
585
+ * it so a very large object still streams and never buffers into a small agent
586
+ * box's memory; a response above the ceiling (or with no declared length) keeps
587
+ * the streaming path, whose residual window is narrowed by the mkdirSync hoist
588
+ * in runGet/runCat. 32 MiB comfortably covers every object hq-cli reads today
589
+ * (vault JSON + images) while staying well under a 4 GB host's headroom.
590
+ */
591
+ export const PRESIGN_BUFFER_MAX_BYTES = 32 * 1024 * 1024;
560
592
  /**
561
593
  * Build a COMPANY-mode browse client backed by the list + presign API. The
562
594
  * access token + companyUid are captured here; the orchestrator just calls
@@ -616,9 +648,31 @@ export function createCompanyPresignClient(input) {
616
648
  if (!dl.ok) {
617
649
  throw new Error(`Failed to download '${key}' (HTTP ${dl.status})`);
618
650
  }
619
- // fetch() yields a web ReadableStream; the orchestrators consume Body as a
620
- // Node Readable (body.on('data') + stream pipeline), so adapt it. An empty
621
- // body (no stream) becomes an empty Readable.
651
+ // HQ-CLI-5: when the response declares a length at or below the ceiling,
652
+ // DRAIN it here — read the whole body before send() resolves and replay it
653
+ // from memory. That removes undici's paused-parser window entirely: by the
654
+ // time the orchestrators pull, there is no live socket left to hit
655
+ // `assert(!this.paused)` on its FIN. `Number(null)` is 0, so guard the
656
+ // absent header explicitly (NaN) — an unmeasured body must keep streaming,
657
+ // never look like a zero-length buffer.
658
+ const declaredLengthHeader = dl.headers.get("content-length");
659
+ const declaredLength = declaredLengthHeader === null ? NaN : Number(declaredLengthHeader);
660
+ const canBuffer = Number.isFinite(declaredLength) &&
661
+ declaredLength >= 0 &&
662
+ declaredLength <= PRESIGN_BUFFER_MAX_BYTES;
663
+ if (canBuffer) {
664
+ const buf = Buffer.from(await dl.arrayBuffer());
665
+ return {
666
+ Body: Readable.from(buf),
667
+ $metadata: {},
668
+ };
669
+ }
670
+ // Above the ceiling, or no declared length: keep streaming so a very large
671
+ // object is never buffered whole. fetch() yields a web ReadableStream; the
672
+ // orchestrators consume Body as a Node Readable (body.on('data') + stream
673
+ // pipeline), so adapt it. An empty body (no stream) becomes an empty
674
+ // Readable. The mkdirSync hoist in runGet/runCat narrows the residual
675
+ // paused-parser window on this branch.
622
676
  const nodeBody = dl.body
623
677
  ? Readable.fromWeb(dl.body)
624
678
  : Readable.from([]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.108.20",
3
+ "version": "5.108.21",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {