@indigoai-us/hq-cli 5.103.10 → 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 +17 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +1 -1
- package/dist/commands/core-checkpoint.js +2 -5
- package/dist/utils/self-update.d.ts +11 -8
- package/dist/utils/self-update.js +14 -44
- package/dist/utils/update-lock.d.ts +84 -0
- package/dist/utils/update-lock.js +173 -0
- package/dist/utils/version-gate.d.ts +53 -9
- package/dist/utils/version-gate.js +142 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.103.12] — 2026-08-20
|
|
6
|
+
|
|
7
|
+
## [5.103.11] — 2026-08-20
|
|
8
|
+
|
|
9
|
+
### Reverted
|
|
10
|
+
|
|
11
|
+
- Reverted the 5.103.10 stop-gate anti-filler wording (#419). Demanding that an
|
|
12
|
+
agent "emit zero characters" after the checkpoint moved the noise instead of
|
|
13
|
+
removing it: agents that had stopped appending a meta-note began emitting a
|
|
14
|
+
bare `<br>` — markup they took to render as nothing — so the dangling line
|
|
15
|
+
under the reply survived in a third form. Each tightening only selects the
|
|
16
|
+
next-smallest token, so the wording lever is abandoned here and the branch
|
|
17
|
+
returns to its 5.103.9 text. The underlying conflict is structural: the gate
|
|
18
|
+
asks for silence at the one moment a model is least able to produce it, and
|
|
19
|
+
it only asks because it blocks AFTER a reply is already delivered. Fixing
|
|
20
|
+
that properly means not blocking in the already-replied case at all.
|
|
21
|
+
|
|
5
22
|
## [5.103.10] — 2026-08-20
|
|
6
23
|
|
|
7
24
|
### Fixed
|
|
@@ -390,7 +390,7 @@ set -uo pipefail
|
|
|
390
390
|
# which case we are in, so say exactly one thing.
|
|
391
391
|
flags_spec=' hq core checkpoint --session-id %s --trigger stop-gate --summary "<what changed, in one line>" [--file <path>] [--decision "<choice and why>"] [--learning "<reusable rule>"] [--next "<outstanding step>"]\n\nOnly --summary is required, and the repeatable flags are what the sibling uses to enrich the record, distil policies and update the indexes — a bare summary gives it almost nothing to work with. Write them as machine record, not prose for the user, and pass each one that genuinely applies:\n --file every path you created or modified this turn\n --decision a choice you made that a reader would otherwise have to reverse-engineer\n --learning a rule that changes how someone acts next time, not a restatement of what just happened\n --next work that is genuinely still outstanding\nOmit a flag rather than padding it: an empty or invented learning is worse than none.\n\nIf this turn only read or inspected things and changed no state, the correct call instead is:\n\n hq core checkpoint --session-id %s --idle'
|
|
392
392
|
if [ "$replied" = 1 ]; then
|
|
393
|
-
reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint before it can end.\n\nYour user-facing reply is ALREADY delivered — the message you just wrote is visible to the user. Do NOT send it again, in full or summarized: repeating it double-messages the user, which is exactly the bug this gate guards against.\n\nTHE SIBLING (a background maintenance agent) reads only the checkpoint payload, never your chat reply — anything it needs must go into the flags.\n\nRun the checkpoint now as the FINAL action of the turn
|
|
393
|
+
reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint before it can end.\n\nYour user-facing reply is ALREADY delivered — the message you just wrote is visible to the user. Do NOT send it again, in full or summarized: repeating it double-messages the user, which is exactly the bug this gate guards against.\n\nTHE SIBLING (a background maintenance agent) reads only the checkpoint payload, never your chat reply — anything it needs must go into the flags.\n\nRun the checkpoint now as the FINAL action of the turn and end the turn immediately after it, adding no further text:\n\n'"$flags_spec" "$session_id" "$session_id")"
|
|
394
394
|
else
|
|
395
395
|
reason="$(printf 'This turn changed something, so it needs an end-of-turn checkpoint. Two different audiences are involved — do not conflate them:\n\n1. THE USER reads your normal chat reply. You have not sent one yet this turn — everything you owe them (results, links, answers, status, decisions) must go into it. The checkpoint is invisible to them and does NOT count as having replied.\n2. THE SIBLING (a background maintenance agent) reads the checkpoint payload. It never sees your chat reply, so anything it needs must go into the flags.\n\nORDER: run the checkpoint FIRST, then deliver your complete user-facing reply as the FINAL text of the turn — final-position text is the one placement every host renders in full. Every link, URL, instruction, command, and decision the user needs must appear in that final message.\n\n'"$flags_spec" "$session_id" "$session_id")"
|
|
396
396
|
fi
|
|
@@ -60,11 +60,8 @@ export const CHECKPOINT_REPLY_REMINDER = "checkpoint: REMINDER — this checkpoi
|
|
|
60
60
|
"and it does NOT count as your reply. If this turn's substance (results, findings, " +
|
|
61
61
|
"decisions, state changes, anything awaiting their input) has NOT yet been delivered to " +
|
|
62
62
|
"the user, write your user-facing reply now as the FINAL text of the turn. If you " +
|
|
63
|
-
"already delivered it earlier this turn, end the turn now WITHOUT repeating it
|
|
64
|
-
"
|
|
65
|
-
"this checkpoint. The user has already seen that message; both repeating it and " +
|
|
66
|
-
"appending a dangling note leave visible noise under it. Emitting no further text at " +
|
|
67
|
-
"all is the correct, finished state.";
|
|
63
|
+
"already delivered it earlier this turn, end the turn now WITHOUT repeating it — the " +
|
|
64
|
+
"user has already seen that message, and repeating it double-messages them.";
|
|
68
65
|
function printResult(line) {
|
|
69
66
|
process.stdout.write(`${line}\n`);
|
|
70
67
|
}
|
|
@@ -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
|
|
108
|
-
* machine running several HQ agents can fire many
|
|
109
|
-
* global prefix at once, and the losers fail with
|
|
110
|
-
* exact partial-install state
|
|
111
|
-
* A caller that cannot take the
|
|
112
|
-
* is already installing the very
|
|
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
|
-
*
|
|
115
|
-
* lock
|
|
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
|
|
158
|
-
* machine running several HQ agents can fire many
|
|
159
|
-
* global prefix at once, and the losers fail with
|
|
160
|
-
* exact partial-install state
|
|
161
|
-
* A caller that cannot take the
|
|
162
|
-
* is already installing the very
|
|
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
|
-
*
|
|
165
|
-
* lock
|
|
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
|
|
169
|
-
|
|
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
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
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
|
|
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
|
-
|
|
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,
|
|
520
|
-
* fresh binary. Sequence chosen so a user
|
|
521
|
-
* still gets a clear error rather than an
|
|
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
|
-
|
|
769
|
+
return 75;
|
|
645
770
|
}
|
|
646
771
|
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
647
|
-
|
|
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
|
-
|
|
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,
|