@indigoai-us/hq-cli 5.106.1 → 5.106.2

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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.106.2] — 2026-09-02
6
+
7
+ ### Fixed
8
+
9
+ - An `hq` installed as a local project dependency no longer updates itself in an
10
+ endless loop. The updater used to treat a pnpm virtual store
11
+ (`node_modules/.pnpm/...`) as an npm install prefix and install there — a
12
+ directory nothing on PATH ever reads — then report success, so the next
13
+ command found the same old build and updated again. On one outpost this ran
14
+ `npm install -g` up to 31 times an hour for days. Such copies are now
15
+ identified as local, reported with the path to fix, and left for their owning
16
+ project to update.
17
+ - The updater refuses an "update" to a version that is not newer than the one
18
+ already installed, instead of reinstalling it on every invocation.
19
+ - Success is now reported only after verifying the new version is the one PATH
20
+ actually resolves. An install that lands somewhere the running `hq` never
21
+ reads is reported as a failed update rather than as a success.
22
+
5
23
  ## [5.106.1] — 2026-09-02
6
24
 
7
25
  ## [5.106.0] — 2026-09-02
@@ -33,6 +33,7 @@ async function emitCachedCliSessionStarted() {
33
33
  app: "hq-cli",
34
34
  source: "cli",
35
35
  occurredAt: new Date().toISOString(),
36
+ consentBasis: "no-consent",
36
37
  schemaVersion: 1,
37
38
  properties: { version: CLI_VERSION },
38
39
  },
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
60
60
  import semver from "semver";
61
61
  import chalk from "chalk";
62
62
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
63
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
63
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
64
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
65
  /**
66
66
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
@@ -222,11 +222,20 @@ async function updateAndReexec(argv, flavor, known, deps) {
222
222
  if (!interactive && flavor.onlyWhenAttended) {
223
223
  return { action: "deferred", latest };
224
224
  }
225
+ // A local copy (a project dependency, a `pnpm dlx` cache) has no self-update
226
+ // path: every install argv here targets a GLOBAL install, so the copy that is
227
+ // actually running would stay exactly as stale as it started while the CLI
228
+ // reported success and re-exec'd into the same build. That is the shape of
229
+ // the 2026-09-02 gate loop — see isLocalDependencyInstall in version-gate.ts.
230
+ const install = (deps.resolveInstall ?? resolveRunningInstall)();
231
+ if (isLocalDependencyInstall(install)) {
232
+ console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
233
+ return { action: "skipped", latest };
234
+ }
225
235
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
226
236
  if (!releaseLock)
227
237
  return { action: "skipped", latest };
228
238
  let result;
229
- const install = (deps.resolveInstall ?? resolveRunningInstall)();
230
239
  const plan = buildSelfUpdatePlan(install);
231
240
  // A pnpm global install needs PNPM_HOME to find its global bin dir. A
232
241
  // minimal-environment parent (systemd, cron, non-login shell) lacks it and
@@ -70,6 +70,59 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
70
70
  export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
71
71
  /** Whether the running package lives inside Bun's global install tree. */
72
72
  export declare function isBunManagedPackageDir(pkgDir: string): boolean;
73
+ /**
74
+ * Whether the package dir sits inside a pnpm **virtual store** — the adjacent
75
+ * `node_modules/.pnpm` segment pair pnpm creates for every install, global or
76
+ * not:
77
+ *
78
+ * <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
79
+ *
80
+ * A virtual store is a content-addressed cache keyed by the EXACT version in
81
+ * the directory name. Nothing on PATH ever resolves through
82
+ * `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
83
+ * one thing that must never happen is treating it as one.
84
+ *
85
+ * Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
86
+ * answers a different question ("is this the copy `pnpm add -g` updates?") and
87
+ * is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
88
+ * decides anything, a pnpm *global* store has already been classified.
89
+ */
90
+ export declare function isPnpmVirtualStorePackageDir(pkgDir: string): boolean;
91
+ /**
92
+ * Whether the running CLI is a **local** copy — a project dependency or a
93
+ * `pnpm dlx` cache — rather than a global install this process may replace.
94
+ *
95
+ * This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
96
+ * `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
97
+ * sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
98
+ * it. The gate saw a build below `minVersion`, derived an npm prefix from the
99
+ * store path, ran
100
+ *
101
+ * npm install -g --prefix <store> @indigoai-us/hq-cli@latest
102
+ *
103
+ * which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
104
+ * pnpm's shim never reads — then reported success. The next invocation resolved
105
+ * the same 5.69.0 shim and did it all again, ~25 times an hour forever.
106
+ *
107
+ * A local copy has no self-update path at all: replacing it is its owning
108
+ * project's job, and installing globally would leave the copy that is actually
109
+ * running untouched. So the gate must say so and stop, not install.
110
+ */
111
+ export declare function isLocalDependencyPackageDir(pkgDir: string, platform?: NodeJS.Platform): boolean;
112
+ export declare function isLocalDependencyInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
113
+ /**
114
+ * Whether an update to `target` would actually move the install forward.
115
+ *
116
+ * Loop protection, independent of the layout bug above: an "update" to a
117
+ * version that is not strictly newer than what is running can never converge,
118
+ * so however the target was resolved — a stale dist-tag, a bad cache, a
119
+ * misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
120
+ * every invocation and announce success each time.
121
+ *
122
+ * Unparseable versions return true: this guard exists to stop a provable
123
+ * no-op, not to become a new way for the gate to refuse to work.
124
+ */
125
+ export declare function isNewerVersion(target: string, current: string): boolean;
73
126
  /**
74
127
  * Where the running CLI is installed and who owns it. Resolved in ONE pass so
75
128
  * the package-root walk (which reads and parses a `package.json` per directory
@@ -278,7 +331,7 @@ export declare function probeCliVersion(bin: string): string | null;
278
331
  export declare function checkUpdateConvergence(targetVersion: string, deps?: {
279
332
  resolveBin?: () => string | null;
280
333
  probeVersion?: (bin: string) => string | null;
281
- }): void;
334
+ }): boolean;
282
335
  /** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
283
336
  interface EnforceUpdateDeps {
284
337
  performUpdateString?: (command: string) => UpdateResult;
@@ -286,7 +339,12 @@ interface EnforceUpdateDeps {
286
339
  runner?: UpdateRunner;
287
340
  cleanStale?: (prefix: string) => string[];
288
341
  acquireLock?: () => UpdateLockHandle | null;
289
- checkConvergence?: (targetVersion: string) => void;
342
+ /**
343
+ * `false` means the probe *disproved* convergence. `void`/`undefined` keeps
344
+ * the historical "unverified is fine" behaviour, so an injected stub that
345
+ * returns nothing still exercises the success path.
346
+ */
347
+ checkConvergence?: (targetVersion: string) => boolean | void;
290
348
  }
291
349
  /**
292
350
  * Hard enforcement when the server says we're below `minVersion`. Print a
@@ -346,7 +404,11 @@ export declare const __test__: {
346
404
  enforceUpdateRequired: typeof enforceUpdateRequired;
347
405
  isBunManagedPackageDir: typeof isBunManagedPackageDir;
348
406
  pnpmUpdateEnv: typeof pnpmUpdateEnv;
407
+ isLocalDependencyInstall: typeof isLocalDependencyInstall;
408
+ isLocalDependencyPackageDir: typeof isLocalDependencyPackageDir;
409
+ isNewerVersion: typeof isNewerVersion;
349
410
  isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
411
+ isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
350
412
  npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
351
413
  nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
352
414
  performUpdate: typeof performUpdate;
@@ -34,6 +34,7 @@ import os from "node:os";
34
34
  import path from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import chalk from "chalk";
37
+ import semver from "semver";
37
38
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
38
39
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
39
40
  import { acquireUpdateLock, } from "./update-lock.js";
@@ -140,6 +141,106 @@ export function isBunManagedPackageDir(pkgDir) {
140
141
  }
141
142
  return false;
142
143
  }
144
+ /**
145
+ * Whether the package dir sits inside a pnpm **virtual store** — the adjacent
146
+ * `node_modules/.pnpm` segment pair pnpm creates for every install, global or
147
+ * not:
148
+ *
149
+ * <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
150
+ *
151
+ * A virtual store is a content-addressed cache keyed by the EXACT version in
152
+ * the directory name. Nothing on PATH ever resolves through
153
+ * `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
154
+ * one thing that must never happen is treating it as one.
155
+ *
156
+ * Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
157
+ * answers a different question ("is this the copy `pnpm add -g` updates?") and
158
+ * is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
159
+ * decides anything, a pnpm *global* store has already been classified.
160
+ */
161
+ export function isPnpmVirtualStorePackageDir(pkgDir) {
162
+ const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
163
+ const segments = normalized.split("/").filter(Boolean);
164
+ for (let i = 0; i < segments.length - 1; i += 1) {
165
+ if (segments[i] === "node_modules" && segments[i + 1] === ".pnpm")
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+ /**
171
+ * Whether the running CLI is a **local** copy — a project dependency or a
172
+ * `pnpm dlx` cache — rather than a global install this process may replace.
173
+ *
174
+ * This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
175
+ * `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
176
+ * sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
177
+ * it. The gate saw a build below `minVersion`, derived an npm prefix from the
178
+ * store path, ran
179
+ *
180
+ * npm install -g --prefix <store> @indigoai-us/hq-cli@latest
181
+ *
182
+ * which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
183
+ * pnpm's shim never reads — then reported success. The next invocation resolved
184
+ * the same 5.69.0 shim and did it all again, ~25 times an hour forever.
185
+ *
186
+ * A local copy has no self-update path at all: replacing it is its owning
187
+ * project's job, and installing globally would leave the copy that is actually
188
+ * running untouched. So the gate must say so and stop, not install.
189
+ */
190
+ export function isLocalDependencyPackageDir(pkgDir, platform = process.platform) {
191
+ // A pnpm virtual store is local by construction on every platform. (A pnpm
192
+ // *global* store is classified earlier, by isPnpmManagedPackageDir.)
193
+ if (isPnpmVirtualStorePackageDir(pkgDir))
194
+ return true;
195
+ // On every non-Windows platform npm's global root is ALWAYS
196
+ // `<prefix>/lib/node_modules` — that is what `npm root -g` reports for
197
+ // /usr/local, Homebrew, nvm and a user-level `--prefix` alike. So a package
198
+ // under a `node_modules` with no `lib` parent is not a global install: it is
199
+ // a project dependency (`<proj>/node_modules/@scope/pkg`) or an npx cache
200
+ // (`~/.npm/_npx/<hash>/node_modules/@scope/pkg`). Both reproduce the same
201
+ // loop as the pnpm store — `npm install -g --prefix <dir>` writes
202
+ // `<dir>/lib/node_modules` while the `.bin` shim keeps resolving
203
+ // `<dir>/node_modules`.
204
+ //
205
+ // Windows is the exception and must keep the old behaviour: its global
206
+ // layout is `<prefix>\node_modules` with no `lib` segment, so the same test
207
+ // would misread a genuine global install as local.
208
+ if (platform === "win32")
209
+ return false;
210
+ const segments = pkgDir
211
+ .replace(/\\/g, "/")
212
+ .replace(/\/+$/, "")
213
+ .split("/")
214
+ .filter(Boolean);
215
+ const nodeModulesIndex = segments.lastIndexOf("node_modules");
216
+ if (nodeModulesIndex <= 0)
217
+ return false;
218
+ return segments[nodeModulesIndex - 1] !== "lib";
219
+ }
220
+ export function isLocalDependencyInstall(install, platform = process.platform) {
221
+ return (install.manager === "npm" &&
222
+ install.packageRoot !== null &&
223
+ isLocalDependencyPackageDir(install.packageRoot, platform));
224
+ }
225
+ /**
226
+ * Whether an update to `target` would actually move the install forward.
227
+ *
228
+ * Loop protection, independent of the layout bug above: an "update" to a
229
+ * version that is not strictly newer than what is running can never converge,
230
+ * so however the target was resolved — a stale dist-tag, a bad cache, a
231
+ * misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
232
+ * every invocation and announce success each time.
233
+ *
234
+ * Unparseable versions return true: this guard exists to stop a provable
235
+ * no-op, not to become a new way for the gate to refuse to work.
236
+ */
237
+ export function isNewerVersion(target, current) {
238
+ const t = semver.valid(target);
239
+ const c = semver.valid(current);
240
+ if (!t || !c)
241
+ return true;
242
+ return semver.gt(t, c);
243
+ }
143
244
  export function resolveRunningInstall() {
144
245
  try {
145
246
  const packageRoot = findRunningPackageRoot();
@@ -151,6 +252,13 @@ export function resolveRunningInstall() {
151
252
  if (isBunManagedPackageDir(packageRoot)) {
152
253
  return { manager: "bun", prefix: null, packageRoot };
153
254
  }
255
+ // A local copy — project dependency, pnpm virtual store, or npx cache.
256
+ // `npmPrefixFromPackageDir` would hand back the project/cache directory and
257
+ // `npm install -g --prefix <dir>` then writes where nothing loads from —
258
+ // see isLocalDependencyPackageDir. There is no npm prefix to report here.
259
+ if (isLocalDependencyPackageDir(packageRoot)) {
260
+ return { manager: "npm", prefix: null, packageRoot };
261
+ }
154
262
  return {
155
263
  manager: "npm",
156
264
  prefix: npmPrefixFromPackageDir(packageRoot),
@@ -506,6 +614,14 @@ function manualUpdateCommand(install, decision) {
506
614
  function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
507
615
  const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
508
616
  console.error(msg);
617
+ // A local copy is not updated by any global command. Printing the server's
618
+ // npm-shaped one would send the user to install a second copy that this
619
+ // invocation still would not use — the same wrong advice the pnpm-global
620
+ // carve-out exists to avoid.
621
+ if (isLocalDependencyInstall(install)) {
622
+ console.error(chalk.dim(` This copy is a local dependency (${install.packageRoot}); update the project that owns it, or invoke the global \`hq\`.`));
623
+ return;
624
+ }
509
625
  const command = install.manager === "pnpm"
510
626
  ? `pnpm ${buildPnpmInstallArgv().join(" ")}`
511
627
  : install.manager === "bun"
@@ -584,21 +700,41 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
584
700
  const bin = (deps.resolveBin ?? resolveHqOnPath)();
585
701
  if (!bin) {
586
702
  console.error(chalk.yellow("⚠ Updated, but couldn't resolve `hq` on PATH to verify the new version took effect."));
587
- return;
703
+ // Unverifiable, not disproven — the caller keeps its success report.
704
+ return true;
588
705
  }
589
706
  const reported = (deps.probeVersion ?? probeCliVersion)(bin);
590
707
  if (!reported) {
591
708
  console.error(chalk.yellow(`⚠ Updated, but \`${bin} --version\` did not respond — couldn't verify the new version took effect.`));
592
- return;
709
+ return true; // unverifiable, see above
593
710
  }
594
711
  if (reported === targetVersion)
595
- return; // converged — the normal case
712
+ return true; // converged — the normal case
596
713
  console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
597
714
  `a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
598
715
  "or the updater will loop forever."));
716
+ // The warning above fires for ANY mismatch — something other than the copy
717
+ // we just wrote is winning PATH resolution, which is worth saying either
718
+ // way. But only a STALE result disproves convergence.
719
+ //
720
+ // The install runs `@latest`, not `@<targetVersion>`, so it can legitimately
721
+ // land a version NEWER than the `latestVersion` the gate was handed: the npm
722
+ // dist-tag moves between the version-check response and the install, or the
723
+ // service's value was briefly behind. That is a successful upgrade. Failing
724
+ // it would exit 75 and reinstall on the next invocation — the very loop this
725
+ // guard exists to stop.
726
+ const reportedSemver = semver.valid(reported);
727
+ const targetSemver = semver.valid(targetVersion);
728
+ if (reportedSemver &&
729
+ targetSemver &&
730
+ semver.gte(reportedSemver, targetSemver)) {
731
+ return true;
732
+ }
733
+ return false;
599
734
  }
600
735
  catch {
601
736
  // Verification is best-effort; never break the CLI over a probe.
737
+ return true;
602
738
  }
603
739
  }
604
740
  /**
@@ -629,6 +765,26 @@ function enforceUpdateRequired(decision, deps = {}) {
629
765
  // `resolveRunningInstall`
630
766
  // already reports `prefix: null` there and neither is consulted below.
631
767
  const install = (deps.resolveInstall ?? resolveRunningInstall)();
768
+ // Loop protection #1 — nothing here can update a copy this process does not
769
+ // own. Installing globally would leave the local copy that is actually
770
+ // running stale, so the gate would fire again on the very next invocation.
771
+ // Say what to fix and stop; do NOT spend an install.
772
+ if (isLocalDependencyInstall(install)) {
773
+ console.error(chalk.red(` This copy is a local dependency, not a global install: ${install.packageRoot}`));
774
+ console.error(chalk.dim(" Self-update cannot replace it — an install would land where this copy is never loaded from, and the gate would fire again on the next run."));
775
+ console.error(chalk.dim(` Update the project that owns it (e.g. \`pnpm update ${CLI_NAME}\` in its root, or remove the stale dependency), or invoke the global \`hq\` instead.`));
776
+ process.exit(75);
777
+ }
778
+ // Loop protection #2 — an "update" to a version that is not strictly newer
779
+ // than what is running can never converge. Whatever produced the target (a
780
+ // stale dist-tag, a bad cache, a misconfigured pin), reinstalling it on every
781
+ // invocation and announcing success each time is strictly worse than saying
782
+ // so once.
783
+ if (!isNewerVersion(decision.latestVersion, decision.currentVersion)) {
784
+ console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the installed ${decision.currentVersion}.`));
785
+ console.error(chalk.dim(" This is a server-side or registry problem, not a local one — reinstalling would loop without ever converging."));
786
+ process.exit(75);
787
+ }
632
788
  const isManagedOutsideNpm = install.manager !== "npm";
633
789
  const prefix = install.prefix;
634
790
  if (!isManagedOutsideNpm && !command && !prefix) {
@@ -768,11 +924,17 @@ function attemptRequiredUpdate(decision, deps, install) {
768
924
  }
769
925
  return 75;
770
926
  }
771
- console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
772
927
  // Read-your-writes: a "successful" install into the npm prefix does not
773
- // prove the user's PATH resolves it. Warn (once, no retry) when a ghost
774
- // install is shadowing the copy we just wrote see checkUpdateConvergence.
775
- (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
928
+ // prove the user's PATH resolves it. Verify BEFORE announcing, so a ghost
929
+ // install shadowing the copy we just wrote is reported as the failed update
930
+ // it is rather than as a success the caller will keep retrying — see
931
+ // checkUpdateConvergence.
932
+ const converged = (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
933
+ if (converged === false) {
934
+ console.error(chalk.red(`✗ Update did not take effect: \`hq\` on PATH still resolves a different build than ${decision.latestVersion}.`));
935
+ return 75;
936
+ }
937
+ console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
776
938
  return 0;
777
939
  }
778
940
  export async function enforceVersionGate(onUpdateRecommended) {
@@ -829,7 +991,11 @@ export const __test__ = {
829
991
  enforceUpdateRequired,
830
992
  isBunManagedPackageDir,
831
993
  pnpmUpdateEnv,
994
+ isLocalDependencyInstall,
995
+ isLocalDependencyPackageDir,
996
+ isNewerVersion,
832
997
  isPnpmManagedPackageDir,
998
+ isPnpmVirtualStorePackageDir,
833
999
  npmPrefixFromPackageDir,
834
1000
  nudgeUpdateRecommended,
835
1001
  performUpdate,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.106.1",
3
+ "version": "5.106.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {