@indigoai-us/hq-cli 5.77.7 → 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.
- package/CHANGELOG.md +27 -0
- package/dist/commands/group-grants.d.ts +1 -1
- package/dist/commands/group-grants.js +6 -6
- package/dist/commands/secrets.js +2 -1
- package/dist/lib/plan-limit-nag.d.ts +45 -0
- package/dist/lib/plan-limit-nag.js +212 -0
- package/dist/main.js +4 -0
- package/dist/utils/vault-api.js +62 -1
- package/dist/utils/version-gate.d.ts +97 -1
- package/dist/utils/version-gate.js +211 -32
- package/package.json +1 -1
- package/src/commands/group-grants.test.ts +41 -2
- package/src/commands/group-grants.ts +11 -8
- package/src/commands/reindex.test.ts +1 -1
- package/src/commands/secrets.test.ts +40 -0
- package/src/commands/secrets.ts +11 -2
- package/src/lib/plan-limit-nag.test.ts +317 -0
- package/src/lib/plan-limit-nag.ts +264 -0
- package/src/main.ts +4 -0
- package/src/utils/vault-api.test.ts +139 -0
- package/src/utils/vault-api.ts +61 -1
- package/src/utils/version-gate.test.ts +415 -6
- package/src/utils/version-gate.ts +259 -33
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `plan-limit-nag` (US-016).
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* 1. Warning renders when ≥80% metadata present; absent → no output.
|
|
6
|
+
* 2. Malformed planLimits ignored (no output, no throw).
|
|
7
|
+
* 3. Once-per-session: second emit prints nothing.
|
|
8
|
+
* 4. Over-limit boxed notice; same-day state suppresses; >24h re-prints.
|
|
9
|
+
* 5. HQ_NO_PLAN_LIMIT_NAG=1 disables all output.
|
|
10
|
+
* 6. emit never throws even when statePath unwritable.
|
|
11
|
+
* 7. Exit-code neutrality: emit does not touch process.exitCode.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
PLAN_LIMIT_UPGRADE_URL,
|
|
21
|
+
_resetForTests,
|
|
22
|
+
emitPlanLimitNag,
|
|
23
|
+
recordPlanLimitStatus,
|
|
24
|
+
} from "./plan-limit-nag.js";
|
|
25
|
+
|
|
26
|
+
function makeSink(): { lines: string[]; write: (s: string) => void } {
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
return {
|
|
29
|
+
lines,
|
|
30
|
+
write: (s: string) => {
|
|
31
|
+
lines.push(s);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tmpStatePath(): string {
|
|
37
|
+
return path.join(
|
|
38
|
+
os.tmpdir(),
|
|
39
|
+
`hq-plan-limit-nag-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Portable unwritable statePath: join under a plain FILE so
|
|
45
|
+
* mkdirSync/writeFileSync/readFileSync fail immediately with ENOTDIR
|
|
46
|
+
* on every platform (unlike /proc paths, which hang on Linux procfs).
|
|
47
|
+
*/
|
|
48
|
+
function unwritableStatePath(): string {
|
|
49
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-nag-"));
|
|
50
|
+
const file = path.join(dir, "not-a-dir");
|
|
51
|
+
fs.writeFileSync(file, "x");
|
|
52
|
+
return path.join(file, "x", "plan-limit-nag.json");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ENV_KEY = "HQ_NO_PLAN_LIMIT_NAG";
|
|
56
|
+
let prevEnv: string | undefined;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
_resetForTests();
|
|
60
|
+
prevEnv = process.env[ENV_KEY];
|
|
61
|
+
delete process.env[ENV_KEY];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
if (prevEnv === undefined) {
|
|
66
|
+
delete process.env[ENV_KEY];
|
|
67
|
+
} else {
|
|
68
|
+
process.env[ENV_KEY] = prevEnv;
|
|
69
|
+
}
|
|
70
|
+
_resetForTests();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("recordPlanLimitStatus + emitPlanLimitNag (warning path)", () => {
|
|
74
|
+
it("renders a one-line warning when planLimits has a ≥80% entry", () => {
|
|
75
|
+
const sink = makeSink();
|
|
76
|
+
recordPlanLimitStatus({
|
|
77
|
+
ok: true,
|
|
78
|
+
planLimits: {
|
|
79
|
+
users: { used: 9, limit: 10, over: false },
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
83
|
+
expect(sink.lines).toHaveLength(1);
|
|
84
|
+
expect(sink.lines[0]).toMatch(/HQ free plan/i);
|
|
85
|
+
expect(sink.lines[0]).toMatch(/users at 9\/10 \(90%\)/);
|
|
86
|
+
expect(sink.lines[0]).toContain(PLAN_LIMIT_UPGRADE_URL);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("produces no output when planLimits metadata is absent", () => {
|
|
90
|
+
const sink = makeSink();
|
|
91
|
+
recordPlanLimitStatus({ ok: true, items: [] });
|
|
92
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
93
|
+
expect(sink.lines).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("produces no output when recordPlanLimitStatus was never called", () => {
|
|
97
|
+
const sink = makeSink();
|
|
98
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
99
|
+
expect(sink.lines).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("lists the worst resource when multiple ≥80% entries exist", () => {
|
|
103
|
+
const sink = makeSink();
|
|
104
|
+
recordPlanLimitStatus({
|
|
105
|
+
planLimits: {
|
|
106
|
+
users: { used: 8, limit: 10, over: false },
|
|
107
|
+
secrets: { used: 19, limit: 20, over: false },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
111
|
+
expect(sink.lines).toHaveLength(1);
|
|
112
|
+
expect(sink.lines[0]).toMatch(/secrets at 19\/20/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("malformed planLimits", () => {
|
|
117
|
+
it("ignores wrong types — no output, no throw", () => {
|
|
118
|
+
const sink = makeSink();
|
|
119
|
+
expect(() =>
|
|
120
|
+
recordPlanLimitStatus({
|
|
121
|
+
planLimits: {
|
|
122
|
+
users: { used: "9", limit: 10, over: false },
|
|
123
|
+
secrets: { used: 5, limit: "10", over: true },
|
|
124
|
+
agents: null,
|
|
125
|
+
integrations: "nope",
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
).not.toThrow();
|
|
129
|
+
expect(() =>
|
|
130
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() }),
|
|
131
|
+
).not.toThrow();
|
|
132
|
+
expect(sink.lines).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ignores non-object planLimits", () => {
|
|
136
|
+
const sink = makeSink();
|
|
137
|
+
recordPlanLimitStatus({ planLimits: "not-an-object" });
|
|
138
|
+
recordPlanLimitStatus({ planLimits: [1, 2, 3] });
|
|
139
|
+
recordPlanLimitStatus({ planLimits: null });
|
|
140
|
+
recordPlanLimitStatus(null);
|
|
141
|
+
recordPlanLimitStatus("string");
|
|
142
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
143
|
+
expect(sink.lines).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("accepts only well-formed entries and still emits when mixed", () => {
|
|
147
|
+
const sink = makeSink();
|
|
148
|
+
recordPlanLimitStatus({
|
|
149
|
+
planLimits: {
|
|
150
|
+
bad: { used: "x", limit: 1, over: false },
|
|
151
|
+
users: { used: 9, limit: 10, over: false },
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
155
|
+
expect(sink.lines).toHaveLength(1);
|
|
156
|
+
expect(sink.lines[0]).toMatch(/users at 9\/10/);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("once-per-session dedupe", () => {
|
|
161
|
+
it("second emit prints nothing for the warning path", () => {
|
|
162
|
+
const sink = makeSink();
|
|
163
|
+
const statePath = tmpStatePath();
|
|
164
|
+
recordPlanLimitStatus({
|
|
165
|
+
planLimits: { users: { used: 9, limit: 10, over: false } },
|
|
166
|
+
});
|
|
167
|
+
emitPlanLimitNag({ write: sink.write, statePath });
|
|
168
|
+
emitPlanLimitNag({ write: sink.write, statePath });
|
|
169
|
+
expect(sink.lines).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("over-limit boxed notice", () => {
|
|
174
|
+
it("prints a boxed over-limit notice listing over resources", () => {
|
|
175
|
+
const sink = makeSink();
|
|
176
|
+
const statePath = tmpStatePath();
|
|
177
|
+
recordPlanLimitStatus({
|
|
178
|
+
planLimits: {
|
|
179
|
+
users: { used: 11, limit: 10, over: true },
|
|
180
|
+
secrets: { used: 5, limit: 20, over: false },
|
|
181
|
+
},
|
|
182
|
+
requiredPlan: "agents-500",
|
|
183
|
+
});
|
|
184
|
+
emitPlanLimitNag({
|
|
185
|
+
write: sink.write,
|
|
186
|
+
statePath,
|
|
187
|
+
now: () => new Date("2026-03-01T12:00:00.000Z"),
|
|
188
|
+
});
|
|
189
|
+
expect(sink.lines).toHaveLength(1);
|
|
190
|
+
const out = sink.lines[0] ?? "";
|
|
191
|
+
expect(out).toMatch(/plan limit exceeded/i);
|
|
192
|
+
expect(out).toMatch(/users:\s*11\/10/);
|
|
193
|
+
expect(out).toContain(PLAN_LIMIT_UPGRADE_URL);
|
|
194
|
+
expect(out).toMatch(/[┌└│─]/);
|
|
195
|
+
// Non-over resources should not appear in the over box resource list.
|
|
196
|
+
expect(out).not.toMatch(/secrets:\s*5\/20/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("second emit same day (fresh shownAt in statePath) prints nothing", () => {
|
|
200
|
+
const sink = makeSink();
|
|
201
|
+
const statePath = tmpStatePath();
|
|
202
|
+
const now = new Date("2026-03-01T12:00:00.000Z");
|
|
203
|
+
fs.writeFileSync(
|
|
204
|
+
statePath,
|
|
205
|
+
JSON.stringify({ shownAt: now.getTime() - 60 * 60 * 1000 }),
|
|
206
|
+
);
|
|
207
|
+
recordPlanLimitStatus({
|
|
208
|
+
planLimits: { users: { used: 11, limit: 10, over: true } },
|
|
209
|
+
});
|
|
210
|
+
emitPlanLimitNag({ write: sink.write, statePath, now: () => now });
|
|
211
|
+
expect(sink.lines).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("shownAt older than 24h prints again", () => {
|
|
215
|
+
const sink = makeSink();
|
|
216
|
+
const statePath = tmpStatePath();
|
|
217
|
+
const now = new Date("2026-03-02T13:00:00.000Z");
|
|
218
|
+
fs.writeFileSync(
|
|
219
|
+
statePath,
|
|
220
|
+
JSON.stringify({
|
|
221
|
+
shownAt: now.getTime() - 25 * 60 * 60 * 1000,
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
recordPlanLimitStatus({
|
|
225
|
+
planLimits: { users: { used: 11, limit: 10, over: true } },
|
|
226
|
+
});
|
|
227
|
+
emitPlanLimitNag({ write: sink.write, statePath, now: () => now });
|
|
228
|
+
expect(sink.lines).toHaveLength(1);
|
|
229
|
+
expect(sink.lines[0]).toMatch(/plan limit exceeded/i);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("session-dedupes over notice even without state file", () => {
|
|
233
|
+
const sink = makeSink();
|
|
234
|
+
// Unwritable parent: skip persistence but still session-dedupe.
|
|
235
|
+
const statePath = unwritableStatePath();
|
|
236
|
+
recordPlanLimitStatus({
|
|
237
|
+
planLimits: { users: { used: 11, limit: 10, over: true } },
|
|
238
|
+
});
|
|
239
|
+
emitPlanLimitNag({
|
|
240
|
+
write: sink.write,
|
|
241
|
+
statePath,
|
|
242
|
+
now: () => new Date("2026-03-01T12:00:00.000Z"),
|
|
243
|
+
});
|
|
244
|
+
emitPlanLimitNag({
|
|
245
|
+
write: sink.write,
|
|
246
|
+
statePath,
|
|
247
|
+
now: () => new Date("2026-03-01T12:00:00.000Z"),
|
|
248
|
+
});
|
|
249
|
+
expect(sink.lines).toHaveLength(1);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("env off-switch", () => {
|
|
254
|
+
it("HQ_NO_PLAN_LIMIT_NAG=1 produces no output", () => {
|
|
255
|
+
process.env[ENV_KEY] = "1";
|
|
256
|
+
const sink = makeSink();
|
|
257
|
+
recordPlanLimitStatus({
|
|
258
|
+
planLimits: {
|
|
259
|
+
users: { used: 11, limit: 10, over: true },
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
263
|
+
expect(sink.lines).toHaveLength(0);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("resilience + exit-code neutrality", () => {
|
|
268
|
+
it("emit never throws even when statePath is unwritable", () => {
|
|
269
|
+
const sink = makeSink();
|
|
270
|
+
const statePath = unwritableStatePath();
|
|
271
|
+
recordPlanLimitStatus({
|
|
272
|
+
planLimits: { users: { used: 12, limit: 10, over: true } },
|
|
273
|
+
});
|
|
274
|
+
expect(() =>
|
|
275
|
+
emitPlanLimitNag({
|
|
276
|
+
write: sink.write,
|
|
277
|
+
statePath,
|
|
278
|
+
now: () => new Date("2026-03-01T12:00:00.000Z"),
|
|
279
|
+
}),
|
|
280
|
+
).not.toThrow();
|
|
281
|
+
// Still attempted to print (write may succeed even if state write fails).
|
|
282
|
+
expect(sink.lines.length).toBeGreaterThanOrEqual(0);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("does not touch process.exitCode", () => {
|
|
286
|
+
const prev = process.exitCode;
|
|
287
|
+
process.exitCode = 0;
|
|
288
|
+
try {
|
|
289
|
+
const sink = makeSink();
|
|
290
|
+
recordPlanLimitStatus({
|
|
291
|
+
planLimits: { users: { used: 9, limit: 10, over: false } },
|
|
292
|
+
});
|
|
293
|
+
emitPlanLimitNag({ write: sink.write, statePath: tmpStatePath() });
|
|
294
|
+
expect(process.exitCode).toBe(0);
|
|
295
|
+
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
_resetForTests();
|
|
298
|
+
recordPlanLimitStatus({
|
|
299
|
+
planLimits: { users: { used: 11, limit: 10, over: true } },
|
|
300
|
+
});
|
|
301
|
+
emitPlanLimitNag({
|
|
302
|
+
write: sink.write,
|
|
303
|
+
statePath: tmpStatePath(),
|
|
304
|
+
now: () => new Date("2026-04-01T00:00:00.000Z"),
|
|
305
|
+
});
|
|
306
|
+
expect(process.exitCode).toBe(1);
|
|
307
|
+
} finally {
|
|
308
|
+
process.exitCode = prev;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("recordPlanLimitStatus never throws on garbage input", () => {
|
|
313
|
+
expect(() => recordPlanLimitStatus(undefined)).not.toThrow();
|
|
314
|
+
expect(() => recordPlanLimitStatus(42)).not.toThrow();
|
|
315
|
+
expect(() => recordPlanLimitStatus([])).not.toThrow();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -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
|