@indigoai-us/hq-cli 5.77.8 → 5.77.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,264 @@
1
+ /**
2
+ * `plan-limit-nag` (US-016) — non-blocking plan-limit warning surface.
3
+ *
4
+ * hq-pro shallow-merges a `planLimits` status payload into the top level of
5
+ * some 2xx JSON response bodies (US-012). This module:
6
+ *
7
+ * 1. Best-effort records the last-seen status from decoded JSON bodies
8
+ * (`recordPlanLimitStatus`).
9
+ * 2. Emits a stderr nag at command completion (`emitPlanLimitNag`):
10
+ * - entries present, none over (≥80% warning): one-line yellow warning,
11
+ * once per process session
12
+ * - any resource over: boxed notice, at most once per day (persisted in
13
+ * `~/.hq/plan-limit-nag.json`) and once per session
14
+ *
15
+ * Additive only — never throws, never touches `process.exitCode`, never
16
+ * writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
17
+ */
18
+
19
+ import chalk from "chalk";
20
+ import * as fs from "node:fs";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+
24
+ export const PLAN_LIMIT_UPGRADE_URL =
25
+ "https://app.indigo-hq.com/billing/upgrade";
26
+
27
+ const DAY_MS = 24 * 60 * 60 * 1000;
28
+
29
+ export interface PlanLimitEntry {
30
+ used: number;
31
+ limit: number;
32
+ over: boolean;
33
+ }
34
+
35
+ /** Validated map of resource key → entry. */
36
+ export type PlanLimitsMap = Record<string, PlanLimitEntry>;
37
+
38
+ interface LastSeenStatus {
39
+ limits: PlanLimitsMap;
40
+ anyOver: boolean;
41
+ }
42
+
43
+ interface NagStateFile {
44
+ shownAt: number;
45
+ }
46
+
47
+ /** Module-level last-seen cell — overwritten by each successful parse. */
48
+ let lastSeen: LastSeenStatus | null = null;
49
+
50
+ /** Session dedupe for the ≥80% one-line warning. */
51
+ let warningShownThisSession = false;
52
+
53
+ /** Session dedupe for the over-limit boxed notice. */
54
+ let overShownThisSession = false;
55
+
56
+ function defaultStatePath(): string {
57
+ return path.join(os.homedir(), ".hq", "plan-limit-nag.json");
58
+ }
59
+
60
+ function isOptedOut(): boolean {
61
+ return process.env.HQ_NO_PLAN_LIMIT_NAG === "1";
62
+ }
63
+
64
+ /**
65
+ * Defensively parse a single planLimits entry. Returns null if the shape is
66
+ * not well-formed (non-numeric used/limit, non-boolean over, etc.).
67
+ */
68
+ function parseEntry(value: unknown): PlanLimitEntry | null {
69
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
70
+ return null;
71
+ }
72
+ const rec = value as Record<string, unknown>;
73
+ if (
74
+ typeof rec.used !== "number" ||
75
+ !Number.isFinite(rec.used) ||
76
+ typeof rec.limit !== "number" ||
77
+ !Number.isFinite(rec.limit) ||
78
+ typeof rec.over !== "boolean"
79
+ ) {
80
+ return null;
81
+ }
82
+ return { used: rec.used, limit: rec.limit, over: rec.over };
83
+ }
84
+
85
+ /**
86
+ * Defensively parse a decoded JSON body for a well-formed top-level
87
+ * `planLimits` object. Malformed or absent → null. Never throws.
88
+ */
89
+ function parsePlanLimits(body: unknown): PlanLimitsMap | null {
90
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
91
+ return null;
92
+ }
93
+ const planLimits = (body as Record<string, unknown>).planLimits;
94
+ if (
95
+ planLimits === null ||
96
+ typeof planLimits !== "object" ||
97
+ Array.isArray(planLimits)
98
+ ) {
99
+ return null;
100
+ }
101
+
102
+ const out: PlanLimitsMap = {};
103
+ let anyValid = false;
104
+ for (const [key, value] of Object.entries(
105
+ planLimits as Record<string, unknown>,
106
+ )) {
107
+ const entry = parseEntry(value);
108
+ if (entry === null) continue;
109
+ out[key] = entry;
110
+ anyValid = true;
111
+ }
112
+ return anyValid ? out : null;
113
+ }
114
+
115
+ /**
116
+ * Record plan-limit status from a decoded JSON response body.
117
+ * Never throws. Overwrites the last-seen cell when a well-formed
118
+ * `planLimits` object is present; ignores malformed / absent payloads.
119
+ */
120
+ export function recordPlanLimitStatus(body: unknown): void {
121
+ try {
122
+ const limits = parsePlanLimits(body);
123
+ if (limits === null) return;
124
+ const anyOver = Object.values(limits).some((e) => e.over);
125
+ lastSeen = { limits, anyOver };
126
+ } catch {
127
+ // Never throw from record path.
128
+ }
129
+ }
130
+
131
+ function usageRatio(entry: PlanLimitEntry): number {
132
+ if (entry.limit <= 0) return entry.used > 0 ? Infinity : 0;
133
+ return entry.used / entry.limit;
134
+ }
135
+
136
+ /** Pick the resource with the highest used/limit ratio (worst headroom). */
137
+ function worstResource(
138
+ limits: PlanLimitsMap,
139
+ ): { key: string; entry: PlanLimitEntry } | null {
140
+ let best: { key: string; entry: PlanLimitEntry; ratio: number } | null =
141
+ null;
142
+ for (const [key, entry] of Object.entries(limits)) {
143
+ const ratio = usageRatio(entry);
144
+ if (best === null || ratio > best.ratio) {
145
+ best = { key, entry, ratio };
146
+ }
147
+ }
148
+ return best ? { key: best.key, entry: best.entry } : null;
149
+ }
150
+
151
+ function formatPct(entry: PlanLimitEntry): string {
152
+ if (entry.limit <= 0) return entry.used > 0 ? "∞" : "0%";
153
+ return `${Math.round((entry.used / entry.limit) * 100)}%`;
154
+ }
155
+
156
+ function formatEntryLine(key: string, entry: PlanLimitEntry): string {
157
+ return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})`;
158
+ }
159
+
160
+ function readShownAt(statePath: string): number | null {
161
+ try {
162
+ const raw = fs.readFileSync(statePath, "utf-8");
163
+ const parsed = JSON.parse(raw) as Partial<NagStateFile>;
164
+ if (typeof parsed.shownAt !== "number" || !Number.isFinite(parsed.shownAt)) {
165
+ return null;
166
+ }
167
+ return parsed.shownAt;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ function writeShownAt(statePath: string, shownAt: number): void {
174
+ try {
175
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
176
+ const payload: NagStateFile = { shownAt };
177
+ fs.writeFileSync(statePath, JSON.stringify(payload));
178
+ } catch {
179
+ // best-effort; never break the CLI on cache write failure
180
+ }
181
+ }
182
+
183
+ function withinDayWindow(shownAt: number, nowMs: number): boolean {
184
+ return nowMs - shownAt < DAY_MS;
185
+ }
186
+
187
+ function buildOverBox(overEntries: Array<[string, PlanLimitEntry]>): string {
188
+ const title = "⚠ HQ plan limit exceeded";
189
+ const upgrade = `Upgrade: ${PLAN_LIMIT_UPGRADE_URL}`;
190
+ const resourceLines = overEntries.map(
191
+ ([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}`,
192
+ );
193
+
194
+ const contentLines = [title, "", ...resourceLines, "", upgrade];
195
+ const innerWidth = Math.max(
196
+ ...contentLines.map((l) => l.length),
197
+ 40,
198
+ );
199
+ const top = `┌${"─".repeat(innerWidth + 2)}┐`;
200
+ const bot = `└${"─".repeat(innerWidth + 2)}┘`;
201
+ const mid = contentLines
202
+ .map((l) => `│ ${l.padEnd(innerWidth)} │`)
203
+ .join("\n");
204
+ return `${top}\n${mid}\n${bot}`;
205
+ }
206
+
207
+ /**
208
+ * Emit a plan-limit nag to stderr at command completion.
209
+ *
210
+ * Never throws, never touches `process.exitCode`. Default sink is
211
+ * `process.stderr.write`. Tests inject `write`, `now`, and `statePath`.
212
+ */
213
+ export function emitPlanLimitNag(
214
+ opts: {
215
+ write?: (s: string) => void;
216
+ now?: () => Date;
217
+ statePath?: string;
218
+ } = {},
219
+ ): void {
220
+ try {
221
+ if (isOptedOut()) return;
222
+ if (lastSeen === null) return;
223
+
224
+ const write =
225
+ opts.write ?? ((s: string) => process.stderr.write(s));
226
+ const now = opts.now ?? (() => new Date());
227
+ const statePath = opts.statePath ?? defaultStatePath();
228
+ const { limits, anyOver } = lastSeen;
229
+ const entries = Object.entries(limits);
230
+ if (entries.length === 0) return;
231
+
232
+ if (anyOver) {
233
+ if (overShownThisSession) return;
234
+ const nowMs = now().getTime();
235
+ const prev = readShownAt(statePath);
236
+ if (prev !== null && withinDayWindow(prev, nowMs)) return;
237
+
238
+ overShownThisSession = true;
239
+ const overEntries = entries.filter(([, e]) => e.over);
240
+ const box = buildOverBox(overEntries);
241
+ write(chalk.yellow(box) + "\n");
242
+ writeShownAt(statePath, nowMs);
243
+ return;
244
+ }
245
+
246
+ // Warning path: entries present, none over (≥80% resources only appear).
247
+ if (warningShownThisSession) return;
248
+ const worst = worstResource(limits);
249
+ if (worst === null) return;
250
+
251
+ warningShownThisSession = true;
252
+ const line = `⚠ HQ free plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${PLAN_LIMIT_UPGRADE_URL}`;
253
+ write(chalk.yellow(line) + "\n");
254
+ } catch {
255
+ // Never throw from emit path.
256
+ }
257
+ }
258
+
259
+ /** Test-only helper — clears last-seen status and session dedupe flags. */
260
+ export function _resetForTests(): void {
261
+ lastSeen = null;
262
+ warningShownThisSession = false;
263
+ overShownThisSession = false;
264
+ }
package/src/main.ts CHANGED
@@ -76,6 +76,7 @@ import {
76
76
  import { CLI_VERSION } from "./cli-version.js";
77
77
  import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
78
78
  import { settleWithin } from "./utils/settle-with-timeout.js";
79
+ import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
79
80
 
80
81
  /** Hard upper bound for non-user-visible release-health finalization. */
81
82
  const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
@@ -353,6 +354,9 @@ export async function runCli(): Promise<void> {
353
354
  process.exitCode = 1;
354
355
  }
355
356
  } finally {
357
+ // Plan-limit nag (US-016): stderr-only, never throws, never touches
358
+ // process.exitCode — safe to run after exit codes have been set.
359
+ emitPlanLimitNag();
356
360
  // Release health: finalize the per-run session before the flush.
357
361
  Sentry.endSession();
358
362
  // Neither task may turn a successful command into Node's
@@ -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
  /**