@indigoai-us/hq-cli 5.108.5 → 5.108.7

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.
@@ -23,7 +23,7 @@ import * as os from "os";
23
23
  import * as path from "path";
24
24
  import * as yaml from "js-yaml";
25
25
  import chalk from "chalk";
26
- import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, } from "@indigoai-us/hq-cloud";
26
+ import { loadCachedTokens, isExpiring, refreshTokens, browserLogin, detectHqCoreVersion, isMachineIdentity, getValidMachineTokens, CognitoRefreshError, accessTokenFingerprint, invalidateCachedTokensByFingerprint, } from "@indigoai-us/hq-cloud";
27
27
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
28
28
  import { daemonDir } from "../lib/mesh/live/daemon/paths.js";
29
29
  import { workMeshRoot } from "../lib/mesh/live/paths.js";
@@ -248,40 +248,36 @@ export function machineTokenCacheFile(home = os.homedir(), env = process.env) {
248
248
  }
249
249
  /**
250
250
  * True when `HQ_MACHINE_CREDS_FILE` is explicitly set (non-empty) in `env`.
251
- * The mere presence of `~/.hq-agent/machine-creds.json` is NOT enough minting
252
- * and doctor "machine" reporting are opt-in via this env (systemd unit) or an
253
- * explicit `{ tokenSource: "machine" }` caller option.
251
+ * Explicit env remains the primary opt-in for daemon units. Fresh agent boxes
252
+ * without the env still mint via {@link wantsMachineCognitoTokens}'s
253
+ * default-file fallback when no usable person session exists.
254
254
  */
255
255
  export function isMachineCredsFileEnvSet(env = process.env) {
256
256
  const raw = env.HQ_MACHINE_CREDS_FILE;
257
257
  return typeof raw === "string" && raw.trim().length > 0;
258
258
  }
259
+ /** Default machine-creds path hq-cloud also checks when the env is unset. */
260
+ export function defaultMachineCredsFile(home = os.homedir()) {
261
+ return path.join(home, ".hq-agent", "machine-creds.json");
262
+ }
259
263
  /**
260
- * Whether this call should mint/cache via machine creds (USER_PASSWORD_AUTH).
261
- * Opt-in only: explicit `{ tokenSource: "machine" }`, or `HQ_MACHINE_CREDS_FILE`
262
- * set in the environment. Default-path `~/.hq-agent/machine-creds.json` alone
263
- * never flips other CLI commands onto the machine path.
264
+ * Align `process.env.HQ_MACHINE_CREDS_FILE` with a (possibly synthetic) `env`
265
+ * for the duration of `fn`, so hq-cloud's `isMachineIdentity()` sees the same
266
+ * value doctor / tests intended.
264
267
  */
265
- export function wantsMachineCognitoTokens(options = {}, env = process.env) {
266
- if (options.tokenSource === "person")
267
- return false;
268
- if (options.tokenSource === "machine") {
269
- return isMachineIdentity();
270
- }
271
- if (!isMachineCredsFileEnvSet(env))
272
- return false;
273
- // Align process.env for hq-cloud's isMachineIdentity() when a synthetic env
274
- // is supplied (doctor / tests).
268
+ function withAlignedMachineCredsEnv(env, fn) {
275
269
  const prevCreds = process.env.HQ_MACHINE_CREDS_FILE;
276
270
  const nextCreds = env.HQ_MACHINE_CREDS_FILE;
277
271
  if (nextCreds !== prevCreds) {
278
- if (nextCreds === undefined)
272
+ if (nextCreds === undefined || String(nextCreds).trim() === "") {
279
273
  delete process.env.HQ_MACHINE_CREDS_FILE;
280
- else
274
+ }
275
+ else {
281
276
  process.env.HQ_MACHINE_CREDS_FILE = nextCreds;
277
+ }
282
278
  }
283
279
  try {
284
- return isMachineIdentity();
280
+ return fn();
285
281
  }
286
282
  finally {
287
283
  if (nextCreds !== prevCreds) {
@@ -293,9 +289,104 @@ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
293
289
  }
294
290
  }
295
291
  /**
296
- * Which Cognito token source this process will use for doctor / reporting:
297
- * machine only when `HQ_MACHINE_CREDS_FILE` is explicitly set and readable;
298
- * otherwise the person login cache. Default-path machine-creds alone person.
292
+ * On-disk person Cognito cache path for `env` (`HQ_STATE_DIR` or `~/.hq`).
293
+ * Deliberately does not use hq-cloud's `loadCachedTokens()`: after a machine
294
+ * mint in this process, that helper returns in-memory machine tokens when the
295
+ * person file is absent, which would falsely block the fresh-box fallback.
296
+ */
297
+ export function personTokenCacheFile(home = os.homedir(), env = process.env) {
298
+ const override = env.HQ_STATE_DIR?.trim();
299
+ const stateDir = override && override.length > 0 ? override : path.join(home, ".hq");
300
+ return path.join(stateDir, "cognito-tokens.json");
301
+ }
302
+ /**
303
+ * True when a usable person Cognito cache file exists under the effective
304
+ * `HQ_STATE_DIR` (or `~/.hq`). Presence alone keeps human commands on the
305
+ * person path even when the default machine-creds file is also on disk
306
+ * (no-flip guarantee).
307
+ */
308
+ export function hasPersonCachedSession(env = process.env) {
309
+ try {
310
+ const file = personTokenCacheFile(os.homedir(), env);
311
+ if (!fs.existsSync(file))
312
+ return false;
313
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
314
+ if (typeof raw.accessToken !== "string" ||
315
+ raw.accessToken.length === 0 ||
316
+ typeof raw.idToken !== "string" ||
317
+ raw.idToken.length === 0) {
318
+ return false;
319
+ }
320
+ // Definitive refresh rejection writes `${file}.invalid.<fingerprint>`;
321
+ // treat that as no usable person session so status/whoami report machine.
322
+ const marker = `${file}.invalid.${accessTokenFingerprint(raw.accessToken)}`;
323
+ if (fs.existsSync(marker))
324
+ return false;
325
+ return true;
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ }
331
+ /**
332
+ * Whether hq-cloud currently sees readable machine creds (env override or
333
+ * default `~/.hq-agent/machine-creds.json`), honoring a synthetic `env`.
334
+ */
335
+ function probeMachineIdentity(env) {
336
+ return withAlignedMachineCredsEnv(env, () => isMachineIdentity());
337
+ }
338
+ /**
339
+ * Fresh-box / Outpost bootstrap: env unset, not forced to person, no person
340
+ * cache, and the default machine-creds file is readable. Distinct from
341
+ * explicit `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }` opt-in.
342
+ */
343
+ export function isDefaultMachineCredsFallback(options = {}, env = process.env) {
344
+ if (options.tokenSource === "person")
345
+ return false;
346
+ if (options.tokenSource === "machine")
347
+ return false;
348
+ if (isMachineCredsFileEnvSet(env))
349
+ return false;
350
+ if (hasPersonCachedSession(env))
351
+ return false;
352
+ return probeMachineIdentity(env);
353
+ }
354
+ function logDefaultMachineCredsFallback() {
355
+ console.error(chalk.dim("machine identity from default creds file"));
356
+ }
357
+ /**
358
+ * Whether this call should mint/cache via machine creds (USER_PASSWORD_AUTH).
359
+ *
360
+ * Machine path when:
361
+ * - explicit `{ tokenSource: "machine" }` and creds are readable, or
362
+ * - `HQ_MACHINE_CREDS_FILE` is set and readable, or
363
+ * - fresh-box fallback: env unset, no person cache, default
364
+ * `~/.hq-agent/machine-creds.json` readable.
365
+ *
366
+ * Explicit `{ tokenSource: "person" }` never uses machine creds. A present
367
+ * person cache keeps the person path even when the default creds file exists
368
+ * (do not flip a human's commands onto machine tokens).
369
+ */
370
+ export function wantsMachineCognitoTokens(options = {}, env = process.env) {
371
+ if (options.tokenSource === "person")
372
+ return false;
373
+ if (options.tokenSource === "machine") {
374
+ return probeMachineIdentity(env);
375
+ }
376
+ if (isMachineCredsFileEnvSet(env)) {
377
+ return probeMachineIdentity(env);
378
+ }
379
+ // Fresh-box fallback — default creds file only when no person session.
380
+ if (hasPersonCachedSession(env))
381
+ return false;
382
+ return probeMachineIdentity(env);
383
+ }
384
+ /**
385
+ * Which Cognito token source this process will use for doctor / status / whoami.
386
+ * Matches {@link wantsMachineCognitoTokens}: env opt-in, or default-file
387
+ * fallback when no usable person session is cached (absent, or marked rejected
388
+ * after a definitive Cognito refresh refusal). Transient refresh failures do
389
+ * not flip this to machine.
299
390
  */
300
391
  export function resolveCognitoTokenSource(env = process.env) {
301
392
  return wantsMachineCognitoTokens({}, env) ? "machine" : "person";
@@ -400,16 +491,13 @@ export function describeCognitoTokenSource(opts = {}) {
400
491
  * Run `fn` with `HQ_STATE_DIR` pointed at the machine/daemon token cache so
401
492
  * hq-cloud's mint writes never touch the person login file.
402
493
  *
403
- * When `HQ_STATE_DIR` is already set (tests / explicit callers), leave it alone
404
- * so a pre-seeded machine session is still found — only redirect when unset.
494
+ * Always redirects even when the caller already set `HQ_STATE_DIR` (person
495
+ * cache / tests). Override the machine cache location with
496
+ * `HQ_MACHINE_TOKEN_STATE_DIR` instead. Restores the prior env afterwards.
405
497
  */
406
498
  export async function withMachineTokenStateDir(fn, opts = {}) {
407
499
  const home = opts.home ?? os.homedir();
408
500
  const env = opts.env ?? process.env;
409
- const existing = env.HQ_STATE_DIR?.trim() || process.env.HQ_STATE_DIR?.trim() || "";
410
- if (existing) {
411
- return fn();
412
- }
413
501
  const prev = process.env.HQ_STATE_DIR;
414
502
  process.env.HQ_STATE_DIR = machineTokenStateDir(home, env);
415
503
  try {
@@ -425,12 +513,54 @@ export async function withMachineTokenStateDir(fn, opts = {}) {
425
513
  async function ensureMachineTokens() {
426
514
  return withMachineTokenStateDir(() => getValidMachineTokens(DEFAULT_COGNITO));
427
515
  }
516
+ /**
517
+ * Mint machine tokens (daemon state dir cache). Logs once when the fresh-box
518
+ * default-creds fallback is what selected the machine path.
519
+ */
520
+ async function ensureMachineTokensMaybeFallback(options = {}) {
521
+ if (isDefaultMachineCredsFallback(options)) {
522
+ logDefaultMachineCredsFallback();
523
+ }
524
+ return ensureMachineTokens();
525
+ }
526
+ /**
527
+ * After a person-cache miss or refresh failure: if the default machine-creds
528
+ * file is readable and the caller did not force person mode, mint from it.
529
+ * Returns null when the fallback does not apply.
530
+ */
531
+ async function tryDefaultMachineCredsFallback(options = {}) {
532
+ if (options.tokenSource === "person")
533
+ return null;
534
+ if (isMachineCredsFileEnvSet())
535
+ return null;
536
+ if (!isMachineIdentity())
537
+ return null;
538
+ logDefaultMachineCredsFallback();
539
+ return ensureMachineTokens();
540
+ }
541
+ /**
542
+ * True when a person-token refresh failed with a definitive Cognito auth
543
+ * rejection (refresh token invalid/revoked). Transient network/5xx/timeouts
544
+ * must NOT trigger machine-identity fallback.
545
+ */
546
+ export function isDefinitivePersonAuthRejection(err) {
547
+ if (err instanceof CognitoRefreshError) {
548
+ return err.requiresReauth === true;
549
+ }
550
+ const msg = err instanceof Error ? err.message : String(err);
551
+ return /NotAuthorizedException|invalid_grant|invalid refresh token/i.test(msg);
552
+ }
553
+ function markPersonSessionRejected(accessToken) {
554
+ invalidateCachedTokensByFingerprint(accessTokenFingerprint(accessToken));
555
+ }
428
556
  /**
429
557
  * Return a non-expired Cognito access token, refreshing or browser-logging-in
430
558
  * as needed. Person cache lives at ~/.hq/cognito-tokens.json. Machine minting
431
- * is opt-in: `HQ_MACHINE_CREDS_FILE` explicitly set, or
432
- * `{ tokenSource: "machine" }` then USER_PASSWORD_AUTH and (when
433
- * `HQ_STATE_DIR` is unset) a cache under the daemon state dir.
559
+ * uses `HQ_MACHINE_CREDS_FILE` / `{ tokenSource: "machine" }`, or the fresh-box
560
+ * fallback when the default creds file is readable and no person session
561
+ * exists (or person refresh fails with a definitive auth rejection).
562
+ * Machine tokens always cache under the dedicated machine/daemon state dir
563
+ * (never the caller's `HQ_STATE_DIR` person cache).
434
564
  *
435
565
  * Pass `interactive: false` from automated contexts (e.g. the `hq-auth-refresh`
436
566
  * bin invoked by the deploy skill) where failing fast is better than opening
@@ -445,10 +575,10 @@ export async function ensureCognitoToken(options = {}) {
445
575
  // so vault-API calls from a machine identity send the ID token. The cached
446
576
  // token file keeps correct field semantics (real access token in
447
577
  // accessToken) for consumers that need token_use=access, e.g. the deploy
448
- // API via the deploy skill. Cache path: daemon state dir when HQ_STATE_DIR
449
- // is unset, never a silent overwrite of a person's ~/.hq.
578
+ // API via the deploy skill. Cache path: dedicated machine/daemon state dir
579
+ // (isolated even when HQ_STATE_DIR points at a person cache).
450
580
  if (wantsMachineCognitoTokens(options)) {
451
- const machine = await ensureMachineTokens();
581
+ const machine = await ensureMachineTokensMaybeFallback(options);
452
582
  return machine.idToken;
453
583
  }
454
584
  const cached = loadCachedTokens();
@@ -464,11 +594,24 @@ export async function ensureCognitoToken(options = {}) {
464
594
  return refreshed.accessToken;
465
595
  }
466
596
  catch (err) {
597
+ // Transient/network/server errors stay person errors — never mint machine.
598
+ if (!isDefinitivePersonAuthRejection(err)) {
599
+ throw err;
600
+ }
601
+ markPersonSessionRejected(cached.accessToken);
602
+ const fallback = await tryDefaultMachineCredsFallback(options);
603
+ if (fallback)
604
+ return fallback.idToken;
467
605
  if (interactive) {
468
606
  console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
469
607
  }
470
608
  }
471
609
  }
610
+ else {
611
+ const fallback = await tryDefaultMachineCredsFallback(options);
612
+ if (fallback)
613
+ return fallback.idToken;
614
+ }
472
615
  if (!interactive) {
473
616
  throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
474
617
  }
@@ -490,7 +633,7 @@ export async function ensureCognitoToken(options = {}) {
490
633
  export async function ensureCognitoIdToken(options = {}) {
491
634
  const interactive = options.interactive ?? true;
492
635
  if (wantsMachineCognitoTokens(options)) {
493
- const machine = await ensureMachineTokens();
636
+ const machine = await ensureMachineTokensMaybeFallback(options);
494
637
  return machine.idToken;
495
638
  }
496
639
  const cached = loadCachedTokens();
@@ -506,11 +649,23 @@ export async function ensureCognitoIdToken(options = {}) {
506
649
  return refreshed.idToken;
507
650
  }
508
651
  catch (err) {
652
+ if (!isDefinitivePersonAuthRejection(err)) {
653
+ throw err;
654
+ }
655
+ markPersonSessionRejected(cached.accessToken);
656
+ const fallback = await tryDefaultMachineCredsFallback(options);
657
+ if (fallback)
658
+ return fallback.idToken;
509
659
  if (interactive) {
510
660
  console.error(chalk.dim(` Refresh failed (${err instanceof Error ? err.message : err}), falling back to browser login`));
511
661
  }
512
662
  }
513
663
  }
664
+ else {
665
+ const fallback = await tryDefaultMachineCredsFallback(options);
666
+ if (fallback)
667
+ return fallback.idToken;
668
+ }
514
669
  if (!interactive) {
515
670
  throw new Error("No valid HQ session and interactive login is disabled. Run `hq login` first.");
516
671
  }
@@ -551,10 +706,11 @@ export function buildVaultConfig(authToken) {
551
706
  export async function refreshCachedSession(options = {}) {
552
707
  // Machine identities have no refresh token; ensure a valid cached machine
553
708
  // session without forcing a re-mint when the cache is already healthy.
554
- // Tokens land under the daemon state dir when HQ_STATE_DIR is unset.
709
+ // Tokens land under the dedicated machine/daemon state dir.
710
+ // Includes the fresh-box default-creds fallback when no person session.
555
711
  if (wantsMachineCognitoTokens(options)) {
556
712
  try {
557
- await ensureMachineTokens();
713
+ await ensureMachineTokensMaybeFallback(options);
558
714
  return { refreshed: true };
559
715
  }
560
716
  catch (err) {
@@ -566,6 +722,20 @@ export async function refreshCachedSession(options = {}) {
566
722
  }
567
723
  const cached = loadCachedTokens();
568
724
  if (!cached) {
725
+ // Person path had nothing; last chance is default machine creds (e.g. the
726
+ // cache was cleared between the wantsMachine check and now, or identity
727
+ // became readable). Prefer machine mint over "no cached session" on boxes.
728
+ try {
729
+ const fallback = await tryDefaultMachineCredsFallback(options);
730
+ if (fallback)
731
+ return { refreshed: true };
732
+ }
733
+ catch (err) {
734
+ return {
735
+ refreshed: false,
736
+ reason: err instanceof Error ? err.message : String(err),
737
+ };
738
+ }
569
739
  return { refreshed: false, reason: "no cached session" };
570
740
  }
571
741
  if (!isExpiring(cached, 120)) {
@@ -576,6 +746,26 @@ export async function refreshCachedSession(options = {}) {
576
746
  return { refreshed: true };
577
747
  }
578
748
  catch (err) {
749
+ // Transient/network/server refresh errors stay person failures.
750
+ if (!isDefinitivePersonAuthRejection(err)) {
751
+ return {
752
+ refreshed: false,
753
+ reason: err instanceof Error ? err.message : String(err),
754
+ };
755
+ }
756
+ // Definitive rejection — person session is dead; mark it and fall back.
757
+ markPersonSessionRejected(cached.accessToken);
758
+ try {
759
+ const fallback = await tryDefaultMachineCredsFallback(options);
760
+ if (fallback)
761
+ return { refreshed: true };
762
+ }
763
+ catch (mintErr) {
764
+ return {
765
+ refreshed: false,
766
+ reason: mintErr instanceof Error ? mintErr.message : String(mintErr),
767
+ };
768
+ }
579
769
  return {
580
770
  refreshed: false,
581
771
  reason: err instanceof Error ? err.message : String(err),
@@ -1,4 +1,5 @@
1
1
  import { type VersionInfo } from "./feedback-versions.js";
2
+ import type { FeedbackLogsBlob } from "./feedback-logs.js";
2
3
  export interface GitContext {
3
4
  branch: string | null;
4
5
  head: string | null;
@@ -23,6 +24,16 @@ export interface DiagnosticsBlob {
23
24
  cwd: string;
24
25
  git: GitContext;
25
26
  recentSentryBreadcrumbs: unknown[];
27
+ /**
28
+ * Redacted tails of the submitter's `~/.hq` log files.
29
+ *
30
+ * Attached by `submitFeedback`, NOT by `collectDiagnostics`, because the
31
+ * size budget can only be computed once the rest of the request body is
32
+ * known (the server caps the whole body at 64 KiB). Absent when the user
33
+ * opted out (`--no-logs` / `HQ_FEEDBACK_LOGS=0`), when no eligible log file
34
+ * exists, or when the body left no headroom.
35
+ */
36
+ logs?: FeedbackLogsBlob;
26
37
  }
27
38
  export declare function sanitizeArgv(argv: string[]): string[];
28
39
  export declare function collectDiagnostics(): DiagnosticsBlob;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Full-history log bundle for `hq feedback` — the out-of-band companion to the
3
+ * inline `diagnostics.logs` blob in `feedback-logs.ts`.
4
+ *
5
+ * Inline logs are capped twice over: the feedback endpoint rejects a request
6
+ * body above 64 KiB, and `diagnostics` is written into a DynamoDB item, which
7
+ * caps at 400 KB. Neither can be tuned into carrying a real log history, so the
8
+ * inline blob is deliberately a ~40 KiB summary of 16 KiB tails. This module
9
+ * produces the other half: a gzipped bundle uploaded direct to S3, carrying the
10
+ * files whole rather than in tail-sized slivers.
11
+ *
12
+ * Three properties are load-bearing and none may be traded away:
13
+ *
14
+ * 1. SAME ALLOWLIST. Discovery reuses `feedback-logs.ts` verbatim —
15
+ * `discoverLogFiles`, `discoverStateFiles`, `discoverFlatDirFiles`,
16
+ * `walkJsonFiles`. The bundle carries MORE OF the same files, never more
17
+ * files. Credential material lives outside `~/.hq` (`~/.codex/auth.json`,
18
+ * `~/.hq-agent/machine-creds.json`), so widening discovery here — not
19
+ * raising the size — is what would turn this into an exfiltration bug.
20
+ *
21
+ * 2. SAME REDACTION. Every line goes through `redactLogText` before it is
22
+ * compressed. Shipping a raw archive would be far simpler and would
23
+ * silently undo the entire security model of the inline path.
24
+ *
25
+ * 3. BOUNDED MEMORY. 50 MB compressed is roughly a gigabyte of raw log text
26
+ * at the ratio these files compress at. Nothing is ever fully materialised:
27
+ * files are read in slices, redacted a line at a time, and streamed into
28
+ * gzip, with only the compressed output retained.
29
+ *
30
+ * Output format — gzipped NDJSON, one JSON record per line:
31
+ * {"kind":"manifest","version":1,...} exactly one, first
32
+ * {"kind":"file","name":"logs/hq-sync.log",...} one per file
33
+ * {"kind":"chunk","name":"logs/hq-sync.log","seq":0,...} many per file
34
+ * {"kind":"summary","fileCount":12,...} exactly one, last
35
+ *
36
+ * NDJSON rather than tar so there is no archive dependency, so a truncated
37
+ * bundle is still parseable line-by-line up to the cut, and so the records
38
+ * carry the same redaction metadata the inline blob already reports.
39
+ */
40
+ import { type LogCandidate } from "./feedback-logs.js";
41
+ import { vaultApiFetch } from "./vault-api.js";
42
+ /**
43
+ * Compressed ceiling. Mirrors `MAX_LOG_BUNDLE_BYTES` in the hq-pro handler
44
+ * `feedback-log-bundles.ts`, which refuses to presign above it — the two must
45
+ * stay in step or the CLI will build bundles the server will not accept.
46
+ */
47
+ export declare const LOG_BUNDLE_MAX_BYTES: number;
48
+ /**
49
+ * Headroom between the size we stop feeding at and the hard cap.
50
+ *
51
+ * gzip reports compressed bytes only as its internal buffer flushes, so the
52
+ * running total lags the bytes actually consumed. The lag is bounded by that
53
+ * buffer (tens of KiB); a 1 MiB margin covers it with three orders of magnitude
54
+ * to spare, and the final size is asserted against the real cap regardless.
55
+ */
56
+ export declare const LOG_BUNDLE_SAFETY_MARGIN_BYTES: number;
57
+ /**
58
+ * Per-file raw ceiling. The desktop logger rotates at 32 MiB (hq-desktop-core
59
+ * `logfile.rs`), so this admits a full generation with headroom while stopping
60
+ * one pathological file from consuming the whole bundle. Files above it are
61
+ * read from the TAIL — the end of a log is what explains a failure.
62
+ */
63
+ export declare const LOG_BUNDLE_MAX_FILE_RAW_BYTES: number;
64
+ export interface LogBundleResult {
65
+ /** The gzipped NDJSON bytes, ready to PUT. */
66
+ gzip: Buffer;
67
+ /** `gzip.byteLength` — what the presign request must declare. */
68
+ sizeBytes: number;
69
+ /** How many files contributed at least one chunk. */
70
+ fileCount: number;
71
+ /** True when the size cap stopped collection before every file was read. */
72
+ truncated: boolean;
73
+ /** Raw (pre-compression, post-redaction) bytes read into the bundle. */
74
+ rawBytes: number;
75
+ /** Total redacted spans across every file. Non-zero is expected and fine. */
76
+ redactions: number;
77
+ }
78
+ /**
79
+ * What a submission records about its uploaded bundle. Must stay in step with
80
+ * `LogBundleRef` in the hq-pro handler `feedback-log-bundles.ts`, which
81
+ * re-validates every field before persisting it.
82
+ */
83
+ export interface LogBundleRef {
84
+ key: string;
85
+ sizeBytes: number;
86
+ fileCount: number;
87
+ truncated: boolean;
88
+ }
89
+ export interface BuildLogBundleOptions {
90
+ /** Compressed ceiling. Clamped to {@link LOG_BUNDLE_MAX_BYTES}. */
91
+ maxBytes?: number;
92
+ /** Override `~/.hq` (tests). */
93
+ hqDir?: string;
94
+ /** Override the home directory used to derive `~/.hq` (tests). */
95
+ homeDir?: string;
96
+ /** Override the per-file raw ceiling (tests). */
97
+ maxFileRawBytes?: number;
98
+ }
99
+ /**
100
+ * Order candidates so that, when the cap truncates collection, what survives is
101
+ * what a triager reads first.
102
+ *
103
+ * State documents lead: they are tiny and answer questions a log cannot (which
104
+ * operation is claimed, what the sync cursor is). Log files follow, newest
105
+ * first. Reversing this would let one large old log crowd out every status
106
+ * file — the same class of ordering defect that let ordinary lock entries
107
+ * crowd out a stale claim in the inline collector.
108
+ */
109
+ export declare function orderBundleCandidates(hqDir: string): LogCandidate[];
110
+ /**
111
+ * Read `absPath` in slices, redacting whole lines, invoking `onChunk` with
112
+ * roughly {@link CHUNK_TEXT_BYTES} of redacted text at a time.
113
+ *
114
+ * Reads from the tail when the file exceeds `maxRawBytes`, and drops the first
115
+ * partial line after seeking so a chunk never begins mid-record. `onChunk`
116
+ * returns false to stop early (the cap was hit).
117
+ */
118
+ export declare function streamRedactedFile(absPath: string, sizeBytes: number, maxRawBytes: number, chunkBytes: number, onChunk: (text: string, redactions: number) => Promise<boolean>): Promise<{
119
+ fromTail: boolean;
120
+ stopped: boolean;
121
+ }>;
122
+ /**
123
+ * Build a gzipped NDJSON bundle of the submitter's redacted `~/.hq` logs.
124
+ *
125
+ * Returns `undefined` when nothing eligible exists, so the caller can skip the
126
+ * upload entirely. Never throws: a bug report must not fail over its own
127
+ * diagnostics, so any unexpected error yields `undefined` and the submission
128
+ * proceeds with inline logs alone.
129
+ */
130
+ export declare function buildLogBundle(opts?: BuildLogBundleOptions): Promise<LogBundleResult | undefined>;
131
+ /**
132
+ * Build, presign, and upload a log bundle; return the reference the submission
133
+ * should carry, or `undefined` if anything at all did not work out.
134
+ *
135
+ * Every failure path is silent and non-fatal by design. The bundle is an
136
+ * enrichment on top of the inline logs that already ship in the request body,
137
+ * so a missing endpoint, a disabled bucket, a refused presign, or a failed PUT
138
+ * must all degrade to "submit without it" rather than cost the user their bug
139
+ * report. In particular a 404 is expected and unremarkable while a CLI that
140
+ * knows about bundles is running against a server that does not yet.
141
+ */
142
+ export declare function uploadLogBundle(opts: {
143
+ token: string;
144
+ enabled: boolean;
145
+ fetchImpl?: typeof fetch;
146
+ build?: typeof buildLogBundle;
147
+ apiFetch?: typeof vaultApiFetch;
148
+ }): Promise<LogBundleRef | undefined>;
149
+ //# sourceMappingURL=feedback-log-bundle.d.ts.map