@indigoai-us/hq-cli 5.103.11 → 5.103.12

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,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.12] — 2026-08-20
6
+
5
7
  ## [5.103.11] — 2026-08-20
6
8
 
7
9
  ### Reverted
@@ -104,15 +104,18 @@ export declare function buildSelfUpdatePlan(install: RunningInstall): {
104
104
  */
105
105
  export declare function runUpdateQuiet(cmd: string, args: string[], env?: NodeJS.ProcessEnv): UpdateResult;
106
106
  /**
107
- * Serialize self-updates across concurrent `hq` processes. Without this, a
108
- * machine running several HQ agents can fire many `npm install -g` at the same
109
- * global prefix at once, and the losers fail with ENOTEMPTY mid-rename — the
110
- * exact partial-install state `cleanStalePartialInstall` exists to repair.
111
- * A caller that cannot take the lock simply skips its update: another process
112
- * is already installing the very version it wanted.
107
+ * Serialize self-updates across concurrent `hq` processes AND the hq-sync
108
+ * menubar app. Without this, a machine running several HQ agents can fire many
109
+ * `npm install -g` at the same global prefix at once, and the losers fail with
110
+ * ENOTEMPTY mid-rename — the exact partial-install state
111
+ * `cleanStalePartialInstall` exists to repair. A caller that cannot take the
112
+ * lock simply skips its update: another process is already installing the very
113
+ * version it wanted.
113
114
  *
114
- * `mkdir` is the atomic primitive (same approach as version-check's refresh
115
- * lock); a lock left behind by a killed process goes stale and is reclaimed.
115
+ * Delegates to the shared advisory lock in `update-lock.ts`
116
+ * (`$HOME/.hq/locks/cli-update.lock` a cross-repo contract also honored by
117
+ * the hq-sync Rust app), so every updater on the machine contends on ONE lock
118
+ * instead of each tool keeping its own.
116
119
  */
117
120
  export declare function acquireUpdateLock(now?: number): (() => void) | null;
118
121
  /** Injectable surface so the flow is unit-testable without network or spawns. */
@@ -57,13 +57,11 @@
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
59
  import { spawnSync } from "node:child_process";
60
- import * as fs from "node:fs";
61
- import * as os from "node:os";
62
- import * as path from "node:path";
63
60
  import semver from "semver";
64
61
  import chalk from "chalk";
65
62
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
66
63
  import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
64
+ import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
67
65
  /**
68
66
  * Set on the re-exec'd child so it can never self-update (and re-exec) again.
69
67
  * One update + one re-exec per user invocation, ever.
@@ -71,8 +69,6 @@ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, bu
71
69
  export const REEXEC_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
72
70
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
73
71
  const FETCH_TIMEOUT_MS = 3_000;
74
- /** A held update lock older than this is treated as abandoned (crashed owner). */
75
- const UPDATE_LOCK_STALE_MS = 10 * 60 * 1000;
76
72
  /** Tail of captured package-manager stderr kept for the failure warning. */
77
73
  const DETAIL_MAX_CHARS = 400;
78
74
  /** npm `latest` for this package, or null on any failure (offline, 5xx, bad body). */
@@ -150,49 +146,23 @@ export function runUpdateQuiet(cmd, args, env) {
150
146
  output.dispose();
151
147
  }
152
148
  }
153
- function lockDir() {
154
- return path.join(os.homedir(), ".hq", "self-update.lock");
155
- }
156
149
  /**
157
- * Serialize self-updates across concurrent `hq` processes. Without this, a
158
- * machine running several HQ agents can fire many `npm install -g` at the same
159
- * global prefix at once, and the losers fail with ENOTEMPTY mid-rename — the
160
- * exact partial-install state `cleanStalePartialInstall` exists to repair.
161
- * A caller that cannot take the lock simply skips its update: another process
162
- * is already installing the very version it wanted.
150
+ * Serialize self-updates across concurrent `hq` processes AND the hq-sync
151
+ * menubar app. Without this, a machine running several HQ agents can fire many
152
+ * `npm install -g` at the same global prefix at once, and the losers fail with
153
+ * ENOTEMPTY mid-rename — the exact partial-install state
154
+ * `cleanStalePartialInstall` exists to repair. A caller that cannot take the
155
+ * lock simply skips its update: another process is already installing the very
156
+ * version it wanted.
163
157
  *
164
- * `mkdir` is the atomic primitive (same approach as version-check's refresh
165
- * lock); a lock left behind by a killed process goes stale and is reclaimed.
158
+ * Delegates to the shared advisory lock in `update-lock.ts`
159
+ * (`$HOME/.hq/locks/cli-update.lock` a cross-repo contract also honored by
160
+ * the hq-sync Rust app), so every updater on the machine contends on ONE lock
161
+ * instead of each tool keeping its own.
166
162
  */
167
163
  export function acquireUpdateLock(now = Date.now()) {
168
- const dir = lockDir();
169
- const release = () => {
170
- try {
171
- fs.rmSync(dir, { recursive: true, force: true });
172
- }
173
- catch {
174
- // best-effort lock cleanup
175
- }
176
- };
177
- try {
178
- fs.mkdirSync(path.dirname(dir), { recursive: true });
179
- fs.mkdirSync(dir);
180
- return release;
181
- }
182
- catch {
183
- try {
184
- const stat = fs.statSync(dir);
185
- if (now - stat.mtimeMs > UPDATE_LOCK_STALE_MS) {
186
- fs.rmSync(dir, { recursive: true, force: true });
187
- fs.mkdirSync(dir);
188
- return release;
189
- }
190
- }
191
- catch {
192
- // lock vanished or is unreadable — treat as held and skip
193
- }
194
- return null;
195
- }
164
+ const handle = acquireSharedUpdateLock({ now, tool: "hq-cli-self-update" });
165
+ return handle ? handle.release : null;
196
166
  }
197
167
  /**
198
168
  * Re-run `hq <argv…>` from PATH so the freshly-installed version handles the
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Shared advisory cross-process lock serializing every actor that runs
3
+ * `npm install -g @indigoai-us/hq-cli` (or the pnpm/bun equivalent) against
4
+ * the same machine.
5
+ *
6
+ * Why: production logs showed FOUR independent updaters — this CLI's hard
7
+ * version-gate, the soft self-updater, and updaters inside the hq-sync menubar
8
+ * app — each firing `npm install -g` with no coordination. On a machine with a
9
+ * second "ghost" install (e.g. a pnpm-global shim shadowing the npm one) the
10
+ * updaters kept "successfully updating" forever, and eventually two concurrent
11
+ * installs collided mid-rename (npm stages by renaming the package dir aside
12
+ * to a hidden `.hq-cli-XXXX` dir), gutting the install: no package.json, no
13
+ * bin/hq. This lock makes losing that race impossible for cooperating actors.
14
+ *
15
+ * ── CROSS-REPO CONTRACT ─────────────────────────────────────────────────────
16
+ * The lock file path and JSON shape below are a contract shared with the
17
+ * hq-sync Rust menubar app, which honors the SAME file before running its own
18
+ * CLI updates. Do not change either without a coordinated hq-sync release.
19
+ *
20
+ * Path: $HOME/.hq/locks/cli-update.lock
21
+ * Content: JSON object with exactly these fields:
22
+ * {
23
+ * "pid": <number> — owning process id,
24
+ * "startedAt": <string> — ISO-8601 acquisition timestamp,
25
+ * "tool": <string> — which actor holds it (e.g. "hq-cli-version-gate"),
26
+ * "version": <string> — the holder's own version
27
+ * }
28
+ * Staleness: a holder is stale when `startedAt` is older than 10 minutes OR
29
+ * its `pid` is no longer alive (`kill(pid, 0)` → ESRCH). A stale lock may be
30
+ * removed and re-acquired; a fresh one means "someone else is installing the
31
+ * very version you want — skip".
32
+ * ────────────────────────────────────────────────────────────────────────────
33
+ *
34
+ * Mechanics: acquisition is a single atomic `open(…, 'wx')` (O_CREAT|O_EXCL) —
35
+ * no mkdir dance, no read-then-write window. Unparseable lock content is
36
+ * treated as stale (a torn write from a crashed holder must not wedge updates
37
+ * forever). Everything is best-effort: any unexpected filesystem error reads
38
+ * as "lock held", because the callers' shared philosophy is that updating is
39
+ * optional and the CLI keeps working either way.
40
+ */
41
+ /** A held lock older than this is treated as abandoned (crashed owner). */
42
+ export declare const UPDATE_LOCK_STALE_MS: number;
43
+ /** `tool` recorded when this CLI takes the lock. */
44
+ export declare const UPDATE_LOCK_TOOL = "hq-cli-version-gate";
45
+ export interface UpdateLockInfo {
46
+ pid: number;
47
+ startedAt: string;
48
+ tool: string;
49
+ version: string;
50
+ }
51
+ export interface UpdateLockHandle {
52
+ /** Absolute path of the held lock file. */
53
+ path: string;
54
+ /** Delete the lock file. Idempotent, never throws. */
55
+ release: () => void;
56
+ }
57
+ /** Injectable knobs so contention/staleness are unit-testable hermetically. */
58
+ export interface UpdateLockOptions {
59
+ lockPath?: string;
60
+ now?: number;
61
+ /** Override liveness probe (defaults to `process.kill(pid, 0)`). */
62
+ isPidAlive?: (pid: number) => boolean;
63
+ tool?: string;
64
+ version?: string;
65
+ }
66
+ export declare function updateLockPath(): string;
67
+ /**
68
+ * Whether an existing lock's recorded holder is safe to displace: its
69
+ * `startedAt` is older than {@link UPDATE_LOCK_STALE_MS}, or its pid is dead.
70
+ * Unreadable / malformed content is stale by definition — only a crashed or
71
+ * interrupted holder leaves one behind, and treating it as fresh would block
72
+ * every future update on this machine.
73
+ */
74
+ export declare function isLockStale(raw: string | null, now: number, isPidAlive: (pid: number) => boolean): boolean;
75
+ /**
76
+ * Take the machine-wide CLI-update lock, or return `null` when a fresh holder
77
+ * has it (this process should skip its install). A stale holder is removed and
78
+ * acquisition retried exactly once — two genuinely-racing processes still
79
+ * serialize correctly because the retry goes back through O_EXCL.
80
+ *
81
+ * Callers MUST release in a `finally` around the install.
82
+ */
83
+ export declare function acquireUpdateLock(options?: UpdateLockOptions): UpdateLockHandle | null;
84
+ //# sourceMappingURL=update-lock.d.ts.map
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared advisory cross-process lock serializing every actor that runs
3
+ * `npm install -g @indigoai-us/hq-cli` (or the pnpm/bun equivalent) against
4
+ * the same machine.
5
+ *
6
+ * Why: production logs showed FOUR independent updaters — this CLI's hard
7
+ * version-gate, the soft self-updater, and updaters inside the hq-sync menubar
8
+ * app — each firing `npm install -g` with no coordination. On a machine with a
9
+ * second "ghost" install (e.g. a pnpm-global shim shadowing the npm one) the
10
+ * updaters kept "successfully updating" forever, and eventually two concurrent
11
+ * installs collided mid-rename (npm stages by renaming the package dir aside
12
+ * to a hidden `.hq-cli-XXXX` dir), gutting the install: no package.json, no
13
+ * bin/hq. This lock makes losing that race impossible for cooperating actors.
14
+ *
15
+ * ── CROSS-REPO CONTRACT ─────────────────────────────────────────────────────
16
+ * The lock file path and JSON shape below are a contract shared with the
17
+ * hq-sync Rust menubar app, which honors the SAME file before running its own
18
+ * CLI updates. Do not change either without a coordinated hq-sync release.
19
+ *
20
+ * Path: $HOME/.hq/locks/cli-update.lock
21
+ * Content: JSON object with exactly these fields:
22
+ * {
23
+ * "pid": <number> — owning process id,
24
+ * "startedAt": <string> — ISO-8601 acquisition timestamp,
25
+ * "tool": <string> — which actor holds it (e.g. "hq-cli-version-gate"),
26
+ * "version": <string> — the holder's own version
27
+ * }
28
+ * Staleness: a holder is stale when `startedAt` is older than 10 minutes OR
29
+ * its `pid` is no longer alive (`kill(pid, 0)` → ESRCH). A stale lock may be
30
+ * removed and re-acquired; a fresh one means "someone else is installing the
31
+ * very version you want — skip".
32
+ * ────────────────────────────────────────────────────────────────────────────
33
+ *
34
+ * Mechanics: acquisition is a single atomic `open(…, 'wx')` (O_CREAT|O_EXCL) —
35
+ * no mkdir dance, no read-then-write window. Unparseable lock content is
36
+ * treated as stale (a torn write from a crashed holder must not wedge updates
37
+ * forever). Everything is best-effort: any unexpected filesystem error reads
38
+ * as "lock held", because the callers' shared philosophy is that updating is
39
+ * optional and the CLI keeps working either way.
40
+ */
41
+ import * as fs from "node:fs";
42
+ import * as os from "node:os";
43
+ import * as path from "node:path";
44
+ import { CLI_VERSION } from "../cli-version.js";
45
+ /** A held lock older than this is treated as abandoned (crashed owner). */
46
+ export const UPDATE_LOCK_STALE_MS = 10 * 60 * 1000;
47
+ /** `tool` recorded when this CLI takes the lock. */
48
+ export const UPDATE_LOCK_TOOL = "hq-cli-version-gate";
49
+ export function updateLockPath() {
50
+ return path.join(os.homedir(), ".hq", "locks", "cli-update.lock");
51
+ }
52
+ function defaultIsPidAlive(pid) {
53
+ try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ }
57
+ catch (err) {
58
+ // ESRCH — no such process. EPERM means it exists but is not ours; that
59
+ // still counts as alive (we must not steal a root-owned updater's lock).
60
+ return err.code === "EPERM";
61
+ }
62
+ }
63
+ /**
64
+ * Whether an existing lock's recorded holder is safe to displace: its
65
+ * `startedAt` is older than {@link UPDATE_LOCK_STALE_MS}, or its pid is dead.
66
+ * Unreadable / malformed content is stale by definition — only a crashed or
67
+ * interrupted holder leaves one behind, and treating it as fresh would block
68
+ * every future update on this machine.
69
+ */
70
+ export function isLockStale(raw, now, isPidAlive) {
71
+ if (raw == null)
72
+ return true;
73
+ let info;
74
+ try {
75
+ info = JSON.parse(raw);
76
+ }
77
+ catch {
78
+ return true;
79
+ }
80
+ if (typeof info.pid !== "number" || typeof info.startedAt !== "string") {
81
+ return true;
82
+ }
83
+ const startedAt = Date.parse(info.startedAt);
84
+ if (Number.isNaN(startedAt))
85
+ return true;
86
+ if (now - startedAt > UPDATE_LOCK_STALE_MS)
87
+ return true;
88
+ if (!isPidAlive(info.pid))
89
+ return true;
90
+ return false;
91
+ }
92
+ function tryCreate(lockPath, body) {
93
+ let fd;
94
+ try {
95
+ // 'wx' — O_CREAT|O_EXCL|O_WRONLY: fails atomically if the file exists.
96
+ fd = fs.openSync(lockPath, "wx");
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ try {
102
+ fs.writeSync(fd, body);
103
+ }
104
+ catch {
105
+ // A lock we created but could not stamp is still OURS — keep it.
106
+ }
107
+ finally {
108
+ try {
109
+ fs.closeSync(fd);
110
+ }
111
+ catch {
112
+ // already closed
113
+ }
114
+ }
115
+ return {
116
+ path: lockPath,
117
+ release: () => {
118
+ try {
119
+ fs.rmSync(lockPath, { force: true });
120
+ }
121
+ catch {
122
+ // best-effort lock cleanup
123
+ }
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Take the machine-wide CLI-update lock, or return `null` when a fresh holder
129
+ * has it (this process should skip its install). A stale holder is removed and
130
+ * acquisition retried exactly once — two genuinely-racing processes still
131
+ * serialize correctly because the retry goes back through O_EXCL.
132
+ *
133
+ * Callers MUST release in a `finally` around the install.
134
+ */
135
+ export function acquireUpdateLock(options = {}) {
136
+ const lockPath = options.lockPath ?? updateLockPath();
137
+ const now = options.now ?? Date.now();
138
+ const isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
139
+ const info = {
140
+ pid: process.pid,
141
+ startedAt: new Date(now).toISOString(),
142
+ tool: options.tool ?? UPDATE_LOCK_TOOL,
143
+ version: options.version ?? CLI_VERSION,
144
+ };
145
+ const body = JSON.stringify(info);
146
+ try {
147
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
148
+ }
149
+ catch {
150
+ return null; // can't even create ~/.hq/locks — treat as held, skip update
151
+ }
152
+ const first = tryCreate(lockPath, body);
153
+ if (first)
154
+ return first;
155
+ // Lock exists. Fresh holder → back off. Stale → remove and retry ONCE.
156
+ let raw;
157
+ try {
158
+ raw = fs.readFileSync(lockPath, "utf-8");
159
+ }
160
+ catch {
161
+ raw = null; // vanished (holder just released) or unreadable — stale path
162
+ }
163
+ if (!isLockStale(raw, now, isPidAlive))
164
+ return null;
165
+ try {
166
+ fs.rmSync(lockPath, { force: true });
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ return tryCreate(lockPath, body);
172
+ }
173
+ //# sourceMappingURL=update-lock.js.map
@@ -28,6 +28,7 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
31
+ import { type UpdateLockHandle } from "./update-lock.js";
31
32
  /** Which package manager owns the running global install. */
32
33
  export type InstallManager = "npm" | "pnpm" | "bun";
33
34
  export interface VersionCheckResponse {
@@ -250,21 +251,60 @@ declare function performUpdate(command: string, runner?: UpdateRunner): UpdateRe
250
251
  */
251
252
  declare function nudgeUpdateRecommended(decision: VersionCheckResponse, install?: RunningInstall): void;
252
253
  /**
253
- * Hard enforcement when the server says we're below `minVersion`. Print a
254
- * red banner, attempt the update, then exit so the user reruns against the
255
- * fresh binary. Sequence chosen so a user with a broken `npm` global prefix
256
- * still gets a clear error rather than an opaque silent failure.
254
+ * The `hq` binary an ordinary shell PATH lookup would run, or null when it
255
+ * can't be resolved. Uses `command -v` through `/bin/sh` (POSIX) / `where`
256
+ * (Windows) rather than trusting our own install path the whole point is to
257
+ * see what the USER's next invocation resolves.
258
+ */
259
+ export declare function resolveHqOnPath(): string | null;
260
+ /** `<bin> --version` output (trimmed), or null on any failure/timeout. */
261
+ export declare function probeCliVersion(bin: string): string | null;
262
+ /**
263
+ * Read-your-writes convergence check, run after an install reports success.
257
264
  *
258
- * Exit codes:
259
- * 0update succeeded; user must rerun their command
260
- * 75 update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
265
+ * A "successful" `npm install -g` proves only that the npm prefix was
266
+ * rewritten NOT that the `hq` the user's PATH resolves is the copy that was
267
+ * just installed. On a machine with a second "ghost" install (e.g. a
268
+ * pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
269
+ * every updater on the box probes the ghost's version, sees it stale, installs
270
+ * into the npm prefix, reports success, and repeats forever — the production
271
+ * updater-war this fix exists for. Detect the divergence here, warn ONCE with
272
+ * the exact shadowing path, and never retry: no number of retries into the
273
+ * same prefix can change what PATH resolves.
274
+ *
275
+ * Advisory only — resolution failure warns once and continues; nothing in
276
+ * here may crash or block the CLI.
261
277
  */
262
- declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: {
278
+ export declare function checkUpdateConvergence(targetVersion: string, deps?: {
279
+ resolveBin?: () => string | null;
280
+ probeVersion?: (bin: string) => string | null;
281
+ }): void;
282
+ /** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
283
+ interface EnforceUpdateDeps {
263
284
  performUpdateString?: (command: string) => UpdateResult;
264
285
  resolveInstall?: () => RunningInstall;
265
286
  runner?: UpdateRunner;
266
287
  cleanStale?: (prefix: string) => string[];
267
- }): never;
288
+ acquireLock?: () => UpdateLockHandle | null;
289
+ checkConvergence?: (targetVersion: string) => void;
290
+ }
291
+ /**
292
+ * Hard enforcement when the server says we're below `minVersion`. Print a
293
+ * red banner, take the machine-wide update lock, attempt the update, then
294
+ * exit so the user reruns against the fresh binary. Sequence chosen so a user
295
+ * with a broken `npm` global prefix still gets a clear error rather than an
296
+ * opaque silent failure.
297
+ *
298
+ * When another updater already holds the lock (fresh, live holder) the gate
299
+ * SKIPS the install and RETURNS, letting the CLI continue on the current
300
+ * version — the gate must never break the CLI, and the lock holder is
301
+ * installing the very version we want anyway.
302
+ *
303
+ * Exit codes:
304
+ * 0 — update succeeded; user must rerun their command
305
+ * 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
306
+ */
307
+ declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void;
268
308
  /**
269
309
  * Public entry point. Call before commander parses argv. Blocks the CLI on
270
310
  * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
@@ -291,8 +331,12 @@ export declare function enforceVersionGate(onUpdateRecommended?: (decision: Vers
291
331
  export declare function shouldSkipGate(argv: readonly string[]): boolean;
292
332
  export declare const __test__: {
293
333
  CLIENT_ID: string;
334
+ CONVERGENCE_TIMEOUT_MS: number;
294
335
  ENDPOINT_PATH: string;
295
336
  FETCH_TIMEOUT_MS: number;
337
+ checkUpdateConvergence: typeof checkUpdateConvergence;
338
+ probeCliVersion: typeof probeCliVersion;
339
+ resolveHqOnPath: typeof resolveHqOnPath;
296
340
  buildBunInstallArgv: typeof buildBunInstallArgv;
297
341
  buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
298
342
  buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
@@ -36,6 +36,7 @@ import { fileURLToPath } from "node:url";
36
36
  import chalk from "chalk";
37
37
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
38
38
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
39
+ import { acquireUpdateLock, } from "./update-lock.js";
39
40
  const CLIENT_ID = "hq-cli";
40
41
  const ENDPOINT_PATH = "/v1/client-version/check";
41
42
  const FETCH_TIMEOUT_MS = 3_000;
@@ -514,11 +515,103 @@ function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
514
515
  console.error(chalk.dim(` Update: ${command}`));
515
516
  }
516
517
  }
518
+ /** Timeout for the read-your-writes PATH/version probes below. A broken or
519
+ * hung shell must not stall the gate — the probes are advisory only. */
520
+ const CONVERGENCE_TIMEOUT_MS = 3_000;
521
+ /**
522
+ * The `hq` binary an ordinary shell PATH lookup would run, or null when it
523
+ * can't be resolved. Uses `command -v` through `/bin/sh` (POSIX) / `where`
524
+ * (Windows) rather than trusting our own install path — the whole point is to
525
+ * see what the USER's next invocation resolves.
526
+ */
527
+ export function resolveHqOnPath() {
528
+ try {
529
+ const probe = process.platform === "win32"
530
+ ? spawnSync("where", ["hq"], {
531
+ encoding: "utf-8",
532
+ timeout: CONVERGENCE_TIMEOUT_MS,
533
+ })
534
+ : spawnSync("/bin/sh", ["-c", "command -v hq"], {
535
+ encoding: "utf-8",
536
+ timeout: CONVERGENCE_TIMEOUT_MS,
537
+ });
538
+ if (probe.error || probe.status !== 0)
539
+ return null;
540
+ const first = (probe.stdout ?? "")
541
+ .split(/\r?\n/)
542
+ .map((line) => line.trim())
543
+ .filter(Boolean)[0];
544
+ return first ?? null;
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ }
550
+ /** `<bin> --version` output (trimmed), or null on any failure/timeout. */
551
+ export function probeCliVersion(bin) {
552
+ try {
553
+ const result = spawnSync(bin, ["--version"], {
554
+ encoding: "utf-8",
555
+ timeout: CONVERGENCE_TIMEOUT_MS,
556
+ });
557
+ if (result.error || result.status !== 0)
558
+ return null;
559
+ const out = (result.stdout ?? "").trim();
560
+ return out || null;
561
+ }
562
+ catch {
563
+ return null;
564
+ }
565
+ }
566
+ /**
567
+ * Read-your-writes convergence check, run after an install reports success.
568
+ *
569
+ * A "successful" `npm install -g` proves only that the npm prefix was
570
+ * rewritten — NOT that the `hq` the user's PATH resolves is the copy that was
571
+ * just installed. On a machine with a second "ghost" install (e.g. a
572
+ * pnpm-global shim at `~/Library/pnpm/bin/hq` shadowing the npm bin dir),
573
+ * every updater on the box probes the ghost's version, sees it stale, installs
574
+ * into the npm prefix, reports success, and repeats forever — the production
575
+ * updater-war this fix exists for. Detect the divergence here, warn ONCE with
576
+ * the exact shadowing path, and never retry: no number of retries into the
577
+ * same prefix can change what PATH resolves.
578
+ *
579
+ * Advisory only — resolution failure warns once and continues; nothing in
580
+ * here may crash or block the CLI.
581
+ */
582
+ export function checkUpdateConvergence(targetVersion, deps = {}) {
583
+ try {
584
+ const bin = (deps.resolveBin ?? resolveHqOnPath)();
585
+ if (!bin) {
586
+ console.error(chalk.yellow("⚠ Updated, but couldn't resolve `hq` on PATH to verify the new version took effect."));
587
+ return;
588
+ }
589
+ const reported = (deps.probeVersion ?? probeCliVersion)(bin);
590
+ if (!reported) {
591
+ console.error(chalk.yellow(`⚠ Updated, but \`${bin} --version\` did not respond — couldn't verify the new version took effect.`));
592
+ return;
593
+ }
594
+ if (reported === targetVersion)
595
+ return; // converged — the normal case
596
+ console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
597
+ `a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
598
+ "or the updater will loop forever."));
599
+ }
600
+ catch {
601
+ // Verification is best-effort; never break the CLI over a probe.
602
+ }
603
+ }
517
604
  /**
518
605
  * Hard enforcement when the server says we're below `minVersion`. Print a
519
- * red banner, attempt the update, then exit so the user reruns against the
520
- * fresh binary. Sequence chosen so a user with a broken `npm` global prefix
521
- * still gets a clear error rather than an opaque silent failure.
606
+ * red banner, take the machine-wide update lock, attempt the update, then
607
+ * exit so the user reruns against the fresh binary. Sequence chosen so a user
608
+ * with a broken `npm` global prefix still gets a clear error rather than an
609
+ * opaque silent failure.
610
+ *
611
+ * When another updater already holds the lock (fresh, live holder) the gate
612
+ * SKIPS the install and RETURNS, letting the CLI continue on the current
613
+ * version — the gate must never break the CLI, and the lock holder is
614
+ * installing the very version we want anyway.
522
615
  *
523
616
  * Exit codes:
524
617
  * 0 — update succeeded; user must rerun their command
@@ -545,6 +638,38 @@ function enforceUpdateRequired(decision, deps = {}) {
545
638
  }
546
639
  process.exit(75);
547
640
  }
641
+ // Serialize against every other updater on the machine (other hq processes,
642
+ // the hq-sync menubar app) via the shared advisory lock — see update-lock.ts
643
+ // for the cross-repo contract. A fresh holder means someone else is already
644
+ // installing the version we want: skip and continue on the current version
645
+ // (the gate must never break the CLI).
646
+ const lock = (deps.acquireLock ?? acquireUpdateLock)();
647
+ if (!lock) {
648
+ console.error(chalk.dim(" Another hq updater is already installing (update lock held); continuing on the current version."));
649
+ return;
650
+ }
651
+ let exitCode;
652
+ try {
653
+ exitCode = attemptRequiredUpdate(decision, deps, install);
654
+ }
655
+ finally {
656
+ // `process.exit` skips `finally` blocks, so the exit itself lives OUTSIDE
657
+ // the lock scope; this `finally` releases on the normal path and on any
658
+ // unexpected throw from the install attempt.
659
+ lock.release();
660
+ }
661
+ process.exit(exitCode);
662
+ }
663
+ /**
664
+ * The install attempt itself (npm/pnpm/bun routing, stale-partial cleanup,
665
+ * sudo fallback, post-success convergence check). Runs with the machine-wide
666
+ * update lock held. Returns the process exit code: 0 on success, 75
667
+ * (EX_TEMPFAIL) on failure.
668
+ */
669
+ function attemptRequiredUpdate(decision, deps, install) {
670
+ const command = decision.updateCommand;
671
+ const isManagedOutsideNpm = install.manager !== "npm";
672
+ const prefix = install.prefix;
548
673
  const runner = deps.runner ?? runUpdateCommand;
549
674
  // Resolve the concrete install argv once so the sudo fallback below can re-run
550
675
  // the EXACT same command under elevation.
@@ -641,10 +766,14 @@ function enforceUpdateRequired(decision, deps = {}) {
641
766
  if (isManagedOutsideNpm && result.code === "ENOENT" && command) {
642
767
  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.)`));
643
768
  }
644
- process.exit(75);
769
+ return 75;
645
770
  }
646
771
  console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
647
- process.exit(0);
772
+ // 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);
776
+ return 0;
648
777
  }
649
778
  export async function enforceVersionGate(onUpdateRecommended) {
650
779
  if (isOptedOut())
@@ -658,7 +787,10 @@ export async function enforceVersionGate(onUpdateRecommended) {
658
787
  // neither downstream path repeats it.
659
788
  const install = resolveRunningInstall();
660
789
  if (decision.updateRequired) {
661
- enforceUpdateRequired(decision, { resolveInstall: () => install }); // exits process
790
+ // Exits the process, EXCEPT when another updater holds the shared update
791
+ // lock — then it returns and the CLI continues on the current version.
792
+ enforceUpdateRequired(decision, { resolveInstall: () => install });
793
+ return "continue";
662
794
  }
663
795
  if (decision.updateRecommended) {
664
796
  if (onUpdateRecommended) {
@@ -682,8 +814,12 @@ export function shouldSkipGate(argv) {
682
814
  }
683
815
  export const __test__ = {
684
816
  CLIENT_ID,
817
+ CONVERGENCE_TIMEOUT_MS,
685
818
  ENDPOINT_PATH,
686
819
  FETCH_TIMEOUT_MS,
820
+ checkUpdateConvergence,
821
+ probeCliVersion,
822
+ resolveHqOnPath,
687
823
  buildBunInstallArgv,
688
824
  buildPnpmInstallArgv,
689
825
  buildPrefixedInstallArgv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.11",
3
+ "version": "5.103.12",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {