@indigoai-us/hq-cli 5.345.36 → 5.345.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.38] — 2026-10-07
6
+ - Windows CI parity suites now derive their shell-subprocess time budget from a warm-up measured on the same runner instead of a fixed 7-second deadline.
7
+
8
+ ## [5.345.37] — 2026-10-07
9
+ - Keep pnpm's public global package link readable while self-update scans and replaces existing global installs, so Corepack-managed updates can complete with rollback protection intact.
10
+
11
+ - Add published-package parity coverage for native core commands and align help output and symlinked project indexing with the bundled scripts.
12
+ - When Windows blocks writing the derived workers registry, `hq core` now reports a partial result instead of terminating on an uncaught filesystem error.
13
+ - Lane story ledger updates now keep state and status aligned, clear stale single-lane assignments, and use the canonical `review` state.
14
+ - Add `hq provider launch` for noninteractive Claude Code and Codex CLI runs. When either provider reports the known unauthenticated response, the launcher prints the matching sign-in command.
15
+
5
16
  ## [5.345.36] — 2026-10-07
6
17
  - A queued `hq sync` no longer pins the installed `hq` version while it waits its turn, so `hq pkg update` is no longer blocked for the length of somebody else's sync.
7
18
  - The flag-registry background refresh no longer throws as it starts, so running any `hq` command stops filing a crash report that nothing in the command caused.
@@ -44,7 +44,8 @@ case "${1:-}" in
44
44
  --check) MODE="check" ;;
45
45
  --prune-identical) MODE="prune" ;;
46
46
  -h|--help)
47
- sed -n '2,45p' "$0"
47
+ help_end="$(awk 'NR == 1 { next } /^#/ { last = NR; next } /^[[:space:]]*$/ { next } { exit } END { print last }' "$0")"
48
+ sed -n "2,${help_end}p" "$0"
48
49
  exit 0 ;;
49
50
  *)
50
51
  printf 'detect-stale-core-policy-mirror: unknown argument: %s\n' "$1" >&2
@@ -1286,6 +1286,40 @@ export declare const COMMAND_CATALOG: readonly [{
1286
1286
  readonly hidden: false;
1287
1287
  }];
1288
1288
  readonly subcommands: readonly [];
1289
+ }, {
1290
+ readonly name: "provider";
1291
+ readonly description: "Launch supported AI provider CLIs";
1292
+ readonly aliases: readonly [];
1293
+ readonly hidden: false;
1294
+ readonly helpOption: true;
1295
+ readonly helpCommand: true;
1296
+ readonly usage: "[options] [command]";
1297
+ readonly helpText: readonly [];
1298
+ readonly arguments: readonly [];
1299
+ readonly options: readonly [];
1300
+ readonly subcommands: readonly [{
1301
+ readonly name: "launch";
1302
+ readonly description: "Run a noninteractive Claude Code or Codex command with sign-in guidance";
1303
+ readonly aliases: readonly [];
1304
+ readonly hidden: false;
1305
+ readonly helpOption: true;
1306
+ readonly helpCommand: false;
1307
+ readonly usage: "[options] <name> [args...]";
1308
+ readonly helpText: readonly [];
1309
+ readonly arguments: readonly [{
1310
+ readonly name: "name";
1311
+ readonly required: true;
1312
+ readonly variadic: false;
1313
+ readonly description: "";
1314
+ }, {
1315
+ readonly name: "args";
1316
+ readonly required: false;
1317
+ readonly variadic: true;
1318
+ readonly description: "";
1319
+ }];
1320
+ readonly options: readonly [];
1321
+ readonly subcommands: readonly [];
1322
+ }];
1289
1323
  }, {
1290
1324
  readonly name: "version";
1291
1325
  readonly description: "Show HQ, CLI, and latest hq-core versions";
@@ -1542,6 +1542,46 @@ export const COMMAND_CATALOG = [
1542
1542
  ],
1543
1543
  "subcommands": []
1544
1544
  },
1545
+ {
1546
+ "name": "provider",
1547
+ "description": "Launch supported AI provider CLIs",
1548
+ "aliases": [],
1549
+ "hidden": false,
1550
+ "helpOption": true,
1551
+ "helpCommand": true,
1552
+ "usage": "[options] [command]",
1553
+ "helpText": [],
1554
+ "arguments": [],
1555
+ "options": [],
1556
+ "subcommands": [
1557
+ {
1558
+ "name": "launch",
1559
+ "description": "Run a noninteractive Claude Code or Codex command with sign-in guidance",
1560
+ "aliases": [],
1561
+ "hidden": false,
1562
+ "helpOption": true,
1563
+ "helpCommand": false,
1564
+ "usage": "[options] <name> [args...]",
1565
+ "helpText": [],
1566
+ "arguments": [
1567
+ {
1568
+ "name": "name",
1569
+ "required": true,
1570
+ "variadic": false,
1571
+ "description": ""
1572
+ },
1573
+ {
1574
+ "name": "args",
1575
+ "required": false,
1576
+ "variadic": true,
1577
+ "description": ""
1578
+ }
1579
+ ],
1580
+ "options": [],
1581
+ "subcommands": []
1582
+ }
1583
+ ]
1584
+ },
1545
1585
  {
1546
1586
  "name": "version",
1547
1587
  "description": "Show HQ, CLI, and latest hq-core versions",
@@ -201,6 +201,12 @@ export declare const REGISTRATION_PLAN: readonly [{
201
201
  readonly parent: "program";
202
202
  readonly module: "./commands/link.js";
203
203
  readonly exportName: "registerResolveCompanyCommand";
204
+ }, {
205
+ readonly type: "registrar";
206
+ readonly root: "provider";
207
+ readonly parent: "program";
208
+ readonly module: "./commands/provider.js";
209
+ readonly exportName: "registerProviderCommand";
204
210
  }, {
205
211
  readonly type: "registrar";
206
212
  readonly root: "version";
@@ -36,6 +36,7 @@ export const REGISTRATION_PLAN = [
36
36
  { type: "registrar", root: "unlink", parent: "program", module: "./commands/link.js", exportName: "registerUnlinkCommand" },
37
37
  { type: "registrar", root: "uninstall", parent: "program", module: "./commands/install-global.js", exportName: "registerUninstallCommand" },
38
38
  { type: "registrar", root: "resolve-company", parent: "program", module: "./commands/link.js", exportName: "registerResolveCompanyCommand" },
39
+ { type: "registrar", root: "provider", parent: "program", module: "./commands/provider.js", exportName: "registerProviderCommand" },
39
40
  { type: "registrar", root: "version", parent: "program", module: "./commands/version.js", exportName: "registerVersionCommand" },
40
41
  { type: "registrar", root: "auth", parent: "program", module: "./commands/auth.js", exportName: "registerAuthCommands" },
41
42
  { type: "registrar", root: "secrets", parent: "program", module: "./commands/secrets.js", exportName: "registerSecretsCommand" },
@@ -532,7 +532,7 @@ export function registerCoreCommands(program) {
532
532
  ? generateWorkersRegistry(hqRoot)
533
533
  : scanPackages(hqRoot, { quiet: process.env.HQ_SCAN_QUIET === "1" });
534
534
  if (result.status !== 0)
535
- process.exit(result.status);
535
+ process.exitCode = result.status;
536
536
  });
537
537
  describeCoreCommand(command, {
538
538
  execution: "native",
@@ -0,0 +1,7 @@
1
+ import { Command } from "commander";
2
+ import { type ProviderLauncherDependencies } from "../lib/provider-launcher.js";
3
+ export interface ProviderCommandDependencies extends ProviderLauncherDependencies {
4
+ setExitCode?: (code: number) => void;
5
+ }
6
+ export declare function registerProviderCommand(program: Command, dependencies?: ProviderCommandDependencies): void;
7
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { runProviderLauncher } from "../lib/provider-launcher.js";
2
+ export function registerProviderCommand(program, dependencies = {}) {
3
+ program
4
+ .command("provider")
5
+ .description("Launch supported AI provider CLIs")
6
+ .command("launch <name> [args...]")
7
+ .description("Run a noninteractive Claude Code or Codex command with sign-in guidance")
8
+ .allowUnknownOption()
9
+ .action(async (name, args) => {
10
+ const exitCode = await runProviderLauncher(name, args, dependencies);
11
+ (dependencies.setExitCode ?? ((code) => { process.exitCode = code; }))(exitCode);
12
+ });
13
+ }
14
+ //# sourceMappingURL=provider.js.map
@@ -164,7 +164,7 @@ export function tokenUsageReport(args = [], options = {}) {
164
164
  index += 2;
165
165
  }
166
166
  else if (arg === "-h" || arg === "--help") {
167
- stdout("token-usage-report.sh — Daily effective-token totals + top burners from Claude Code session JSONLs.\nUsage:\n scripts/token-usage-report.sh # last 7 days, table\n scripts/token-usage-report.sh --last 14 # last 14 days\n scripts/token-usage-report.sh --since 2026-05-01\n scripts/token-usage-report.sh --json # machine-readable\n scripts/token-usage-report.sh --project-dir <path> # use one Claude activity folder\nEffective tokens weighting (matches weekly rate-limit volume):\n input + 5×output + 1.25×cache_creation + 0.1×cache_read\n--- Compare-windows mode (US-011) ---\nFormat: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD\n");
167
+ stdout("token-usage-report.sh — Daily effective-token totals + top burners from Claude Code session JSONLs.\nUsage:\n scripts/token-usage-report.sh # last 7 days, table\n scripts/token-usage-report.sh --last 14 # last 14 days\n scripts/token-usage-report.sh --since 2026-05-01\n scripts/token-usage-report.sh --json # machine-readable\n scripts/token-usage-report.sh --project-dir <path> # read one activity folder\nEffective tokens weighting (matches weekly rate-limit volume):\n input + 5×output + 1.25×cache_creation + 0.1×cache_read\nResolve roots in the same precedence order as the native command. Explicit\nproject directories and environment overrides select one exact activity root;\notherwise include standard, desktop-saved, and discovered Claude roots.\n--- Compare-windows mode (US-011) ---\nFormat: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD\n");
168
168
  return 0;
169
169
  }
170
170
  else {
@@ -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 };
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.36",
3
+ "version": "5.345.38",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {