@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.
- 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 +2 -2
- package/pnpm-workspace.yaml +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
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [5.77.
|
|
5
|
+
## [5.77.9]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The CLI now surfaces a plan-limit warning when an account approaches its
|
|
10
|
+
configured limits. (#265)
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A pnpm-managed global install now self-updates with
|
|
15
|
+
`pnpm add -g @indigoai-us/hq-cli@latest` instead of an npm `--prefix`
|
|
16
|
+
install into pnpm's content store. The npm path exited 0 while the PATH shim
|
|
17
|
+
kept running the old build, so the version gate reported success and the
|
|
18
|
+
next invocation detected the same stale version — an update loop that could
|
|
19
|
+
never converge. Detection is restricted to genuinely global pnpm layouts, so
|
|
20
|
+
a local project dependency or a `pnpm dlx` cache is not mistaken for the
|
|
21
|
+
global install. A missing `pnpm`/`npm` on PATH now exits 75 with an explicit
|
|
22
|
+
message instead of failing unexplained under minimal-PATH parents such as
|
|
23
|
+
launchd or cron. (#266)
|
|
6
24
|
|
|
7
25
|
### Fixed
|
|
8
26
|
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
export declare const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
|
|
19
|
+
export interface PlanLimitEntry {
|
|
20
|
+
used: number;
|
|
21
|
+
limit: number;
|
|
22
|
+
over: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** Validated map of resource key → entry. */
|
|
25
|
+
export type PlanLimitsMap = Record<string, PlanLimitEntry>;
|
|
26
|
+
/**
|
|
27
|
+
* Record plan-limit status from a decoded JSON response body.
|
|
28
|
+
* Never throws. Overwrites the last-seen cell when a well-formed
|
|
29
|
+
* `planLimits` object is present; ignores malformed / absent payloads.
|
|
30
|
+
*/
|
|
31
|
+
export declare function recordPlanLimitStatus(body: unknown): void;
|
|
32
|
+
/**
|
|
33
|
+
* Emit a plan-limit nag to stderr at command completion.
|
|
34
|
+
*
|
|
35
|
+
* Never throws, never touches `process.exitCode`. Default sink is
|
|
36
|
+
* `process.stderr.write`. Tests inject `write`, `now`, and `statePath`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function emitPlanLimitNag(opts?: {
|
|
39
|
+
write?: (s: string) => void;
|
|
40
|
+
now?: () => Date;
|
|
41
|
+
statePath?: string;
|
|
42
|
+
}): void;
|
|
43
|
+
/** Test-only helper — clears last-seen status and session dedupe flags. */
|
|
44
|
+
export declare function _resetForTests(): void;
|
|
45
|
+
//# sourceMappingURL=plan-limit-nag.d.ts.map
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
import chalk from "chalk";
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
export const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
|
|
23
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
|
+
/** Module-level last-seen cell — overwritten by each successful parse. */
|
|
25
|
+
let lastSeen = null;
|
|
26
|
+
/** Session dedupe for the ≥80% one-line warning. */
|
|
27
|
+
let warningShownThisSession = false;
|
|
28
|
+
/** Session dedupe for the over-limit boxed notice. */
|
|
29
|
+
let overShownThisSession = false;
|
|
30
|
+
function defaultStatePath() {
|
|
31
|
+
return path.join(os.homedir(), ".hq", "plan-limit-nag.json");
|
|
32
|
+
}
|
|
33
|
+
function isOptedOut() {
|
|
34
|
+
return process.env.HQ_NO_PLAN_LIMIT_NAG === "1";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Defensively parse a single planLimits entry. Returns null if the shape is
|
|
38
|
+
* not well-formed (non-numeric used/limit, non-boolean over, etc.).
|
|
39
|
+
*/
|
|
40
|
+
function parseEntry(value) {
|
|
41
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const rec = value;
|
|
45
|
+
if (typeof rec.used !== "number" ||
|
|
46
|
+
!Number.isFinite(rec.used) ||
|
|
47
|
+
typeof rec.limit !== "number" ||
|
|
48
|
+
!Number.isFinite(rec.limit) ||
|
|
49
|
+
typeof rec.over !== "boolean") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return { used: rec.used, limit: rec.limit, over: rec.over };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Defensively parse a decoded JSON body for a well-formed top-level
|
|
56
|
+
* `planLimits` object. Malformed or absent → null. Never throws.
|
|
57
|
+
*/
|
|
58
|
+
function parsePlanLimits(body) {
|
|
59
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const planLimits = body.planLimits;
|
|
63
|
+
if (planLimits === null ||
|
|
64
|
+
typeof planLimits !== "object" ||
|
|
65
|
+
Array.isArray(planLimits)) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const out = {};
|
|
69
|
+
let anyValid = false;
|
|
70
|
+
for (const [key, value] of Object.entries(planLimits)) {
|
|
71
|
+
const entry = parseEntry(value);
|
|
72
|
+
if (entry === null)
|
|
73
|
+
continue;
|
|
74
|
+
out[key] = entry;
|
|
75
|
+
anyValid = true;
|
|
76
|
+
}
|
|
77
|
+
return anyValid ? out : null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Record plan-limit status from a decoded JSON response body.
|
|
81
|
+
* Never throws. Overwrites the last-seen cell when a well-formed
|
|
82
|
+
* `planLimits` object is present; ignores malformed / absent payloads.
|
|
83
|
+
*/
|
|
84
|
+
export function recordPlanLimitStatus(body) {
|
|
85
|
+
try {
|
|
86
|
+
const limits = parsePlanLimits(body);
|
|
87
|
+
if (limits === null)
|
|
88
|
+
return;
|
|
89
|
+
const anyOver = Object.values(limits).some((e) => e.over);
|
|
90
|
+
lastSeen = { limits, anyOver };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Never throw from record path.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function usageRatio(entry) {
|
|
97
|
+
if (entry.limit <= 0)
|
|
98
|
+
return entry.used > 0 ? Infinity : 0;
|
|
99
|
+
return entry.used / entry.limit;
|
|
100
|
+
}
|
|
101
|
+
/** Pick the resource with the highest used/limit ratio (worst headroom). */
|
|
102
|
+
function worstResource(limits) {
|
|
103
|
+
let best = null;
|
|
104
|
+
for (const [key, entry] of Object.entries(limits)) {
|
|
105
|
+
const ratio = usageRatio(entry);
|
|
106
|
+
if (best === null || ratio > best.ratio) {
|
|
107
|
+
best = { key, entry, ratio };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return best ? { key: best.key, entry: best.entry } : null;
|
|
111
|
+
}
|
|
112
|
+
function formatPct(entry) {
|
|
113
|
+
if (entry.limit <= 0)
|
|
114
|
+
return entry.used > 0 ? "∞" : "0%";
|
|
115
|
+
return `${Math.round((entry.used / entry.limit) * 100)}%`;
|
|
116
|
+
}
|
|
117
|
+
function formatEntryLine(key, entry) {
|
|
118
|
+
return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})`;
|
|
119
|
+
}
|
|
120
|
+
function readShownAt(statePath) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = fs.readFileSync(statePath, "utf-8");
|
|
123
|
+
const parsed = JSON.parse(raw);
|
|
124
|
+
if (typeof parsed.shownAt !== "number" || !Number.isFinite(parsed.shownAt)) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return parsed.shownAt;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function writeShownAt(statePath, shownAt) {
|
|
134
|
+
try {
|
|
135
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
136
|
+
const payload = { shownAt };
|
|
137
|
+
fs.writeFileSync(statePath, JSON.stringify(payload));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// best-effort; never break the CLI on cache write failure
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function withinDayWindow(shownAt, nowMs) {
|
|
144
|
+
return nowMs - shownAt < DAY_MS;
|
|
145
|
+
}
|
|
146
|
+
function buildOverBox(overEntries) {
|
|
147
|
+
const title = "⚠ HQ plan limit exceeded";
|
|
148
|
+
const upgrade = `Upgrade: ${PLAN_LIMIT_UPGRADE_URL}`;
|
|
149
|
+
const resourceLines = overEntries.map(([key, entry]) => ` ${key}: ${entry.used}/${entry.limit}`);
|
|
150
|
+
const contentLines = [title, "", ...resourceLines, "", upgrade];
|
|
151
|
+
const innerWidth = Math.max(...contentLines.map((l) => l.length), 40);
|
|
152
|
+
const top = `┌${"─".repeat(innerWidth + 2)}┐`;
|
|
153
|
+
const bot = `└${"─".repeat(innerWidth + 2)}┘`;
|
|
154
|
+
const mid = contentLines
|
|
155
|
+
.map((l) => `│ ${l.padEnd(innerWidth)} │`)
|
|
156
|
+
.join("\n");
|
|
157
|
+
return `${top}\n${mid}\n${bot}`;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Emit a plan-limit nag to stderr at command completion.
|
|
161
|
+
*
|
|
162
|
+
* Never throws, never touches `process.exitCode`. Default sink is
|
|
163
|
+
* `process.stderr.write`. Tests inject `write`, `now`, and `statePath`.
|
|
164
|
+
*/
|
|
165
|
+
export function emitPlanLimitNag(opts = {}) {
|
|
166
|
+
try {
|
|
167
|
+
if (isOptedOut())
|
|
168
|
+
return;
|
|
169
|
+
if (lastSeen === null)
|
|
170
|
+
return;
|
|
171
|
+
const write = opts.write ?? ((s) => process.stderr.write(s));
|
|
172
|
+
const now = opts.now ?? (() => new Date());
|
|
173
|
+
const statePath = opts.statePath ?? defaultStatePath();
|
|
174
|
+
const { limits, anyOver } = lastSeen;
|
|
175
|
+
const entries = Object.entries(limits);
|
|
176
|
+
if (entries.length === 0)
|
|
177
|
+
return;
|
|
178
|
+
if (anyOver) {
|
|
179
|
+
if (overShownThisSession)
|
|
180
|
+
return;
|
|
181
|
+
const nowMs = now().getTime();
|
|
182
|
+
const prev = readShownAt(statePath);
|
|
183
|
+
if (prev !== null && withinDayWindow(prev, nowMs))
|
|
184
|
+
return;
|
|
185
|
+
overShownThisSession = true;
|
|
186
|
+
const overEntries = entries.filter(([, e]) => e.over);
|
|
187
|
+
const box = buildOverBox(overEntries);
|
|
188
|
+
write(chalk.yellow(box) + "\n");
|
|
189
|
+
writeShownAt(statePath, nowMs);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// Warning path: entries present, none over (≥80% resources only appear).
|
|
193
|
+
if (warningShownThisSession)
|
|
194
|
+
return;
|
|
195
|
+
const worst = worstResource(limits);
|
|
196
|
+
if (worst === null)
|
|
197
|
+
return;
|
|
198
|
+
warningShownThisSession = true;
|
|
199
|
+
const line = `⚠ HQ free plan: ${formatEntryLine(worst.key, worst.entry)}. Upgrade: ${PLAN_LIMIT_UPGRADE_URL}`;
|
|
200
|
+
write(chalk.yellow(line) + "\n");
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Never throw from emit path.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Test-only helper — clears last-seen status and session dedupe flags. */
|
|
207
|
+
export function _resetForTests() {
|
|
208
|
+
lastSeen = null;
|
|
209
|
+
warningShownThisSession = false;
|
|
210
|
+
overShownThisSession = false;
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=plan-limit-nag.js.map
|
package/dist/main.js
CHANGED
|
@@ -68,6 +68,7 @@ import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
|
68
68
|
import { CLI_VERSION } from "./cli-version.js";
|
|
69
69
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
70
70
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
71
|
+
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
71
72
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
72
73
|
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
73
74
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
@@ -307,6 +308,9 @@ export async function runCli() {
|
|
|
307
308
|
}
|
|
308
309
|
}
|
|
309
310
|
finally {
|
|
311
|
+
// Plan-limit nag (US-016): stderr-only, never throws, never touches
|
|
312
|
+
// process.exitCode — safe to run after exit codes have been set.
|
|
313
|
+
emitPlanLimitNag();
|
|
310
314
|
// Release health: finalize the per-run session before the flush.
|
|
311
315
|
Sentry.endSession();
|
|
312
316
|
// Neither task may turn a successful command into Node's
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -2,6 +2,66 @@ 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';
|
|
6
|
+
/**
|
|
7
|
+
* Best-effort peek of a 2xx JSON body for plan-limit status (US-016).
|
|
8
|
+
*
|
|
9
|
+
* For ok JSON responses: read the body ONCE, record planLimits (best-effort),
|
|
10
|
+
* and return a NEW Response built from the buffered body so callers can still
|
|
11
|
+
* call .json()/.text()/.arrayBuffer(). Non-JSON and non-ok responses are
|
|
12
|
+
* returned untouched (streamed binary downloads must never be buffered).
|
|
13
|
+
*
|
|
14
|
+
* Never throws. On any error: return the original response if its body has
|
|
15
|
+
* not been consumed, otherwise the re-wrapped one.
|
|
16
|
+
*
|
|
17
|
+
* Why not response.clone()? Cloning tees the undici body stream; when a
|
|
18
|
+
* caller never consumes the original Response body (many commands only check
|
|
19
|
+
* response.ok/status), the unused tee branch keeps the connection/handle
|
|
20
|
+
* referenced and the process (or vitest worker / spawned CLI child) never
|
|
21
|
+
* exits on Linux CI. Reading once + re-wrapping fully drains the stream.
|
|
22
|
+
*/
|
|
23
|
+
async function peekPlanLimitStatus(response) {
|
|
24
|
+
try {
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
return response;
|
|
27
|
+
const ct = response.headers.get('content-type') ?? '';
|
|
28
|
+
if (!ct.includes('application/json') && !ct.includes('+json')) {
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
let buf;
|
|
32
|
+
try {
|
|
33
|
+
buf = await response.arrayBuffer();
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Body may be locked/errored; original is the only thing we can return.
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const text = new TextDecoder().decode(buf);
|
|
41
|
+
try {
|
|
42
|
+
recordPlanLimitStatus(JSON.parse(text));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Malformed JSON must be ignored silently.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Best-effort record only — still re-wrap so body is readable.
|
|
50
|
+
}
|
|
51
|
+
// Body was consumed; always return a re-wrapped Response so callers can
|
|
52
|
+
// still read it (even if parse/record failed).
|
|
53
|
+
return new Response(buf, {
|
|
54
|
+
status: response.status,
|
|
55
|
+
statusText: response.statusText,
|
|
56
|
+
headers: response.headers,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Outer safety net: never throw from the peek path. If we never consumed
|
|
61
|
+
// the body, the original is still usable.
|
|
62
|
+
return response;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
5
65
|
export async function vaultApiFetch(opts) {
|
|
6
66
|
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
7
67
|
if (opts.query) {
|
|
@@ -33,8 +93,9 @@ export async function vaultApiFetch(opts) {
|
|
|
33
93
|
level: "warning",
|
|
34
94
|
data: { url: safeUrl, status: response.status },
|
|
35
95
|
});
|
|
96
|
+
return response;
|
|
36
97
|
}
|
|
37
|
-
return response;
|
|
98
|
+
return peekPlanLimitStatus(response);
|
|
38
99
|
}
|
|
39
100
|
/**
|
|
40
101
|
* Public (NONE-auth) GET against the vault API — no bearer token. The
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
|
|
28
28
|
* to silence both check + gate).
|
|
29
29
|
*/
|
|
30
|
+
/** Which package manager owns the running global install. */
|
|
31
|
+
export type InstallManager = "npm" | "pnpm";
|
|
30
32
|
interface VersionCheckResponse {
|
|
31
33
|
clientId: string;
|
|
32
34
|
currentVersion: string;
|
|
@@ -39,8 +41,65 @@ interface VersionCheckResponse {
|
|
|
39
41
|
message?: string;
|
|
40
42
|
}
|
|
41
43
|
export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Whether the running package lives inside a pnpm-managed **global** install.
|
|
46
|
+
*
|
|
47
|
+
* pnpm does not use npm's `<prefix>/lib/node_modules` layout. A global
|
|
48
|
+
* `pnpm add -g` puts the package in a versioned content store under the
|
|
49
|
+
* `global/<store-layout-version>` root and exposes it through a generated shim
|
|
50
|
+
* on PATH:
|
|
51
|
+
*
|
|
52
|
+
* $PNPM_HOME/hq <- shim on PATH
|
|
53
|
+
* $PNPM_HOME/global/5/node_modules/@indigoai-us/hq-cli <- symlink
|
|
54
|
+
* $PNPM_HOME/global/5/.pnpm/@indigoai-us+hq-cli@5.61.0/node_modules/…
|
|
55
|
+
*
|
|
56
|
+
* Both the symlinked and the resolved (`.pnpm`) form are recognised, because
|
|
57
|
+
* whether `import.meta.url` reports the link or its target depends on how node
|
|
58
|
+
* resolved the entrypoint.
|
|
59
|
+
*
|
|
60
|
+
* A bare `.pnpm` segment is deliberately NOT enough. It also appears in a local
|
|
61
|
+
* project dependency (`<proj>/node_modules/.pnpm/@indigoai-us+hq-cli@…`) and in
|
|
62
|
+
* a `pnpm dlx` cache. Neither of those is what `pnpm add -g` updates, so
|
|
63
|
+
* treating them as global would mutate the user's global install as a side
|
|
64
|
+
* effect of a local invocation while the copy actually running stayed stale —
|
|
65
|
+
* i.e. the gate would re-fire and re-install globally on every subsequent run.
|
|
66
|
+
* Those layouts fall through to the ordinary npm-prefix/manual handling.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Where the running CLI is installed and who owns it. Resolved in ONE pass so
|
|
71
|
+
* the package-root walk (which reads and parses a `package.json` per directory
|
|
72
|
+
* level) happens once per invocation, and so the manager and the prefix can
|
|
73
|
+
* never disagree because the filesystem shifted between two separate walks.
|
|
74
|
+
*
|
|
75
|
+
* `prefix` is `null` for a pnpm-managed install even though a prefix-shaped
|
|
76
|
+
* string *can* be derived from those paths: `npmPrefixFromPackageDir` would
|
|
77
|
+
* happily hand back the pnpm store directory, and `npm install -g --prefix
|
|
78
|
+
* <store>` then unpacks a fresh copy into `<store>/lib/node_modules`, which
|
|
79
|
+
* pnpm's shim never reads. npm exits 0, the gate reports success, and the next
|
|
80
|
+
* `hq` invocation still runs the old version — the reported loop where a stale
|
|
81
|
+
* pnpm shim kept resolving 5.61.0 after every "successful" update.
|
|
82
|
+
*
|
|
83
|
+
* Defaults to npm with no prefix when the layout can't be determined — that is
|
|
84
|
+
* the historical behaviour and the common case.
|
|
85
|
+
*/
|
|
86
|
+
export interface RunningInstall {
|
|
87
|
+
manager: InstallManager;
|
|
88
|
+
prefix: string | null;
|
|
89
|
+
packageRoot: string | null;
|
|
90
|
+
}
|
|
91
|
+
export declare function resolveRunningInstall(): RunningInstall;
|
|
92
|
+
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
93
|
+
export declare function resolveRunningManager(): InstallManager;
|
|
94
|
+
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
42
95
|
export declare function resolveRunningPrefix(): string | null;
|
|
43
96
|
export declare function buildPrefixedInstallArgv(prefix: string): string[];
|
|
97
|
+
/**
|
|
98
|
+
* Argv for updating a pnpm-managed global install. `pnpm add -g` rewrites the
|
|
99
|
+
* PATH shim as part of the install, so the next invocation genuinely resolves
|
|
100
|
+
* the new version — which is the whole point of routing here instead of npm.
|
|
101
|
+
*/
|
|
102
|
+
export declare function buildPnpmInstallArgv(): string[];
|
|
44
103
|
/**
|
|
45
104
|
* Filesystem surface used by {@link cleanStalePartialInstall}. Injected so the
|
|
46
105
|
* cleanup logic is unit-testable without touching a real global prefix.
|
|
@@ -84,11 +143,41 @@ export declare function cleanStalePartialInstall(prefix: string, fs?: StaleInsta
|
|
|
84
143
|
type UpdateResult = {
|
|
85
144
|
ok: boolean;
|
|
86
145
|
detail?: string;
|
|
146
|
+
code?: string;
|
|
87
147
|
};
|
|
88
148
|
type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
|
|
149
|
+
/**
|
|
150
|
+
* Quote an argv entry for a Windows `cmd.exe` invocation. Needed because Node
|
|
151
|
+
* does NOT quote argv when spawning with `shell: true` on Windows — it joins
|
|
152
|
+
* the array with spaces — so an npm prefix like `C:\Program Files\…` would be
|
|
153
|
+
* split into two arguments.
|
|
154
|
+
*/
|
|
155
|
+
export declare function quoteForWindowsShell(arg: string): string;
|
|
156
|
+
/**
|
|
157
|
+
* How to hand `<cmd> <args…>` to `spawnSync` on this platform.
|
|
158
|
+
*
|
|
159
|
+
* On Windows both `npm` and `pnpm` are `.cmd` shims, and since the
|
|
160
|
+
* CVE-2024-27980 hardening Node refuses to spawn a `.cmd`/`.bat` file without
|
|
161
|
+
* a shell. Without this the update would fail with EINVAL/ENOENT on every
|
|
162
|
+
* Windows install — including the pnpm layouts this gate claims to detect.
|
|
163
|
+
*/
|
|
164
|
+
export declare function buildSpawnPlan(cmd: string, args: readonly string[], platform?: NodeJS.Platform): {
|
|
165
|
+
cmd: string;
|
|
166
|
+
args: string[];
|
|
167
|
+
shell: boolean;
|
|
168
|
+
};
|
|
89
169
|
declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
|
|
90
170
|
declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner): UpdateResult;
|
|
91
171
|
declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
|
|
172
|
+
/**
|
|
173
|
+
* Soft notify when the server says we're below `latestVersion` but still ≥
|
|
174
|
+
* `minVersion`. Single chalk-yellow line on stderr; never blocks.
|
|
175
|
+
*
|
|
176
|
+
* This path fires for EVERY version below latest (the hard gate only fires
|
|
177
|
+
* below `minVersion`), so it is the far more frequently seen of the two and
|
|
178
|
+
* must be manager-aware for the same reason the gate is.
|
|
179
|
+
*/
|
|
180
|
+
declare function nudgeUpdateRecommended(decision: VersionCheckResponse, install?: RunningInstall): void;
|
|
92
181
|
/**
|
|
93
182
|
* Hard enforcement when the server says we're below `minVersion`. Print a
|
|
94
183
|
* red banner, attempt the update, then exit so the user reruns against the
|
|
@@ -101,7 +190,7 @@ declare function performUpdate(command: string, runner?: UpdateRunner): UpdateRe
|
|
|
101
190
|
*/
|
|
102
191
|
declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: {
|
|
103
192
|
performUpdateString?: (command: string) => UpdateResult;
|
|
104
|
-
|
|
193
|
+
resolveInstall?: () => RunningInstall;
|
|
105
194
|
runner?: UpdateRunner;
|
|
106
195
|
cleanStale?: (prefix: string) => string[];
|
|
107
196
|
}): never;
|
|
@@ -126,13 +215,20 @@ export declare const __test__: {
|
|
|
126
215
|
CLIENT_ID: string;
|
|
127
216
|
ENDPOINT_PATH: string;
|
|
128
217
|
FETCH_TIMEOUT_MS: number;
|
|
218
|
+
buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
|
|
129
219
|
buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
|
|
220
|
+
buildSpawnPlan: typeof buildSpawnPlan;
|
|
130
221
|
cleanStalePartialInstall: typeof cleanStalePartialInstall;
|
|
131
222
|
enforceUpdateRequired: typeof enforceUpdateRequired;
|
|
223
|
+
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
132
224
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
225
|
+
nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
|
|
133
226
|
performUpdate: typeof performUpdate;
|
|
134
227
|
performUpdateCommand: typeof performUpdateCommand;
|
|
228
|
+
quoteForWindowsShell: typeof quoteForWindowsShell;
|
|
135
229
|
runUpdateCommand: typeof runUpdateCommand;
|
|
230
|
+
resolveRunningInstall: typeof resolveRunningInstall;
|
|
231
|
+
resolveRunningManager: typeof resolveRunningManager;
|
|
136
232
|
resolveRunningPrefix: typeof resolveRunningPrefix;
|
|
137
233
|
};
|
|
138
234
|
export {};
|