@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 CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
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)
24
+
25
+ ### Fixed
26
+
27
+ - `hq secrets script approve` now accepts `--remote-path <path>`, so an
28
+ operator can hash a local copy while approving the byte-identical script at
29
+ its fleet or other target runtime path. Existing same-path approvals are
30
+ unchanged. (#263)
31
+
5
32
  ## [5.77.7]
6
33
 
7
34
  ### Fixed
@@ -69,7 +69,7 @@ export declare function grantGroup(options: GrantGroupOptions): Promise<GroupGra
69
69
  /** POST /group-grants/revoke — remove a group's grant on a target company. */
70
70
  export declare function revokeGroupGrant(options: RevokeGroupGrantOptions): Promise<void>;
71
71
  /** GET /group-grants/outbound — grants a source company's group(s) hold. */
72
- export declare function listOutboundGrants(token: string, sourceCompanyUid: string, groupId?: string): Promise<GroupGrant[]>;
72
+ export declare function listOutboundGrants(token: string, sourceCompanyUid: string, groupId: string): Promise<GroupGrant[]>;
73
73
  /** GET /group-grants/inbound — grants other companies' groups hold on us. */
74
74
  export declare function listInboundGrants(token: string, companyUid: string): Promise<GroupGrant[]>;
75
75
  export declare function registerGroupGrantsCommand(program: Command): void;
@@ -123,13 +123,13 @@ export async function revokeGroupGrant(options) {
123
123
  }
124
124
  /** GET /group-grants/outbound — grants a source company's group(s) hold. */
125
125
  export async function listOutboundGrants(token, sourceCompanyUid, groupId) {
126
- const query = { sourceCompanyUid };
127
- if (groupId)
128
- query.groupId = groupId;
126
+ if (!GROUP_ID_PATTERN.test(groupId)) {
127
+ throw new Error(`Invalid group id '${groupId}': must match grp_<alphanumeric, underscore, hyphen>`);
128
+ }
129
129
  const res = await vaultApiFetch({
130
130
  token,
131
131
  path: "/group-grants/outbound",
132
- query,
132
+ query: { sourceCompanyUid, groupId },
133
133
  });
134
134
  if (!res.ok) {
135
135
  const err = (await res.json().catch(() => ({})));
@@ -242,8 +242,8 @@ export function registerGroupGrantsCommand(program) {
242
242
  });
243
243
  grants
244
244
  .command("outbound")
245
- .description("List grants the source company's groups hold on other companies")
246
- .option("--group <groupId>", "Filter to a single group id")
245
+ .description("List grants a source-company group holds on other companies")
246
+ .requiredOption("--group <groupId>", "Group id to inspect")
247
247
  .action(async (opts) => {
248
248
  try {
249
249
  const token = await ensureCognitoToken();
@@ -961,6 +961,7 @@ export function registerSecretsCommand(program) {
961
961
  .description("Approve a script for a secret path")
962
962
  .requiredOption("--id <scriptId>", "Stable script identifier")
963
963
  .requiredOption("--script <path>", "Path to the local script file")
964
+ .option("--remote-path <path>", "Script path reported by the target runtime (defaults to the local path)")
964
965
  .option("--attestation <level>", "Attestation level", "self-asserted-hash")
965
966
  .action(async (secretPath, opts) => {
966
967
  try {
@@ -979,7 +980,7 @@ export function registerSecretsCommand(program) {
979
980
  body: {
980
981
  path: secretPath,
981
982
  scriptId: usage.script?.scriptId,
982
- scriptPath: usage.script?.path,
983
+ scriptPath: opts.remotePath ?? usage.script?.path,
983
984
  sha256: usage.script?.sha256,
984
985
  attestationLevel: usage.script?.attestationLevel,
985
986
  },
@@ -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
@@ -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
- resolvePrefix?: () => string | null;
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 {};