@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.
- package/CHANGELOG.md +19 -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/reindex.test.ts +1 -1
- 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
|
@@ -72,20 +72,95 @@ export function npmPrefixFromPackageDir(pkgDir) {
|
|
|
72
72
|
return "/";
|
|
73
73
|
return prefix || null;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
/** `$PNPM_HOME/global`, normalised to forward slashes, when PNPM_HOME is set. */
|
|
76
|
+
function pnpmHomeGlobalRoot() {
|
|
77
|
+
const home = process.env.PNPM_HOME;
|
|
78
|
+
if (!home)
|
|
79
|
+
return null;
|
|
80
|
+
const normalized = home.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
81
|
+
return normalized ? `${normalized}/global` : null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether the running package lives inside a pnpm-managed **global** install.
|
|
85
|
+
*
|
|
86
|
+
* pnpm does not use npm's `<prefix>/lib/node_modules` layout. A global
|
|
87
|
+
* `pnpm add -g` puts the package in a versioned content store under the
|
|
88
|
+
* `global/<store-layout-version>` root and exposes it through a generated shim
|
|
89
|
+
* on PATH:
|
|
90
|
+
*
|
|
91
|
+
* $PNPM_HOME/hq <- shim on PATH
|
|
92
|
+
* $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli <- symlink
|
|
93
|
+
* $PNPM_HOME/global/5/.pnpm/@indigoai-us+hq-cli@5.61.0/node_modules/…
|
|
94
|
+
*
|
|
95
|
+
* Both the symlinked and the resolved (`.pnpm`) form are recognised, because
|
|
96
|
+
* whether `import.meta.url` reports the link or its target depends on how node
|
|
97
|
+
* resolved the entrypoint.
|
|
98
|
+
*
|
|
99
|
+
* A bare `.pnpm` segment is deliberately NOT enough. It also appears in a local
|
|
100
|
+
* project dependency (`<proj>/node_modules/.pnpm/@indigoai-us+hq-cli@…`) and in
|
|
101
|
+
* a `pnpm dlx` cache. Neither of those is what `pnpm add -g` updates, so
|
|
102
|
+
* treating them as global would mutate the user's global install as a side
|
|
103
|
+
* effect of a local invocation while the copy actually running stayed stale —
|
|
104
|
+
* i.e. the gate would re-fire and re-install globally on every subsequent run.
|
|
105
|
+
* Those layouts fall through to the ordinary npm-prefix/manual handling.
|
|
106
|
+
*/
|
|
107
|
+
export function isPnpmManagedPackageDir(pkgDir) {
|
|
108
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
109
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
110
|
+
// `global/<digits>` — pnpm's global root, in either the symlinked or the
|
|
111
|
+
// resolved (`.pnpm`) form, and independent of where PNPM_HOME points.
|
|
112
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
113
|
+
if (segments[i] === "global" && /^\d+$/.test(segments[i + 1]))
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// Belt-and-braces for a future pnpm layout whose store dir is not numeric:
|
|
117
|
+
// anything under `$PNPM_HOME/global` is global by construction. Still keyed
|
|
118
|
+
// on the `global` segment so `$PNPM_HOME/store/**` (dlx caches) stays out.
|
|
119
|
+
const globalRoot = pnpmHomeGlobalRoot();
|
|
120
|
+
if (globalRoot) {
|
|
121
|
+
if (normalized === globalRoot || normalized.startsWith(`${globalRoot}/`)) {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
export function resolveRunningInstall() {
|
|
76
128
|
try {
|
|
77
|
-
const
|
|
78
|
-
if (!
|
|
79
|
-
return null;
|
|
80
|
-
|
|
129
|
+
const packageRoot = findRunningPackageRoot();
|
|
130
|
+
if (!packageRoot)
|
|
131
|
+
return { manager: "npm", prefix: null, packageRoot: null };
|
|
132
|
+
if (isPnpmManagedPackageDir(packageRoot)) {
|
|
133
|
+
return { manager: "pnpm", prefix: null, packageRoot };
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
manager: "npm",
|
|
137
|
+
prefix: npmPrefixFromPackageDir(packageRoot),
|
|
138
|
+
packageRoot,
|
|
139
|
+
};
|
|
81
140
|
}
|
|
82
141
|
catch {
|
|
83
|
-
return null;
|
|
142
|
+
return { manager: "npm", prefix: null, packageRoot: null };
|
|
84
143
|
}
|
|
85
144
|
}
|
|
145
|
+
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
146
|
+
export function resolveRunningManager() {
|
|
147
|
+
return resolveRunningInstall().manager;
|
|
148
|
+
}
|
|
149
|
+
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
150
|
+
export function resolveRunningPrefix() {
|
|
151
|
+
return resolveRunningInstall().prefix;
|
|
152
|
+
}
|
|
86
153
|
export function buildPrefixedInstallArgv(prefix) {
|
|
87
154
|
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
88
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Argv for updating a pnpm-managed global install. `pnpm add -g` rewrites the
|
|
158
|
+
* PATH shim as part of the install, so the next invocation genuinely resolves
|
|
159
|
+
* the new version — which is the whole point of routing here instead of npm.
|
|
160
|
+
*/
|
|
161
|
+
export function buildPnpmInstallArgv() {
|
|
162
|
+
return ["add", "-g", LATEST_PACKAGE_SPEC];
|
|
163
|
+
}
|
|
89
164
|
const nodeStaleInstallFs = {
|
|
90
165
|
readdirSync: (dir) => readdirSync(dir),
|
|
91
166
|
existsSync,
|
|
@@ -205,9 +280,44 @@ async function fetchVersionDecision() {
|
|
|
205
280
|
return null;
|
|
206
281
|
}
|
|
207
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Quote an argv entry for a Windows `cmd.exe` invocation. Needed because Node
|
|
285
|
+
* does NOT quote argv when spawning with `shell: true` on Windows — it joins
|
|
286
|
+
* the array with spaces — so an npm prefix like `C:\Program Files\…` would be
|
|
287
|
+
* split into two arguments.
|
|
288
|
+
*/
|
|
289
|
+
export function quoteForWindowsShell(arg) {
|
|
290
|
+
if (arg === "")
|
|
291
|
+
return '""';
|
|
292
|
+
if (!/[\s"^&|<>()]/.test(arg))
|
|
293
|
+
return arg;
|
|
294
|
+
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* How to hand `<cmd> <args…>` to `spawnSync` on this platform.
|
|
298
|
+
*
|
|
299
|
+
* On Windows both `npm` and `pnpm` are `.cmd` shims, and since the
|
|
300
|
+
* CVE-2024-27980 hardening Node refuses to spawn a `.cmd`/`.bat` file without
|
|
301
|
+
* a shell. Without this the update would fail with EINVAL/ENOENT on every
|
|
302
|
+
* Windows install — including the pnpm layouts this gate claims to detect.
|
|
303
|
+
*/
|
|
304
|
+
export function buildSpawnPlan(cmd, args, platform = process.platform) {
|
|
305
|
+
if (platform !== "win32")
|
|
306
|
+
return { cmd, args: [...args], shell: false };
|
|
307
|
+
return { cmd, args: args.map(quoteForWindowsShell), shell: true };
|
|
308
|
+
}
|
|
208
309
|
function runUpdateCommand(cmd, args) {
|
|
209
310
|
try {
|
|
210
|
-
const
|
|
311
|
+
const plan = buildSpawnPlan(cmd, args);
|
|
312
|
+
const result = spawnSync(plan.cmd, plan.args, {
|
|
313
|
+
stdio: "inherit",
|
|
314
|
+
shell: plan.shell,
|
|
315
|
+
});
|
|
316
|
+
// spawnSync reports a missing executable via `error`, not a throw.
|
|
317
|
+
if (result.error) {
|
|
318
|
+
const code = result.error.code;
|
|
319
|
+
return { ok: false, code, detail: result.error.message };
|
|
320
|
+
}
|
|
211
321
|
if (result.status !== 0) {
|
|
212
322
|
return {
|
|
213
323
|
ok: false,
|
|
@@ -217,7 +327,11 @@ function runUpdateCommand(cmd, args) {
|
|
|
217
327
|
return { ok: true };
|
|
218
328
|
}
|
|
219
329
|
catch (err) {
|
|
220
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
code: err?.code,
|
|
333
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
334
|
+
};
|
|
221
335
|
}
|
|
222
336
|
}
|
|
223
337
|
function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
|
|
@@ -231,15 +345,38 @@ function performUpdate(command, runner = runUpdateCommand) {
|
|
|
231
345
|
const args = parts.slice(1);
|
|
232
346
|
return performUpdateCommand(cmd, args, runner);
|
|
233
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* The command a user should run by hand for this install layout. hq-pro's
|
|
350
|
+
* `updateCommand` is npm-shaped for every client, so a pnpm-managed install
|
|
351
|
+
* must never be told to run it: `npm install -g …` drops a fresh copy under the
|
|
352
|
+
* npm global prefix while pnpm's PATH shim keeps resolving the old build. That
|
|
353
|
+
* is verbatim the reported symptom (a stale shim still reporting 5.61.0 after a
|
|
354
|
+
* "successful" update), so both the hard gate and the soft nudge below have to
|
|
355
|
+
* speak the running manager's language.
|
|
356
|
+
*/
|
|
357
|
+
function manualUpdateCommand(install, decision) {
|
|
358
|
+
if (install.manager === "pnpm")
|
|
359
|
+
return `pnpm ${buildPnpmInstallArgv().join(" ")}`;
|
|
360
|
+
if (install.prefix)
|
|
361
|
+
return `npm ${buildPrefixedInstallArgv(install.prefix).join(" ")}`;
|
|
362
|
+
return decision.updateCommand;
|
|
363
|
+
}
|
|
234
364
|
/**
|
|
235
365
|
* Soft notify when the server says we're below `latestVersion` but still ≥
|
|
236
366
|
* `minVersion`. Single chalk-yellow line on stderr; never blocks.
|
|
367
|
+
*
|
|
368
|
+
* This path fires for EVERY version below latest (the hard gate only fires
|
|
369
|
+
* below `minVersion`), so it is the far more frequently seen of the two and
|
|
370
|
+
* must be manager-aware for the same reason the gate is.
|
|
237
371
|
*/
|
|
238
|
-
function nudgeUpdateRecommended(decision) {
|
|
372
|
+
function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
|
|
239
373
|
const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
|
|
240
374
|
console.error(msg);
|
|
241
|
-
|
|
242
|
-
|
|
375
|
+
const command = install.manager === "pnpm"
|
|
376
|
+
? `pnpm ${buildPnpmInstallArgv().join(" ")}`
|
|
377
|
+
: decision.updateCommand;
|
|
378
|
+
if (command) {
|
|
379
|
+
console.error(chalk.dim(` Update: ${command}`));
|
|
243
380
|
}
|
|
244
381
|
}
|
|
245
382
|
/**
|
|
@@ -258,8 +395,14 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
258
395
|
if (decision.message)
|
|
259
396
|
console.error(chalk.dim(` ${decision.message}`));
|
|
260
397
|
const command = decision.updateCommand;
|
|
261
|
-
|
|
262
|
-
|
|
398
|
+
// ONE package-root walk decides both the manager and the prefix. A
|
|
399
|
+
// pnpm-managed install must be updated by pnpm; the server's `updateCommand`
|
|
400
|
+
// and any npm prefix are both wrong for that layout, so `resolveRunningInstall`
|
|
401
|
+
// already reports `prefix: null` there and neither is consulted below.
|
|
402
|
+
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
403
|
+
const isPnpm = install.manager === "pnpm";
|
|
404
|
+
const prefix = install.prefix;
|
|
405
|
+
if (!isPnpm && !command && !prefix) {
|
|
263
406
|
console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
|
|
264
407
|
if (decision.downloadUrl) {
|
|
265
408
|
console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
|
|
@@ -271,7 +414,11 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
271
414
|
// the EXACT same command under elevation.
|
|
272
415
|
let primaryCmd;
|
|
273
416
|
let primaryArgs;
|
|
274
|
-
if (
|
|
417
|
+
if (isPnpm) {
|
|
418
|
+
primaryCmd = "pnpm";
|
|
419
|
+
primaryArgs = buildPnpmInstallArgv();
|
|
420
|
+
}
|
|
421
|
+
else if (prefix) {
|
|
275
422
|
primaryCmd = "npm";
|
|
276
423
|
primaryArgs = buildPrefixedInstallArgv(prefix);
|
|
277
424
|
}
|
|
@@ -280,18 +427,23 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
280
427
|
primaryCmd = parts[0] ?? "";
|
|
281
428
|
primaryArgs = parts.slice(1);
|
|
282
429
|
}
|
|
283
|
-
let result
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
430
|
+
let result;
|
|
431
|
+
if (isPnpm) {
|
|
432
|
+
console.error(chalk.dim(" Detected a pnpm-managed global install; updating with pnpm"));
|
|
433
|
+
console.error(chalk.dim(` Running: pnpm ${primaryArgs.join(" ")}`));
|
|
434
|
+
result = performUpdateCommand("pnpm", primaryArgs, runner);
|
|
435
|
+
}
|
|
436
|
+
else if (prefix) {
|
|
437
|
+
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
438
|
+
console.error(chalk.dim(` Running: npm ${primaryArgs.join(" ")}`));
|
|
439
|
+
result = performUpdateCommand("npm", primaryArgs, runner);
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
console.error(chalk.dim(` Running: ${command}`));
|
|
443
|
+
result = deps.performUpdateString
|
|
444
|
+
? deps.performUpdateString(command)
|
|
445
|
+
: performUpdate(command, runner);
|
|
446
|
+
}
|
|
295
447
|
// A partial/corrupt global install leaves npm unable to atomically rename its
|
|
296
448
|
// freshly-unpacked package over a leftover directory, so the install above
|
|
297
449
|
// fails with ENOTEMPTY (e.g. a prior interrupted `npm install -g` left a
|
|
@@ -316,7 +468,11 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
316
468
|
// Homebrew on macOS, where the first attempt already succeeded anyway) fall
|
|
317
469
|
// through to the manual path unchanged, while headless boxes with passwordless
|
|
318
470
|
// sudo self-update cleanly.
|
|
319
|
-
|
|
471
|
+
//
|
|
472
|
+
// Never for pnpm: `sudo -n pnpm add -g` installs into ROOT's PNPM_HOME, which
|
|
473
|
+
// leaves the user's shim untouched while reporting success — the same silent
|
|
474
|
+
// no-op this fix exists to remove. A failed pnpm update must surface instead.
|
|
475
|
+
if (!result.ok && primaryCmd && !isPnpm) {
|
|
320
476
|
console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
321
477
|
const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
|
|
322
478
|
if (sudoResult.ok)
|
|
@@ -324,10 +480,21 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
324
480
|
}
|
|
325
481
|
if (!result.ok) {
|
|
326
482
|
console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
483
|
+
// The package manager itself is missing from this environment — the usual
|
|
484
|
+
// cause is a minimal-PATH parent (launchd, cron, a bare systemd unit) that
|
|
485
|
+
// never sourced the shell profile which puts PNPM_HOME (or nvm's npm) on
|
|
486
|
+
// PATH. Say so, because "exit 75" alone reads as a permissions problem.
|
|
487
|
+
if (result.code === "ENOENT") {
|
|
488
|
+
console.error(chalk.red(` \`${primaryCmd}\` was not found on PATH in this environment (launchd, cron and other minimal-PATH parents commonly lack it).`));
|
|
489
|
+
}
|
|
490
|
+
const manual = manualUpdateCommand(install, decision) ?? command;
|
|
330
491
|
console.error(chalk.dim(` Try manually: ${manual}`));
|
|
492
|
+
// Only informational, and only when we could not run the right manager at
|
|
493
|
+
// all: hq-pro's command is npm-shaped, so following it on a pnpm layout is
|
|
494
|
+
// what produced the stale-shim loop in the first place.
|
|
495
|
+
if (isPnpm && result.code === "ENOENT" && command) {
|
|
496
|
+
console.error(chalk.dim(` (hq-pro suggests \`${command}\` — that is for npm-managed installs; use it only if you have switched this install to npm.)`));
|
|
497
|
+
}
|
|
331
498
|
process.exit(75);
|
|
332
499
|
}
|
|
333
500
|
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
@@ -349,11 +516,16 @@ export async function enforceVersionGate() {
|
|
|
349
516
|
const decision = await fetchVersionDecision();
|
|
350
517
|
if (!decision)
|
|
351
518
|
return; // best-effort: silent on any failure
|
|
519
|
+
if (!decision.updateRequired && !decision.updateRecommended)
|
|
520
|
+
return;
|
|
521
|
+
// Resolved once, here, so the up-to-date case never pays for the walk and
|
|
522
|
+
// neither downstream path repeats it.
|
|
523
|
+
const install = resolveRunningInstall();
|
|
352
524
|
if (decision.updateRequired) {
|
|
353
|
-
enforceUpdateRequired(decision); // exits process
|
|
525
|
+
enforceUpdateRequired(decision, { resolveInstall: () => install }); // exits process
|
|
354
526
|
}
|
|
355
527
|
if (decision.updateRecommended) {
|
|
356
|
-
nudgeUpdateRecommended(decision);
|
|
528
|
+
nudgeUpdateRecommended(decision, install);
|
|
357
529
|
}
|
|
358
530
|
}
|
|
359
531
|
/**
|
|
@@ -368,13 +540,20 @@ export const __test__ = {
|
|
|
368
540
|
CLIENT_ID,
|
|
369
541
|
ENDPOINT_PATH,
|
|
370
542
|
FETCH_TIMEOUT_MS,
|
|
543
|
+
buildPnpmInstallArgv,
|
|
371
544
|
buildPrefixedInstallArgv,
|
|
545
|
+
buildSpawnPlan,
|
|
372
546
|
cleanStalePartialInstall,
|
|
373
547
|
enforceUpdateRequired,
|
|
548
|
+
isPnpmManagedPackageDir,
|
|
374
549
|
npmPrefixFromPackageDir,
|
|
550
|
+
nudgeUpdateRecommended,
|
|
375
551
|
performUpdate,
|
|
376
552
|
performUpdateCommand,
|
|
553
|
+
quoteForWindowsShell,
|
|
377
554
|
runUpdateCommand,
|
|
555
|
+
resolveRunningInstall,
|
|
556
|
+
resolveRunningManager,
|
|
378
557
|
resolveRunningPrefix,
|
|
379
558
|
};
|
|
380
559
|
//# sourceMappingURL=version-gate.js.map
|
package/package.json
CHANGED
|
@@ -42,7 +42,7 @@ function writeSettings(root: string, settings: unknown): void {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function makeHqRoot(settings: unknown = HEALTHY_SETTINGS): string {
|
|
45
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-reindex-"));
|
|
45
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hq-reindex-")));
|
|
46
46
|
tempRoots.push(root);
|
|
47
47
|
fs.mkdirSync(path.join(root, ".claude"), { recursive: true });
|
|
48
48
|
fs.mkdirSync(path.join(root, "companies"), { recursive: true });
|
|
@@ -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
|
+
});
|