@indigoai-us/hq-cli 5.345.35 → 5.345.37

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.
@@ -15,7 +15,8 @@ function description(root, company, name) {
15
15
  case "knowledge": return exists(root, `${base}/.git`) ? "Embedded git repo" : `Inline knowledge (${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} subdirs)`;
16
16
  case "settings": return `${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} integration(s)`;
17
17
  case "policies": return `${countImmediate(root, base, (n) => n.endsWith(".md"))} policy file(s)`;
18
- case "projects": return `${countImmediate(root, base, (n, f) => !n.startsWith("_") && !isHidden(n) && fs.statSync(f).isDirectory())} project(s)`;
18
+ // Match `find -type d`: count actual directories, not symlinks to directories.
19
+ case "projects": return `${countImmediate(root, base, (n, f) => !n.startsWith("_") && !isHidden(n) && fs.lstatSync(f).isDirectory())} project(s)`;
19
20
  case "workers": return `${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} worker(s)`;
20
21
  case "repos": return `${countImmediate(root, base, (n, f) => { const s = fs.lstatSync(f); return !isHidden(n) && (s.isDirectory() || s.isSymbolicLink()); })} repo link(s)`;
21
22
  case "registry": return `Resource registry (${countImmediate(root, `${base}/resources`, (n) => n.endsWith(".yaml"))} resource(s))`;
@@ -1,6 +1,6 @@
1
1
  import { type LaneRecord, type LaneState } from "./types.js";
2
2
  export declare const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
3
- declare const STORY_STATES: readonly ["todo", "in_progress", "blocked", "in_review", "verification", "done", "dropped"];
3
+ declare const STORY_STATES: readonly ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
4
4
  export interface ProjectStoryLedger {
5
5
  projectId: string;
6
6
  companySlug?: string;
@@ -8,8 +8,8 @@ export const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
8
8
  const LOCK_TIMEOUT_MS = 10_000;
9
9
  const LOCK_STALE_MS = 60_000;
10
10
  const LOCK_RETRY_MS = 10;
11
- const STORY_STATES = ["todo", "in_progress", "blocked", "in_review", "verification", "done", "dropped"];
12
- const MANAGER_OWNED_STATES = new Set(["in_review", "verification", "done", "dropped"]);
11
+ const STORY_STATES = ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
12
+ const MANAGER_OWNED_STATES = new Set(["review", "done", "dropped"]);
13
13
  const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
14
14
  export function laneStoryLedgerEnabled() {
15
15
  return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false);
@@ -515,7 +515,7 @@ function validPriorQuestions(value, storyId) {
515
515
  }
516
516
  return value;
517
517
  }
518
- function setLaneStatusFields(story, lane, laneStoryStatus) {
518
+ function setLaneStatusFields(story, lane, laneStoryStatus, isCurrentAttribution) {
519
519
  const laneStatus = story.lane_status;
520
520
  if (laneStatus !== undefined && !isJsonObject(laneStatus)) {
521
521
  throw new LanesError("story_ledger_invalid", "lane_status must be an object for story " + String(story.id), { story_id: story.id });
@@ -569,6 +569,9 @@ function setLaneStatusFields(story, lane, laneStoryStatus) {
569
569
  else {
570
570
  delete story.pending_questions;
571
571
  }
572
+ if (story.state === "in_review" || story.state === "verification") {
573
+ story.state = "review";
574
+ }
572
575
  if (MANAGER_OWNED_STATES.has(String(story.state))) {
573
576
  // A manager decision always wins over lane-derived status.
574
577
  }
@@ -576,7 +579,7 @@ function setLaneStatusFields(story, lane, laneStoryStatus) {
576
579
  story.state = "blocked";
577
580
  }
578
581
  else if (laneStoryStatus === "review") {
579
- story.state = "in_review";
582
+ story.state = "review";
580
583
  }
581
584
  else if (laneStoryStatus === "done") {
582
585
  story.state = "done";
@@ -590,6 +593,22 @@ function setLaneStatusFields(story, lane, laneStoryStatus) {
590
593
  ? previous
591
594
  : "in_progress";
592
595
  }
596
+ if (typeof story.state === "string" && STORY_STATES.includes(story.state)) {
597
+ story.status = story.state;
598
+ }
599
+ if (story.state === "blocked") {
600
+ const reasons = blockers.map((entry) => {
601
+ const reason = typeof entry.blocked_on === "string" ? entry.blocked_on.trim() : "";
602
+ return reason || `Lane ${String(entry.lane_id)} is blocked`;
603
+ });
604
+ if (reasons.length > 0)
605
+ story.blocked_reason = [...new Set(reasons)].join("\n");
606
+ }
607
+ else {
608
+ delete story.blocked_reason;
609
+ }
610
+ if (!isCurrentAttribution && story.lane === lane.lane_id)
611
+ delete story.lane;
593
612
  if (blockers.length > 0)
594
613
  story.lane_status = { saved };
595
614
  else
@@ -611,7 +630,7 @@ function updateOneStory(current, ledger, lane, storyId, projectId, questionRefs)
611
630
  (laneStoriesForLedger(lane).length <= 1 ? lane.last_envelope?.blocked_reason : undefined);
612
631
  const laneState = storyLaneState(lane, laneStory?.status, pendingQuestionIds);
613
632
  setLaneEntry(story, lane, laneState, laneStory?.status, prLinks, pendingQuestionIds, blockedReason);
614
- setLaneStatusFields(story, lane, laneStory?.status);
633
+ setLaneStatusFields(story, lane, laneStory?.status, lane.project_id === projectId && lane.story_id === storyId);
615
634
  if (prLinks.length > 0) {
616
635
  if (story.prs === undefined)
617
636
  story.prs = [];
@@ -695,6 +714,8 @@ function storyLaneState(lane, laneStoryStatus, pendingQuestionIds) {
695
714
  }
696
715
  function storyState(story) {
697
716
  const state = story.state;
717
+ if (state === "in_review" || state === "verification")
718
+ return "review";
698
719
  if (typeof state === "string" && STORY_STATES.includes(state)) {
699
720
  return state;
700
721
  }
@@ -0,0 +1,11 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import type { Writable } from "node:stream";
3
+ export type ProviderName = "claude" | "codex";
4
+ export interface ProviderLauncherDependencies {
5
+ spawn?: typeof nodeSpawn;
6
+ stdout?: Writable;
7
+ stderr?: Writable;
8
+ }
9
+ export declare function providerLoginGuidance(provider: ProviderName, output: string): string | null;
10
+ export declare function runProviderLauncher(providerValue: string, args: readonly string[], dependencies?: ProviderLauncherDependencies): Promise<number>;
11
+ //# sourceMappingURL=provider-launcher.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ export function providerLoginGuidance(provider, output) {
3
+ if (provider === "claude" && /not logged in|please run\s+\/login/i.test(output)) {
4
+ return "Claude Code is not signed in. Sign in with the provider command: claude /login";
5
+ }
6
+ if (provider === "codex" &&
7
+ /(?:http\s*)?401\b/i.test(output) &&
8
+ /missing bearer or basic authentication in header/i.test(output)) {
9
+ return "Codex is not signed in. Sign in with the provider command: codex login";
10
+ }
11
+ return null;
12
+ }
13
+ export async function runProviderLauncher(providerValue, args, dependencies = {}) {
14
+ const provider = providerValue;
15
+ if (provider !== "claude" && provider !== "codex") {
16
+ write(dependencies.stderr, `Unsupported provider '${providerValue}'. Choose claude or codex.\n`);
17
+ return 64;
18
+ }
19
+ if (provider === "claude" && !args.some((arg) => arg === "-p" || arg === "--print")) {
20
+ write(dependencies.stderr, "Provider launch supports noninteractive Claude Code commands. Include -p or --print after --.\n");
21
+ return 64;
22
+ }
23
+ if (provider === "codex" && args[0] !== "exec") {
24
+ write(dependencies.stderr, "Provider launch supports noninteractive Codex commands. Start the arguments after -- with exec.\n");
25
+ return 64;
26
+ }
27
+ const child = (dependencies.spawn ?? nodeSpawn)(provider, [...args], {
28
+ stdio: ["inherit", "pipe", "pipe"],
29
+ windowsHide: true,
30
+ });
31
+ let outputTail = "";
32
+ const capture = (stream, destination) => {
33
+ if (!stream)
34
+ return;
35
+ stream.on("data", (chunk) => {
36
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
37
+ outputTail = `${outputTail}${text}`.slice(-16_384);
38
+ destination?.write(text);
39
+ });
40
+ };
41
+ capture(child.stdout, dependencies.stdout ?? process.stdout);
42
+ capture(child.stderr, dependencies.stderr ?? process.stderr);
43
+ return await new Promise((resolve) => {
44
+ child.once("error", () => {
45
+ write(dependencies.stderr, `Could not start ${provider === "claude" ? "Claude Code" : "Codex"}. Confirm the CLI is installed and available on PATH.\n`);
46
+ resolve(127);
47
+ });
48
+ child.once("close", (code) => {
49
+ const guidance = providerLoginGuidance(provider, outputTail);
50
+ if (guidance)
51
+ write(dependencies.stderr, `${guidance}\n`);
52
+ resolve(code ?? 1);
53
+ });
54
+ });
55
+ }
56
+ function write(stream, message) {
57
+ (stream ?? process.stderr).write(message);
58
+ }
59
+ //# sourceMappingURL=provider-launcher.js.map
@@ -241,18 +241,43 @@ export function generateWorkersRegistry(hqRoot, options = {}) {
241
241
  const output = registryText(options.now ?? new Date(), kept);
242
242
  const registry = path.join(hqRoot, 'core/workers/registry.yaml');
243
243
  let written = true;
244
+ let writeFailed = false;
244
245
  if (fs.existsSync(registry) && withoutTimestamp(fs.readFileSync(registry, 'utf8')) === withoutTimestamp(output))
245
246
  written = false;
246
247
  if (written) {
247
- fs.mkdirSync(path.dirname(registry), { recursive: true });
248
- fs.writeFileSync(registry, output);
249
- log('generate-workers-registry: wrote core/workers/registry.yaml');
248
+ try {
249
+ fs.mkdirSync(path.dirname(registry), { recursive: true });
250
+ fs.writeFileSync(registry, output);
251
+ log('generate-workers-registry: wrote core/workers/registry.yaml');
252
+ }
253
+ catch (error) {
254
+ writeFailed = true;
255
+ written = false;
256
+ const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
257
+ ? error.code
258
+ : 'unknown';
259
+ log(`generate-workers-registry: WARNING unable to write ${registry} (${code}) — grant write access to the registry path and re-run; a later run will rewrite the derived file once access is restored.`);
260
+ void import('../../sentry.js')
261
+ .then(({ Sentry }) => Sentry.captureMessage('generate-workers-registry: registry write failed; partial result returned', {
262
+ level: 'warning',
263
+ fingerprint: ['workers-registry', 'write-failed'],
264
+ tags: { registry_write_error: code },
265
+ }))
266
+ .catch(() => {
267
+ log('generate-workers-registry: WARNING unable to send the registry write failure report to Sentry.');
268
+ });
269
+ }
250
270
  }
251
271
  else {
252
272
  log('generate-workers-registry: core/workers/registry.yaml unchanged');
253
273
  }
254
274
  if (quarantined > 0) {
255
275
  log(`generate-workers-registry: wrote registry for all VALID workers; quarantined ${quarantined} problem(s) above (loud + partial, never a silent total block). Fix the reported worker.yaml file(s) and re-run to register them.`);
276
+ }
277
+ if (writeFailed) {
278
+ log('generate-workers-registry: registry was not written; the command completed with a partial result.');
279
+ }
280
+ if (quarantined > 0 || writeFailed) {
256
281
  return { status: 1, written, quarantined };
257
282
  }
258
283
  return { status: 0, written, quarantined };
@@ -50,5 +50,22 @@ export declare const __test__: {
50
50
  waitForUpdateRequest: typeof waitForUpdateRequest;
51
51
  setCurrentPackageUseLease(lease: PackageUseLease | null): void;
52
52
  };
53
+ /**
54
+ * Run `wait` with this process's package-use lease DROPPED, then reacquire the
55
+ * lease before returning.
56
+ *
57
+ * `hq sync` runs under purpose `"command"`, and
58
+ * {@link releasePackageUseLeaseAfterCommandLoad} deliberately keeps that lease
59
+ * for the whole command. hq-cloud's per-root operation lock then waits — by
60
+ * default without a bound — for whichever process is already syncing, so an old
61
+ * `hq` install pinned `hq pkg update` for the entire length of somebody else's
62
+ * lock wait (HQ-DESKTOP-8H). Queuing is not using the package: drop the lease
63
+ * while this process is only waiting, and take it again once the wait is over.
64
+ *
65
+ * A reacquire that throws {@link PackageUseUpdatePendingError} is the correct
66
+ * outcome — an update landed while we queued — and propagates to the caller. On
67
+ * the failure path the original error wins and the reacquire is best-effort.
68
+ */
69
+ export declare function withPackageUseLeaseReleasedDuring<T>(wait: () => Promise<T>, argv?: readonly string[], release?: () => void, reacquire?: (argv: readonly string[]) => unknown): Promise<T>;
53
70
  export {};
54
71
  //# sourceMappingURL=package-use-lease.d.ts.map
@@ -413,4 +413,40 @@ export const __test__ = {
413
413
  currentPackageUseLease = lease;
414
414
  },
415
415
  };
416
+ /**
417
+ * Run `wait` with this process's package-use lease DROPPED, then reacquire the
418
+ * lease before returning.
419
+ *
420
+ * `hq sync` runs under purpose `"command"`, and
421
+ * {@link releasePackageUseLeaseAfterCommandLoad} deliberately keeps that lease
422
+ * for the whole command. hq-cloud's per-root operation lock then waits — by
423
+ * default without a bound — for whichever process is already syncing, so an old
424
+ * `hq` install pinned `hq pkg update` for the entire length of somebody else's
425
+ * lock wait (HQ-DESKTOP-8H). Queuing is not using the package: drop the lease
426
+ * while this process is only waiting, and take it again once the wait is over.
427
+ *
428
+ * A reacquire that throws {@link PackageUseUpdatePendingError} is the correct
429
+ * outcome — an update landed while we queued — and propagates to the caller. On
430
+ * the failure path the original error wins and the reacquire is best-effort.
431
+ */
432
+ export async function withPackageUseLeaseReleasedDuring(wait, argv = process.argv, release = releaseCurrentPackageUseLease, reacquire = acquireCurrentPackageUseLease) {
433
+ if (currentPackageUseLease === null)
434
+ return wait();
435
+ release();
436
+ let result;
437
+ try {
438
+ result = await wait();
439
+ }
440
+ catch (error) {
441
+ try {
442
+ reacquire(argv);
443
+ }
444
+ catch {
445
+ // The caller's error is the one worth reporting.
446
+ }
447
+ throw error;
448
+ }
449
+ reacquire(argv);
450
+ return result;
451
+ }
416
452
  //# sourceMappingURL=package-use-lease.js.map
@@ -79,10 +79,11 @@ export const REEXEC_GUARD_ENV = UPDATE_RECURSION_GUARD_ENV;
79
79
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
80
80
  const FETCH_TIMEOUT_MS = 3_000;
81
81
  /**
82
- * Move the working CLI package beside itself before asking the package manager
83
- * to replace it. If the move is unavailable, the caller proceeds without
84
- * rollback protection. pnpm's .pnpm package tree is immutable store content,
85
- * so only its selecting public link is moved.
82
+ * Back up the working CLI package before asking the package manager to replace
83
+ * it. If the backup is unavailable, the caller proceeds without rollback
84
+ * protection. pnpm's .pnpm package tree is immutable store content, so back up
85
+ * its selecting public link while leaving that link available for pnpm's scan
86
+ * of existing global installs.
86
87
  */
87
88
  function findPnpmPublicLink(packageRoot) {
88
89
  const home = derivePnpmHome({ manager: "pnpm", prefix: null, packageRoot });
@@ -187,6 +188,114 @@ function snapshotNpmBinLinks(prefix, packageRoot) {
187
188
  }
188
189
  return links;
189
190
  }
191
+ function snapshotPnpmBinShims(pnpmHome, packageRoot) {
192
+ const binMap = readNpmBinMap(packageRoot);
193
+ if (!binMap)
194
+ return [];
195
+ const binDirectory = path.join(pnpmHome, "bin");
196
+ const shims = [];
197
+ for (const name of Object.keys(binMap)) {
198
+ for (const suffix of ["", ".cmd", ".ps1"]) {
199
+ const shimPath = path.join(binDirectory, `${name}${suffix}`);
200
+ try {
201
+ const stat = fs.lstatSync(shimPath);
202
+ if (!stat.isFile())
203
+ continue;
204
+ const content = fs.readFileSync(shimPath);
205
+ if (!pnpmShimTargetsPackage(content.toString("utf8"), packageRoot))
206
+ continue;
207
+ shims.push({ path: shimPath, content, mode: stat.mode & 0o777 });
208
+ }
209
+ catch {
210
+ // A missing or unreadable command shim cannot be safely snapshotted.
211
+ }
212
+ }
213
+ }
214
+ return shims;
215
+ }
216
+ function pnpmShimTarget(content) {
217
+ const match = content.match(/^# cmd-shim-target=(.+)$/m);
218
+ return match?.[1]?.trim() || null;
219
+ }
220
+ function pnpmShimTargetsPackage(content, packageRoot) {
221
+ const target = pnpmShimTarget(content);
222
+ if (!target)
223
+ return false;
224
+ try {
225
+ return isPathInside(fs.realpathSync(target), packageRoot);
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ }
231
+ function pnpmShimTargetsGlobalInstall(content, binDirectory) {
232
+ const target = pnpmShimTarget(content);
233
+ if (!target)
234
+ return false;
235
+ return isPathInside(path.resolve(target), path.join(path.dirname(binDirectory), "global"));
236
+ }
237
+ function restorePnpmBinShims(snapshot) {
238
+ if (!snapshot.pnpmBinDirectory || !snapshot.pnpmBinPackageRoot || !snapshot.pnpmBinShims?.length)
239
+ return true;
240
+ let restored = true;
241
+ for (const shim of snapshot.pnpmBinShims) {
242
+ if (path.dirname(shim.path) !== snapshot.pnpmBinDirectory) {
243
+ reportBinLinkConflict(shim.path, "the saved pnpm shim path is outside the pnpm bin directory");
244
+ restored = false;
245
+ continue;
246
+ }
247
+ let existing;
248
+ try {
249
+ existing = fs.lstatSync(shim.path);
250
+ }
251
+ catch (error) {
252
+ if (error.code !== "ENOENT") {
253
+ reportBinLinkConflict(shim.path, errorMessage(error));
254
+ restored = false;
255
+ continue;
256
+ }
257
+ }
258
+ if (existing) {
259
+ if (!existing.isFile() || existing.isSymbolicLink()) {
260
+ reportBinLinkConflict(shim.path, "another entry owns this command name");
261
+ restored = false;
262
+ continue;
263
+ }
264
+ const currentText = fs.readFileSync(shim.path, "utf8");
265
+ if (!pnpmShimTargetsGlobalInstall(currentText, snapshot.pnpmBinDirectory)) {
266
+ reportBinLinkConflict(shim.path, "the existing file is not a pnpm global command shim");
267
+ restored = false;
268
+ continue;
269
+ }
270
+ }
271
+ const temporary = `${shim.path}.hq-cli-restore-${process.pid}-${randomBytes(4).toString("hex")}`;
272
+ try {
273
+ fs.mkdirSync(snapshot.pnpmBinDirectory, { recursive: true });
274
+ fs.writeFileSync(temporary, shim.content, { mode: shim.mode, flag: "wx" });
275
+ if (!existing) {
276
+ fs.linkSync(temporary, shim.path);
277
+ fs.rmSync(temporary, { force: true });
278
+ }
279
+ else {
280
+ const current = fs.lstatSync(shim.path);
281
+ if (!current.isFile() || current.isSymbolicLink()) {
282
+ throw new Error("the pnpm shim changed while it was being restored");
283
+ }
284
+ const currentText = fs.readFileSync(shim.path, "utf8");
285
+ if (!pnpmShimTargetsGlobalInstall(currentText, snapshot.pnpmBinDirectory)) {
286
+ throw new Error("the pnpm shim changed while it was being restored");
287
+ }
288
+ fs.renameSync(temporary, shim.path);
289
+ }
290
+ }
291
+ catch (error) {
292
+ fs.rmSync(temporary, { force: true });
293
+ reportBinLinkConflict(shim.path, errorMessage(error));
294
+ restored = false;
295
+ }
296
+ }
297
+ return restored;
298
+ }
190
299
  function restoreNpmBinLinks(snapshot) {
191
300
  if (!snapshot.npmBinDirectory || !snapshot.npmBinPackageRoot || !snapshot.npmBinLinks?.length)
192
301
  return true;
@@ -391,12 +500,16 @@ function snapshotPathsAreSafe(snapshot) {
391
500
  snapshot.resolvedTarget,
392
501
  snapshot.publicLinkPath,
393
502
  snapshot.npmBinPackageRoot,
503
+ snapshot.pnpmBinPackageRoot,
394
504
  ].filter((value) => value !== undefined);
395
505
  if (!paths.every(isInsideNodeModules))
396
506
  return false;
397
507
  if (snapshot.npmBinLinks?.some((bin) => !snapshot.npmBinDirectory || path.dirname(bin.path) !== snapshot.npmBinDirectory ||
398
508
  !isPathInside(bin.path, snapshot.npmBinDirectory)))
399
509
  return false;
510
+ if (snapshot.pnpmBinShims?.some((shim) => !snapshot.pnpmBinDirectory || path.dirname(shim.path) !== snapshot.pnpmBinDirectory ||
511
+ !isPathInside(shim.path, snapshot.pnpmBinDirectory)))
512
+ return false;
400
513
  return true;
401
514
  }
402
515
  function snapshotInstall(install, renamePath = fs.renameSync) {
@@ -406,6 +519,7 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
406
519
  let movedPath;
407
520
  let backupPath;
408
521
  let didMove = false;
522
+ let copiedPnpmLink = false;
409
523
  try {
410
524
  const entry = fs.lstatSync(packageRoot);
411
525
  let sourcePath = packageRoot;
@@ -440,9 +554,25 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
440
554
  const npmBinLinks = backupKind === "package" && install.manager === "npm" && install.prefix
441
555
  ? snapshotNpmBinLinks(install.prefix, sourcePath)
442
556
  : undefined;
557
+ const pnpmHome = backupKind === "pnpm-public-link"
558
+ ? derivePnpmHome({ manager: "pnpm", prefix: null, packageRoot: sourcePath }) ?? undefined
559
+ : undefined;
560
+ const pnpmBinShims = pnpmHome ? snapshotPnpmBinShims(pnpmHome, sourcePath) : undefined;
443
561
  movedPath = backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath;
444
562
  backupPath = path.join(path.dirname(movedPath), `.hq-cli-backup-${process.pid}-${randomBytes(8).toString("hex")}`);
445
- renamePath(movedPath, backupPath);
563
+ if (backupKind === "pnpm-public-link") {
564
+ // `pnpm add -g` scans every existing global package before it can update
565
+ // this one. Moving the active link makes its package.json disappear
566
+ // during that scan, so preserve a duplicate link for rollback instead.
567
+ const backupTarget = publicLink?.target ?? resolvedTarget ?? symlinkTarget;
568
+ if (!backupTarget)
569
+ return null;
570
+ fs.symlinkSync(backupTarget, backupPath, process.platform === "win32" ? "junction" : "dir");
571
+ copiedPnpmLink = true;
572
+ }
573
+ else {
574
+ renamePath(movedPath, backupPath);
575
+ }
446
576
  didMove = true;
447
577
  return {
448
578
  installPath: packageRoot,
@@ -458,12 +588,18 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
458
588
  npmBinDirectory: npmBinLinks ? path.join(install.prefix, "bin") : undefined,
459
589
  npmBinPackageRoot: npmBinLinks ? sourcePath : undefined,
460
590
  npmBinLinks,
591
+ pnpmBinDirectory: pnpmBinShims?.length ? path.join(pnpmHome, "bin") : undefined,
592
+ pnpmBinPackageRoot: pnpmBinShims?.length ? sourcePath : undefined,
593
+ pnpmBinShims,
461
594
  };
462
595
  }
463
596
  catch {
464
597
  if (didMove && movedPath && backupPath) {
465
598
  try {
466
- fs.renameSync(backupPath, movedPath);
599
+ if (copiedPnpmLink)
600
+ fs.rmSync(backupPath, { recursive: true, force: true });
601
+ else
602
+ fs.renameSync(backupPath, movedPath);
467
603
  }
468
604
  catch {
469
605
  // The update may proceed without rollback if the move cannot be undone.
@@ -483,7 +619,7 @@ function restoreInstall(snapshot, renamePath = fs.renameSync) {
483
619
  fs.rmSync(publicLinkPath, { recursive: true, force: true });
484
620
  fs.mkdirSync(path.dirname(publicLinkPath), { recursive: true });
485
621
  renamePath(snapshot.backupPath, publicLinkPath);
486
- return true;
622
+ return restorePnpmBinShims(snapshot);
487
623
  }
488
624
  fs.rmSync(snapshot.installPath, { recursive: true, force: true });
489
625
  if (snapshot.symlinkTarget !== undefined && snapshot.resolvedTarget !== undefined) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Take hq-cloud's per-root operation lock for a sync pass from hq-cli, so the
3
+ * wait for an existing holder happens OUTSIDE this process's package-use lease.
4
+ *
5
+ * `sync()` normally wraps itself in `withOperationLock`, which waits for a live
6
+ * holder with no bound unless `HQ_OP_LOCK_TIMEOUT` says otherwise. `hq sync`
7
+ * runs under package-use purpose `"command"`, whose lease is held for the whole
8
+ * command, so the queued process pinned the installed `hq` version — and
9
+ * therefore blocked `hq pkg update` — for the entire length of somebody else's
10
+ * sync (HQ-DESKTOP-8H).
11
+ *
12
+ * Acquiring the lock here instead lets the lease be dropped across the wait and
13
+ * retaken the moment the lock is held, before any sync work runs.
14
+ * `operationLockAlreadyHeld` is the seam hq-cloud exposes for a caller that owns
15
+ * the lock; the lock is NOT reentrant in-process, so the flag is required rather
16
+ * than optional.
17
+ */
18
+ import { type SyncOptions, type SyncResult } from "@indigoai-us/hq-cloud";
19
+ import { type LockHandle } from "@indigoai-us/hq-cloud/operation-lock";
20
+ export interface SyncUnderOperationLockDeps<Options, Result> {
21
+ sync: (options: Options & {
22
+ operationLockAlreadyHeld: true;
23
+ }) => Promise<Result>;
24
+ /** Injected in tests; defaults to hq-cloud's wait-capable acquisition. */
25
+ acquireLock?: (hqRoot: string) => Promise<LockHandle>;
26
+ /** Injected in tests; defaults to the real lease drop/retake. */
27
+ withLeaseReleased?: <T>(wait: () => Promise<T>) => Promise<T>;
28
+ }
29
+ export declare function syncUnderOperationLock<Options extends {
30
+ hqRoot: string;
31
+ }, Result>(options: Options, deps: SyncUnderOperationLockDeps<Options, Result>): Promise<Result>;
32
+ /**
33
+ * The production sync leg: hq-cloud's `sync()` under a lock this process takes
34
+ * itself. Every `hq sync` path in `src/commands/cloud.ts` calls this.
35
+ */
36
+ export declare function syncLeg(options: SyncOptions): Promise<SyncResult>;
37
+ //# sourceMappingURL=sync-operation-lock.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Take hq-cloud's per-root operation lock for a sync pass from hq-cli, so the
3
+ * wait for an existing holder happens OUTSIDE this process's package-use lease.
4
+ *
5
+ * `sync()` normally wraps itself in `withOperationLock`, which waits for a live
6
+ * holder with no bound unless `HQ_OP_LOCK_TIMEOUT` says otherwise. `hq sync`
7
+ * runs under package-use purpose `"command"`, whose lease is held for the whole
8
+ * command, so the queued process pinned the installed `hq` version — and
9
+ * therefore blocked `hq pkg update` — for the entire length of somebody else's
10
+ * sync (HQ-DESKTOP-8H).
11
+ *
12
+ * Acquiring the lock here instead lets the lease be dropped across the wait and
13
+ * retaken the moment the lock is held, before any sync work runs.
14
+ * `operationLockAlreadyHeld` is the seam hq-cloud exposes for a caller that owns
15
+ * the lock; the lock is NOT reentrant in-process, so the flag is required rather
16
+ * than optional.
17
+ */
18
+ import { sync } from "@indigoai-us/hq-cloud";
19
+ import { acquireOperationLockAsync, } from "@indigoai-us/hq-cloud/operation-lock";
20
+ import { withPackageUseLeaseReleasedDuring } from "./package-use-lease.js";
21
+ export async function syncUnderOperationLock(options, deps) {
22
+ const acquireLock = deps.acquireLock ?? ((hqRoot) => acquireOperationLockAsync(hqRoot, "sync"));
23
+ const withLeaseReleased = deps.withLeaseReleased ??
24
+ ((wait) => withPackageUseLeaseReleasedDuring(wait));
25
+ const handle = await withLeaseReleased(() => acquireLock(options.hqRoot));
26
+ try {
27
+ return await deps.sync({ ...options, operationLockAlreadyHeld: true });
28
+ }
29
+ finally {
30
+ handle.release();
31
+ }
32
+ }
33
+ /**
34
+ * The production sync leg: hq-cloud's `sync()` under a lock this process takes
35
+ * itself. Every `hq sync` path in `src/commands/cloud.ts` calls this.
36
+ */
37
+ export function syncLeg(options) {
38
+ return syncUnderOperationLock(options, { sync });
39
+ }
40
+ //# sourceMappingURL=sync-operation-lock.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.35",
3
+ "version": "5.345.37",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -48,7 +48,7 @@
48
48
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
49
49
  "@aws-sdk/client-s3": "^3.1049.0",
50
50
  "@aws-sdk/dsql-signer": "^3.1146.0",
51
- "@indigoai-us/hq-cloud": "~6.18.33",
51
+ "@indigoai-us/hq-cloud": "~6.18.50",
52
52
  "@indigoai-us/hq-flags-client": "^0.1.2",
53
53
  "@modelcontextprotocol/sdk": "1.29.0",
54
54
  "@sentry/node": "^10.49.0",