@indigoai-us/hq-cli 5.77.8 → 5.77.10

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.
@@ -1,3 +1,5 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
1
3
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
4
 
3
5
  vi.mock('../sentry.js', () => ({
@@ -9,6 +11,17 @@ import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } fr
9
11
  import { isAuthError } from './auth-error.js';
10
12
  import { isCompanySelectionError } from './company-selection-error.js';
11
13
  import { isExpectedUserError } from './expected-cli-error.js';
14
+ import {
15
+ _resetForTests,
16
+ emitPlanLimitNag,
17
+ } from '../lib/plan-limit-nag.js';
18
+
19
+ function tmpNagStatePath(label: string): string {
20
+ return path.join(
21
+ os.tmpdir(),
22
+ `hq-plan-limit-vault-api-${label}-${process.pid}-${Date.now()}.json`,
23
+ );
24
+ }
12
25
 
13
26
  const fetchMock = vi.fn();
14
27
  const originalFetch = globalThis.fetch;
@@ -23,10 +36,12 @@ function mockResponse(status: number, body: unknown): Response {
23
36
  beforeEach(() => {
24
37
  fetchMock.mockReset();
25
38
  globalThis.fetch = fetchMock as unknown as typeof fetch;
39
+ _resetForTests();
26
40
  });
27
41
 
28
42
  afterEach(() => {
29
43
  globalThis.fetch = originalFetch;
44
+ _resetForTests();
30
45
  });
31
46
 
32
47
  describe('resolveCallerPersonUid', () => {
@@ -395,3 +410,127 @@ describe('vaultApiFetch abort signals', () => {
395
410
  );
396
411
  });
397
412
  });
413
+
414
+ describe('vaultApiFetch plan-limit peek (US-016, no-clone body drain)', () => {
415
+ it('JSON ok response is still fully readable by the caller after peek', async () => {
416
+ const payload = { ok: true, items: [1, 2, 3], planLimits: undefined };
417
+ fetchMock.mockResolvedValueOnce(mockResponse(200, payload));
418
+
419
+ const res = await vaultApiFetch({ token: 'tok', path: '/v1/items' });
420
+ expect(res.ok).toBe(true);
421
+ expect(res.status).toBe(200);
422
+ const body = await res.json();
423
+ expect(body).toEqual(payload);
424
+ });
425
+
426
+ it('records planLimits payload (observable via emitPlanLimitNag)', async () => {
427
+ fetchMock.mockResolvedValueOnce(
428
+ mockResponse(200, {
429
+ ok: true,
430
+ planLimits: {
431
+ users: { used: 9, limit: 10, over: false },
432
+ },
433
+ }),
434
+ );
435
+
436
+ const res = await vaultApiFetch({ token: 'tok', path: '/v1/items' });
437
+ // Caller can still read the body.
438
+ await expect(res.json()).resolves.toMatchObject({ ok: true });
439
+
440
+ const lines: string[] = [];
441
+ emitPlanLimitNag({
442
+ write: (s) => {
443
+ lines.push(s);
444
+ },
445
+ statePath: tmpNagStatePath('recorded'),
446
+ });
447
+ expect(lines).toHaveLength(1);
448
+ expect(lines[0]).toMatch(/HQ free plan/i);
449
+ expect(lines[0]).toMatch(/users at 9\/10/);
450
+ });
451
+
452
+ it('non-JSON content-type body is not buffered (response returned untouched)', async () => {
453
+ const original = new Response(new Uint8Array([1, 2, 3, 4]), {
454
+ status: 200,
455
+ statusText: 'OK',
456
+ headers: { 'Content-Type': 'application/octet-stream' },
457
+ });
458
+ fetchMock.mockResolvedValueOnce(original);
459
+
460
+ const res = await vaultApiFetch({ token: 'tok', path: '/v1/download' });
461
+ // Same object identity — peek must not re-wrap non-JSON bodies.
462
+ expect(res).toBe(original);
463
+ const buf = new Uint8Array(await res.arrayBuffer());
464
+ expect(Array.from(buf)).toEqual([1, 2, 3, 4]);
465
+ });
466
+
467
+ it('malformed JSON does not throw and body is still readable', async () => {
468
+ const raw = '{not-valid-json';
469
+ fetchMock.mockResolvedValueOnce(
470
+ new Response(raw, {
471
+ status: 200,
472
+ headers: { 'Content-Type': 'application/json' },
473
+ }),
474
+ );
475
+
476
+ let res: Response;
477
+ await expect(
478
+ (async () => {
479
+ res = await vaultApiFetch({ token: 'tok', path: '/v1/broken' });
480
+ return res;
481
+ })(),
482
+ ).resolves.toBeDefined();
483
+
484
+ expect(res!.ok).toBe(true);
485
+ await expect(res!.text()).resolves.toBe(raw);
486
+
487
+ // Malformed body must not record plan limits.
488
+ const lines: string[] = [];
489
+ emitPlanLimitNag({
490
+ write: (s) => {
491
+ lines.push(s);
492
+ },
493
+ statePath: tmpNagStatePath('malformed'),
494
+ });
495
+ expect(lines).toHaveLength(0);
496
+ });
497
+
498
+ it('non-ok responses are returned untouched', async () => {
499
+ const original = mockResponse(404, { error: 'not found' });
500
+ fetchMock.mockResolvedValueOnce(original);
501
+
502
+ const res = await vaultApiFetch({ token: 'tok', path: '/v1/missing' });
503
+ expect(res).toBe(original);
504
+ expect(res.status).toBe(404);
505
+ await expect(res.json()).resolves.toEqual({ error: 'not found' });
506
+ });
507
+
508
+ it('accepts application/*+json content-types for peek', async () => {
509
+ fetchMock.mockResolvedValueOnce(
510
+ new Response(
511
+ JSON.stringify({
512
+ planLimits: { seats: { used: 8, limit: 10, over: false } },
513
+ }),
514
+ {
515
+ status: 200,
516
+ headers: { 'Content-Type': 'application/vnd.api+json' },
517
+ },
518
+ ),
519
+ );
520
+
521
+ const res = await vaultApiFetch({ token: 'tok', path: '/v1/vnd' });
522
+ await expect(res.json()).resolves.toMatchObject({
523
+ planLimits: { seats: { used: 8, limit: 10, over: false } },
524
+ });
525
+
526
+ const lines: string[] = [];
527
+ emitPlanLimitNag({
528
+ write: (s) => {
529
+ lines.push(s);
530
+ },
531
+ statePath: tmpNagStatePath('vnd'),
532
+ });
533
+ expect(lines).toHaveLength(1);
534
+ expect(lines[0]).toMatch(/seats at 8\/10/);
535
+ });
536
+ });
@@ -2,6 +2,7 @@ import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
2
2
  import { Sentry } from '../sentry.js';
3
3
  import { AuthError } from './auth-error.js';
4
4
  import { CompanySelectionError } from './company-selection-error.js';
5
+ import { recordPlanLimitStatus } from '../lib/plan-limit-nag.js';
5
6
 
6
7
  export interface VaultApiOptions {
7
8
  token: string;
@@ -12,6 +13,64 @@ export interface VaultApiOptions {
12
13
  signal?: AbortSignal;
13
14
  }
14
15
 
16
+ /**
17
+ * Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
18
+ *
19
+ * For ok JSON responses: read the body ONCE, record planLimits (best-effort),
20
+ * and return a NEW Response built from the buffered body so callers can still
21
+ * call .json()/.text()/.arrayBuffer(). Non-JSON and non-ok responses are
22
+ * returned untouched (streamed binary downloads must never be buffered).
23
+ *
24
+ * Never throws. On any error: return the original response if its body has
25
+ * not been consumed, otherwise the re-wrapped one.
26
+ *
27
+ * Why not response.clone()? Cloning tees the undici body stream; when a
28
+ * caller never consumes the original Response body (many commands only check
29
+ * response.ok/status), the unused tee branch keeps the connection/handle
30
+ * referenced and the process (or vitest worker / spawned CLI child) never
31
+ * exits on Linux CI. Reading once + re-wrapping fully drains the stream.
32
+ */
33
+ async function peekPlanLimitStatus(response: Response): Promise<Response> {
34
+ try {
35
+ if (!response.ok) return response;
36
+ const ct = response.headers.get('content-type') ?? '';
37
+ if (!ct.includes('application/json') && !ct.includes('+json')) {
38
+ return response;
39
+ }
40
+
41
+ let buf: ArrayBuffer;
42
+ try {
43
+ buf = await response.arrayBuffer();
44
+ } catch {
45
+ // Body may be locked/errored; original is the only thing we can return.
46
+ return response;
47
+ }
48
+
49
+ try {
50
+ const text = new TextDecoder().decode(buf);
51
+ try {
52
+ recordPlanLimitStatus(JSON.parse(text) as unknown);
53
+ } catch {
54
+ // Malformed JSON must be ignored silently.
55
+ }
56
+ } catch {
57
+ // Best-effort record only — still re-wrap so body is readable.
58
+ }
59
+
60
+ // Body was consumed; always return a re-wrapped Response so callers can
61
+ // still read it (even if parse/record failed).
62
+ return new Response(buf, {
63
+ status: response.status,
64
+ statusText: response.statusText,
65
+ headers: response.headers,
66
+ });
67
+ } catch {
68
+ // Outer safety net: never throw from the peek path. If we never consumed
69
+ // the body, the original is still usable.
70
+ return response;
71
+ }
72
+ }
73
+
15
74
  export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
16
75
  const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
17
76
  if (opts.query) {
@@ -43,8 +102,9 @@ export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
43
102
  level: "warning",
44
103
  data: { url: safeUrl, status: response.status },
45
104
  });
105
+ return response;
46
106
  }
47
- return response;
107
+ return peekPlanLimitStatus(response);
48
108
  }
49
109
 
50
110
  /**