@indigoai-us/hq-cli 5.288.0 → 5.290.0

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.
@@ -24,6 +24,8 @@ export interface StoreDeps {
24
24
  readdirSync: typeof fs.readdirSync;
25
25
  /** Bulk readers may provide a pre-indexed conflict-sidecar lookup. */
26
26
  conflictSidecarPathsFor?: (recordPath: string) => readonly string[];
27
+ /** Injectable ULID source for deterministic lane-id generation. */
28
+ generateLaneUlid?: (timestampMs: number) => string;
27
29
  openSync: typeof fs.openSync;
28
30
  closeSync: typeof fs.closeSync;
29
31
  fsyncSync: typeof fs.fsyncSync;
@@ -36,7 +38,7 @@ export declare function questionRecordPath(hqRoot: string, questionId: string):
36
38
  export declare function pendingMarkerPath(hqRoot: string, questionId: string): string;
37
39
  export declare function sanitizeHostComponent(hostname: string): string;
38
40
  /** Collision-safe across machines: ULID plus a host component. */
39
- export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
41
+ export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
40
42
  export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
41
43
  export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
42
44
  export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
@@ -52,7 +54,7 @@ export declare function mergeLaneRecords(live: LaneRecord, sidecars: readonly La
52
54
  export declare function mergeQuestionRecords(live: QuestionRecord, sidecars: readonly QuestionRecord[]): QuestionRecord;
53
55
  export declare function tryReadLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord | null;
54
56
  export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
55
- /** Reads every lane while scanning the lane directory for conflict sidecars once. */
57
+ /** Reads all lanes, indexing sidecars once and sorting by creation time then id. */
56
58
  export declare function readAllLanes(hqRoot: string, deps?: StoreDeps): LaneRecord[];
57
59
  export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
58
60
  export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
@@ -67,7 +67,8 @@ export function sanitizeHostComponent(hostname) {
67
67
  /** Collision-safe across machines: ULID plus a host component. */
68
68
  export function mintLaneId(deps = defaultStoreDeps()) {
69
69
  const host = sanitizeHostComponent(deps.hostname());
70
- return `${generateUlid(deps.now().getTime())}_${host}`;
70
+ const timestampMs = deps.now().getTime();
71
+ return `${(deps.generateLaneUlid ?? generateUlid)(timestampMs)}_${host}`;
71
72
  }
72
73
  export function mintQuestionId(deps = defaultStoreDeps()) {
73
74
  const host = sanitizeHostComponent(deps.hostname());
@@ -532,26 +533,44 @@ export function parseLaneRecord(raw, filePath) {
532
533
  const details = raw.queued_details;
533
534
  if (details === null || typeof details !== "object" || Array.isArray(details) ||
534
535
  typeof details.reason !== "string") {
535
- throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
536
+ throw new LaneSchemaError("queued_details must describe a supported queue condition", {
536
537
  path: filePath,
537
538
  field: "queued_details",
538
539
  });
539
540
  }
540
541
  const detailsRecord = details;
541
- if (detailsRecord.reason !== "worker_profile_changed" ||
542
- (detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
543
- typeof detailsRecord.actual_hash !== "string" || detailsRecord.actual_hash === "" ||
544
- typeof detailsRecord.recovery_command !== "string" || detailsRecord.recovery_command.trim() === "") {
545
- throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
546
- path: filePath,
547
- field: "queued_details",
548
- });
542
+ if (detailsRecord.reason === "worker_profile_changed") {
543
+ if ((detailsRecord.expected_hash !== null && typeof detailsRecord.expected_hash !== "string") ||
544
+ typeof detailsRecord.actual_hash !== "string" || detailsRecord.actual_hash === "" ||
545
+ typeof detailsRecord.recovery_command !== "string" || detailsRecord.recovery_command.trim() === "") {
546
+ throw new LaneSchemaError("queued_details must describe a worker_profile_changed queue reason", {
547
+ path: filePath,
548
+ field: "queued_details",
549
+ });
550
+ }
551
+ if (raw.queued_reason !== "worker_profile_changed") {
552
+ throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
553
+ path: filePath,
554
+ field: "queued_details",
555
+ queued_reason: raw.queued_reason,
556
+ });
557
+ }
558
+ }
559
+ else if (detailsRecord.reason === "outpost_dispatch_unknown") {
560
+ if (raw.state !== "queued" ||
561
+ detailsRecord.outcome_unknown !== true ||
562
+ (detailsRecord.command_id !== null &&
563
+ (typeof detailsRecord.command_id !== "string" || detailsRecord.command_id.trim() === ""))) {
564
+ throw new LaneSchemaError("queued_details must describe an unknown Outpost dispatch on a queued lane", {
565
+ path: filePath,
566
+ field: "queued_details",
567
+ });
568
+ }
549
569
  }
550
- if (raw.queued_reason !== "worker_profile_changed") {
551
- throw new LaneSchemaError("queued_details requires queued_reason worker_profile_changed", {
570
+ else {
571
+ throw new LaneSchemaError("queued_details must describe a supported queue condition", {
552
572
  path: filePath,
553
573
  field: "queued_details",
554
- queued_reason: raw.queued_reason,
555
574
  });
556
575
  }
557
576
  }
@@ -1160,7 +1179,7 @@ export function readLane(hqRoot, laneId, deps = defaultStoreDeps()) {
1160
1179
  throw new LaneNotFoundError("lane", laneId);
1161
1180
  return record;
1162
1181
  }
1163
- /** Reads every lane while scanning the lane directory for conflict sidecars once. */
1182
+ /** Reads all lanes, indexing sidecars once and sorting by creation time then id. */
1164
1183
  export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
1165
1184
  const laneIds = listLaneIds(hqRoot, deps);
1166
1185
  if (laneIds.length === 0)
@@ -1194,7 +1213,10 @@ export function readAllLanes(hqRoot, deps = defaultStoreDeps()) {
1194
1213
  ...deps,
1195
1214
  conflictSidecarPathsFor: (recordPath) => sidecarsByRecord.get(path.basename(recordPath)) ?? [],
1196
1215
  };
1197
- return laneIds.map((laneId) => readLane(hqRoot, laneId, indexedDeps));
1216
+ return laneIds
1217
+ .map((laneId) => readLane(hqRoot, laneId, indexedDeps))
1218
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) ||
1219
+ a.lane_id.localeCompare(b.lane_id));
1198
1220
  }
1199
1221
  export function tryReadQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
1200
1222
  const filePath = questionRecordPath(hqRoot, questionId);
@@ -175,12 +175,16 @@ export declare const LANE_PROVIDERS: readonly ["claude", "codex", "grok"];
175
175
  export type LaneProvider = (typeof LANE_PROVIDERS)[number];
176
176
  export declare const QUEUED_REASONS: readonly ["cap", "provider_cap", "account_cap", "profile_cap", "queue_position", "throttle", "provider_binary_missing", "account_retry", "worker_profile_changed"];
177
177
  export type QueuedReason = (typeof QUEUED_REASONS)[number];
178
- export interface QueuedDetails {
178
+ export type QueuedDetails = {
179
179
  reason: "worker_profile_changed";
180
180
  expected_hash: string | null;
181
181
  actual_hash: string;
182
182
  recovery_command: string;
183
- }
183
+ } | {
184
+ reason: "outpost_dispatch_unknown";
185
+ outcome_unknown: true;
186
+ command_id: string | null;
187
+ };
184
188
  export declare function isQueuedReason(value: unknown): value is QueuedReason;
185
189
  export declare const HOST_KINDS: readonly ["local", "outpost"];
186
190
  export type HostKind = (typeof HOST_KINDS)[number];
@@ -231,7 +231,7 @@ export const LANE_FIELD_WRITERS = {
231
231
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
232
232
  last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
233
233
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
234
- admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
234
+ admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command", "reconcile"], settings_history: ["command"], pending_settings: ["command", "spawn"], interrupted_at: ["command", "watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
235
235
  quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
236
236
  provider_session_id: ["watcher", "command", "spawn"], provider_session_id_absent: ["watcher", "command"], provider_home_at_spawn: ["spawn"], last_resume: ["spawn"], last_run_usage: ["watcher"], last_run_usage_at: ["watcher"], envelope_repair: ["watcher", "reconcile"], envelope_validation_errors: ["watcher"], last_progress_event_at: ["watcher"],
237
237
  };
@@ -56,6 +56,7 @@
56
56
  * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
57
57
  * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
58
58
  */
59
+ import * as fs from "node:fs";
59
60
  import { type RunningInstall, type UpdateResult } from "./version-gate.js";
60
61
  import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
61
62
  /**
@@ -136,6 +137,8 @@ export interface SelfUpdateDeps {
136
137
  currentVersion?: string;
137
138
  fetchLatest?: () => Promise<string | null>;
138
139
  resolveInstall?: () => RunningInstall;
140
+ /** Same-filesystem install rename, injectable for rollback tests. */
141
+ renamePath?: typeof fs.renameSync;
139
142
  runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
140
143
  reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
141
144
  acquireLock?: () => (() => void) | null;
@@ -57,10 +57,13 @@
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 { randomBytes } from "node:crypto";
61
+ import * as fs from "node:fs";
62
+ import * as path from "node:path";
60
63
  import semver from "semver";
61
64
  import chalk from "chalk";
62
65
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
63
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
66
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, derivePnpmHome, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
64
67
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
65
68
  import { markLatestIneffective } from "./version-check.js";
66
69
  import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
@@ -71,6 +74,211 @@ import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install
71
74
  export const REEXEC_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
72
75
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
73
76
  const FETCH_TIMEOUT_MS = 3_000;
77
+ /**
78
+ * Move the working CLI package beside itself before asking the package manager
79
+ * to replace it. If the move is unavailable, the caller proceeds without
80
+ * rollback protection. pnpm's .pnpm package tree is immutable store content,
81
+ * so only its selecting public link is moved.
82
+ */
83
+ function findPnpmPublicLink(packageRoot) {
84
+ const home = derivePnpmHome({ manager: "pnpm", prefix: null, packageRoot });
85
+ if (!home)
86
+ return null;
87
+ let resolvedPackageRoot;
88
+ let storeVersions;
89
+ try {
90
+ resolvedPackageRoot = fs.realpathSync(packageRoot);
91
+ storeVersions = fs.readdirSync(path.join(home, "global"));
92
+ }
93
+ catch {
94
+ return null;
95
+ }
96
+ const packageSegments = CLI_NAME.split("/");
97
+ for (const storeVersion of storeVersions) {
98
+ const storeRoot = path.join(home, "global", storeVersion);
99
+ const candidates = [path.join(storeRoot, "node_modules", ...packageSegments)];
100
+ if (/^v\d+$/.test(storeVersion)) {
101
+ try {
102
+ for (const hash of fs.readdirSync(storeRoot)) {
103
+ candidates.push(path.join(storeRoot, hash, "node_modules", ...packageSegments));
104
+ }
105
+ }
106
+ catch {
107
+ // Ignore inaccessible pnpm store versions and try the remaining roots.
108
+ }
109
+ }
110
+ for (const candidate of candidates) {
111
+ if (candidate === packageRoot)
112
+ continue;
113
+ try {
114
+ if (!fs.lstatSync(candidate).isSymbolicLink())
115
+ continue;
116
+ if (fs.realpathSync(candidate) !== resolvedPackageRoot)
117
+ continue;
118
+ return { path: candidate, target: fs.readlinkSync(candidate) };
119
+ }
120
+ catch {
121
+ // A missing or dangling candidate is not the public link for this install.
122
+ }
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ function isPathInside(candidate, parent) {
128
+ const relative = path.relative(path.resolve(parent), path.resolve(candidate));
129
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
130
+ }
131
+ function isInsideNodeModules(candidate) {
132
+ const segments = path.resolve(candidate).replace(/\\/g, "/").split("/").filter(Boolean);
133
+ const index = process.platform === "win32"
134
+ ? segments.map((segment) => segment.toLowerCase()).lastIndexOf("node_modules")
135
+ : segments.lastIndexOf("node_modules");
136
+ return index >= 0 && index < segments.length - 1;
137
+ }
138
+ function isRecognizedGlobalInstall(install, packageRoot) {
139
+ if (!isInsideNodeModules(packageRoot))
140
+ return false;
141
+ if (install.manager === "pnpm")
142
+ return isPnpmManagedPackageDir(packageRoot, true);
143
+ if (install.manager === "bun")
144
+ return isBunManagedPackageDir(packageRoot);
145
+ if (!install.prefix)
146
+ return false;
147
+ const globalNodeModules = process.platform === "win32"
148
+ ? path.join(install.prefix, "node_modules")
149
+ : path.join(install.prefix, "lib", "node_modules");
150
+ return isPathInside(packageRoot, globalNodeModules);
151
+ }
152
+ function snapshotPathsAreSafe(snapshot) {
153
+ const paths = [
154
+ snapshot.installPath,
155
+ snapshot.backupPath,
156
+ snapshot.movedPath,
157
+ snapshot.resolvedTarget,
158
+ snapshot.publicLinkPath,
159
+ ].filter((value) => value !== undefined);
160
+ return paths.every(isInsideNodeModules);
161
+ }
162
+ function snapshotInstall(install, renamePath = fs.renameSync) {
163
+ const packageRoot = install.packageRoot;
164
+ if (!packageRoot)
165
+ return null;
166
+ let movedPath;
167
+ let backupPath;
168
+ let didMove = false;
169
+ try {
170
+ const entry = fs.lstatSync(packageRoot);
171
+ let sourcePath = packageRoot;
172
+ let symlinkTarget;
173
+ let resolvedTarget;
174
+ if (entry.isSymbolicLink()) {
175
+ symlinkTarget = fs.readlinkSync(packageRoot);
176
+ resolvedTarget = fs.realpathSync(packageRoot);
177
+ sourcePath = resolvedTarget;
178
+ }
179
+ else if (!entry.isDirectory()) {
180
+ return null;
181
+ }
182
+ // The running package root can be a source checkout (for example `npm
183
+ // link` or `node dist/...`). Only recognized global layouts are safe to
184
+ // move; the package manager update itself still proceeds without rollback.
185
+ if (!isRecognizedGlobalInstall(install, sourcePath))
186
+ return null;
187
+ const publicLink = install.manager === "pnpm" && !entry.isSymbolicLink()
188
+ ? findPnpmPublicLink(packageRoot)
189
+ : null;
190
+ if (!fs.statSync(sourcePath).isDirectory())
191
+ return null;
192
+ // pnpm's .pnpm tree contains the package store. Moving a package directory
193
+ // out of that tree can invalidate pnpm's global project, so back up only
194
+ // the public link that selects the running store package.
195
+ const isPnpmStorePath = sourcePath.split(path.sep).includes(".pnpm");
196
+ const backupKind = install.manager === "pnpm" && isPnpmStorePath ? "pnpm-public-link" : "package";
197
+ const pnpmPublicLinkPath = publicLink?.path ?? (entry.isSymbolicLink() ? packageRoot : undefined);
198
+ if (backupKind === "pnpm-public-link" && !pnpmPublicLinkPath)
199
+ return null;
200
+ movedPath = backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath;
201
+ backupPath = path.join(path.dirname(movedPath), `.hq-cli-backup-${process.pid}-${randomBytes(8).toString("hex")}`);
202
+ renamePath(movedPath, backupPath);
203
+ didMove = true;
204
+ return {
205
+ installPath: packageRoot,
206
+ backupPath,
207
+ movedPath: backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath,
208
+ backupKind,
209
+ symlinkTarget,
210
+ resolvedTarget,
211
+ publicLinkPath: backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : publicLink?.path,
212
+ publicLinkTarget: backupKind === "pnpm-public-link"
213
+ ? publicLink?.target ?? symlinkTarget
214
+ : publicLink?.target,
215
+ };
216
+ }
217
+ catch {
218
+ if (didMove && movedPath && backupPath) {
219
+ try {
220
+ fs.renameSync(backupPath, movedPath);
221
+ }
222
+ catch {
223
+ // The update may proceed without rollback if the move cannot be undone.
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+ }
229
+ function restoreInstall(snapshot, renamePath = fs.renameSync) {
230
+ if (!snapshotPathsAreSafe(snapshot)) {
231
+ reportFailedRestore(snapshot);
232
+ return false;
233
+ }
234
+ try {
235
+ if (snapshot.backupKind === "pnpm-public-link") {
236
+ const publicLinkPath = snapshot.publicLinkPath ?? snapshot.movedPath;
237
+ fs.rmSync(publicLinkPath, { recursive: true, force: true });
238
+ fs.mkdirSync(path.dirname(publicLinkPath), { recursive: true });
239
+ renamePath(snapshot.backupPath, publicLinkPath);
240
+ return true;
241
+ }
242
+ fs.rmSync(snapshot.installPath, { recursive: true, force: true });
243
+ if (snapshot.symlinkTarget !== undefined && snapshot.resolvedTarget !== undefined) {
244
+ fs.rmSync(snapshot.resolvedTarget, { recursive: true, force: true });
245
+ fs.mkdirSync(path.dirname(snapshot.resolvedTarget), { recursive: true });
246
+ renamePath(snapshot.backupPath, snapshot.resolvedTarget);
247
+ fs.mkdirSync(path.dirname(snapshot.installPath), { recursive: true });
248
+ const target = process.platform === "win32" ? snapshot.resolvedTarget : snapshot.symlinkTarget;
249
+ fs.symlinkSync(target, snapshot.installPath, process.platform === "win32" ? "junction" : "dir");
250
+ }
251
+ else {
252
+ fs.mkdirSync(path.dirname(snapshot.installPath), { recursive: true });
253
+ renamePath(snapshot.backupPath, snapshot.installPath);
254
+ }
255
+ if (snapshot.publicLinkPath && snapshot.publicLinkTarget !== undefined) {
256
+ fs.rmSync(snapshot.publicLinkPath, { recursive: true, force: true });
257
+ fs.mkdirSync(path.dirname(snapshot.publicLinkPath), { recursive: true });
258
+ fs.symlinkSync(snapshot.publicLinkTarget, snapshot.publicLinkPath, process.platform === "win32" ? "junction" : "dir");
259
+ }
260
+ return true;
261
+ }
262
+ catch {
263
+ reportFailedRestore(snapshot);
264
+ return false;
265
+ }
266
+ }
267
+ function discardInstallSnapshot(snapshot) {
268
+ if (!snapshotPathsAreSafe(snapshot)) {
269
+ console.error(chalk.yellow(`⚠ Could not remove the hq-cli install backup at ${snapshot.backupPath}.`));
270
+ return;
271
+ }
272
+ try {
273
+ fs.rmSync(snapshot.backupPath, { recursive: true, force: true });
274
+ }
275
+ catch {
276
+ console.error(chalk.yellow(`⚠ Could not remove the hq-cli install backup at ${snapshot.backupPath}.`));
277
+ }
278
+ }
279
+ function reportFailedRestore(snapshot) {
280
+ console.error(chalk.yellow(`⚠ Could not restore the previous hq-cli install; its backup is at ${snapshot.backupPath}.`));
281
+ }
74
282
  /** npm `latest` for this package, or null on any failure (offline, 5xx, bad body). */
75
283
  async function fetchLatestVersion() {
76
284
  try {
@@ -209,6 +417,11 @@ async function updateAndReexec(argv, flavor, known, deps) {
209
417
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
210
418
  if (!releaseLock)
211
419
  return { action: "skipped", latest };
420
+ const renamePath = deps.renamePath ?? fs.renameSync;
421
+ const installSnapshot = snapshotInstall(install, renamePath);
422
+ if (!installSnapshot) {
423
+ console.error(chalk.dim(" No backup could be made for this hq-cli install; proceeding without rollback protection."));
424
+ }
212
425
  let result;
213
426
  const plan = buildSelfUpdatePlan(install);
214
427
  // A pnpm global install needs PNPM_HOME to find its global bin dir. A
@@ -219,6 +432,7 @@ async function updateAndReexec(argv, flavor, known, deps) {
219
432
  // regardless of the parent environment. No-op for npm/bun and when the
220
433
  // parent already sets PNPM_HOME.
221
434
  const updateEnv = pnpmUpdateEnv(install, env);
435
+ let restoredAfterFailure = false;
222
436
  try {
223
437
  console.error(chalk.dim(`Updating hq-cli ${current} → ${latest}…`));
224
438
  const defaultRunner = flavor.verbose ? runUpdateCommand : runUpdateQuiet;
@@ -230,11 +444,26 @@ async function updateAndReexec(argv, flavor, known, deps) {
230
444
  updateEnv === undefined
231
445
  ? runner(plan.cmd, plan.args)
232
446
  : runner(plan.cmd, plan.args, updateEnv);
447
+ if (!result.ok && installSnapshot) {
448
+ restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
449
+ if (restoredAfterFailure)
450
+ discardInstallSnapshot(installSnapshot);
451
+ }
452
+ }
453
+ catch (error) {
454
+ if (installSnapshot) {
455
+ if (restoreInstall(installSnapshot, renamePath))
456
+ discardInstallSnapshot(installSnapshot);
457
+ }
458
+ throw error;
233
459
  }
234
460
  finally {
235
461
  releaseLock();
236
462
  }
237
463
  if (!result.ok) {
464
+ if (restoredAfterFailure) {
465
+ console.error(chalk.dim(" The previous hq-cli install was restored."));
466
+ }
238
467
  // The one case that still warrants the old yellow banner: we could not get
239
468
  // the user onto the new version, so they need to know it exists and how to
240
469
  // install it by hand.
@@ -259,6 +488,8 @@ async function updateAndReexec(argv, flavor, known, deps) {
259
488
  console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
260
489
  return { action: "update-failed", latest };
261
490
  }
491
+ if (installSnapshot)
492
+ discardInstallSnapshot(installSnapshot);
262
493
  // Read-your-writes: a "successful" `npm install -g` proves only that the
263
494
  // prefix was rewritten, NOT that the `hq` this user's PATH resolves is the
264
495
  // copy we just wrote. When a ghost/shadowing install keeps winning PATH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.288.0",
3
+ "version": "5.290.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {