@indigoai-us/hq-cli 5.303.0 → 5.305.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/command-catalog.generated.d.ts +1 -1
  3. package/dist/command-catalog.generated.js +3 -1
  4. package/dist/command-registration-plan.js +8 -0
  5. package/dist/commands/lanes.d.ts +2 -0
  6. package/dist/commands/lanes.js +34 -6
  7. package/dist/commands/mcp-status.js +1 -0
  8. package/dist/commands/members.js +0 -1
  9. package/dist/commands/people.d.ts +3 -0
  10. package/dist/commands/people.js +27 -3
  11. package/dist/index.js +6 -1
  12. package/dist/lazy-commands.js +5 -2
  13. package/dist/lib/aws-sdk-node-warning-output.d.ts +12 -0
  14. package/dist/lib/aws-sdk-node-warning-output.js +22 -0
  15. package/dist/lib/company-errors-sdk-warning-flag.d.ts +5 -0
  16. package/dist/lib/company-errors-sdk-warning-flag.js +8 -0
  17. package/dist/lib/doctor/checks/work-context.js +2 -2
  18. package/dist/lib/doctor/deep/executor.d.ts +4 -10
  19. package/dist/lib/doctor/deep/executor.js +1 -1
  20. package/dist/lib/doctor/deep/parity.d.ts +2 -29
  21. package/dist/lib/doctor/deep/parity.js +7 -7
  22. package/dist/lib/doctor/deep/sandbox.d.ts +7 -25
  23. package/dist/lib/doctor/deep/sandbox.js +4 -4
  24. package/dist/lib/lanes/keep-alive.js +13 -2
  25. package/dist/lib/mesh/live/daemon/doctor.js +2 -2
  26. package/dist/lib/mesh/live/flush.d.ts +2 -2
  27. package/dist/lib/mesh/live/flush.js +49 -29
  28. package/dist/lib/mesh/live/spool.d.ts +6 -0
  29. package/dist/lib/mesh/live/spool.js +203 -4
  30. package/dist/lib/work-context/bind-best.js +13 -1
  31. package/dist/main.js +16 -3
  32. package/dist/utils/self-update.d.ts +4 -0
  33. package/dist/utils/self-update.js +46 -9
  34. package/dist/utils/version-check.d.ts +7 -0
  35. package/dist/utils/version-check.js +30 -1
  36. package/dist/utils/version-gate.d.ts +28 -10
  37. package/dist/utils/version-gate.js +159 -29
  38. package/dist/utils/version-report.d.ts +2 -1
  39. package/dist/utils/version-report.js +8 -1
  40. package/package.json +1 -1
  41. package/dist/lib/work-context/index.d.ts +0 -26
  42. package/dist/lib/work-context/index.js +0 -22
@@ -65,7 +65,7 @@ import chalk from "chalk";
65
65
  import { Sentry } from "../sentry.js";
66
66
  import { isCliTelemetryDisabled } from "./cli-telemetry.js";
67
67
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
68
- import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
68
+ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
69
69
  import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
70
70
  import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
71
71
  import { markLatestIneffective } from "./version-check.js";
@@ -659,10 +659,30 @@ async function updateAndReexec(argv, flavor, known, deps) {
659
659
  console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
660
660
  return { action: "skipped", latest };
661
661
  }
662
- const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
663
662
  const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
664
663
  if (!releaseLock)
665
664
  return { action: "skipped", latest };
665
+ if (install.manager === "npm" &&
666
+ install.prefix &&
667
+ install.packageRoot &&
668
+ !(deps.canWriteNpmInstall ?? ((resolved) => canWriteNpmInstall(resolved.prefix, resolved.packageRoot)))(install)) {
669
+ const claimed = flavor.noun === "daemon update"
670
+ ? false
671
+ : claimNonWritableNpmPrefixNotice(install, current, deps.homeDir);
672
+ if (flavor.noun === "rescue" || claimed) {
673
+ console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
674
+ }
675
+ await captureNonWritableNpmPrefixNotice({
676
+ fromVersion: current,
677
+ targetVersion: latestValid,
678
+ install,
679
+ env,
680
+ homeDir: deps.homeDir,
681
+ });
682
+ releaseLock();
683
+ return { action: "update-failed", latest };
684
+ }
685
+ const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
666
686
  const renamePath = deps.renamePath ?? fs.renameSync;
667
687
  // The in-place update moves the package aside, and the installer's
668
688
  // supervisor ships inside it, so run the installer through a copy.
@@ -732,12 +752,27 @@ async function updateAndReexec(argv, flavor, known, deps) {
732
752
  console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
733
753
  `${result.detail ? `: ${result.detail}` : ""}`));
734
754
  console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
735
- // A root-owned npm prefix the running user cannot write is the agent-box
736
- // case (the CLI is a /usr global install and the runtime is unprivileged),
737
- // and the startup path has no sudo fallback — say so plainly so a failed
738
- // update on a box reads as the permission wall it is, not a transient error.
739
- if (install.manager === "npm" && install.prefix && !isPrefixWritable(install.prefix)) {
740
- console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
755
+ // A permission wall discovered after npm fails is handled as an expected
756
+ // install-state warning. The preflight normally catches it before spawn;
757
+ // this check covers ACL changes and other races after the preflight.
758
+ const npmPrefixNotWritable = install.manager === "npm" &&
759
+ install.prefix &&
760
+ install.packageRoot &&
761
+ !(deps.canWriteNpmInstall ?? ((resolved) => canWriteNpmInstall(resolved.prefix, resolved.packageRoot)))(install);
762
+ if (npmPrefixNotWritable) {
763
+ const prefixNoticeClaimed = flavor.noun === "daemon update"
764
+ ? false
765
+ : claimNonWritableNpmPrefixNotice(install, current, deps.homeDir);
766
+ if (flavor.noun === "rescue" || prefixNoticeClaimed) {
767
+ console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
768
+ }
769
+ await captureNonWritableNpmPrefixNotice({
770
+ fromVersion: current,
771
+ targetVersion: latestValid,
772
+ install,
773
+ env,
774
+ homeDir: deps.homeDir,
775
+ });
741
776
  }
742
777
  // A manager-level failure often means the install layout itself is broken
743
778
  // (e.g. a hand-rolled pnpm store nested inside the app's bin dir). The
@@ -748,7 +783,9 @@ async function updateAndReexec(argv, flavor, known, deps) {
748
783
  console.error(chalk.dim(` Or repair the install: hq rescue`));
749
784
  }
750
785
  console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
751
- await captureSelfUpdateOutcome("update-failed", current, latestValid, install.manager, result, env, flavor.noun === "rescue");
786
+ if (!npmPrefixNotWritable) {
787
+ await captureSelfUpdateOutcome("update-failed", current, latestValid, install.manager, result, env, flavor.noun === "rescue");
788
+ }
752
789
  return { action: "update-failed", latest };
753
790
  }
754
791
  if (installSnapshot)
@@ -1,3 +1,4 @@
1
+ export declare const CLI_VERSION_NUDGE_FLAG = "cli.version-update-nudge";
1
2
  declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boolean;
2
3
  /**
3
4
  * The cached npm `latest` when it is newer than the running version, else null.
@@ -21,6 +22,12 @@ export interface RefreshVersionCacheOptions {
21
22
  timeoutMs?: number;
22
23
  }
23
24
  export declare function refreshVersionCache(options?: RefreshVersionCacheOptions): Promise<void>;
25
+ /** Write the optional update guidance from the existing npm version cache.
26
+ * This helper never performs a network request; a missing or stale cache means
27
+ * there is no nudge to show.
28
+ */
29
+ export declare function printCachedVersionNudge(stderr?: Pick<NodeJS.WriteStream, "write">): Promise<void>;
30
+ export declare function printVersionAndNudge(stdout?: Pick<NodeJS.WriteStream, "write">, stderr?: Pick<NodeJS.WriteStream, "write">): Promise<void>;
24
31
  export declare const __test__: {
25
32
  CACHE_TTL_MS: number;
26
33
  isKnownNoninteractiveStatusProbe: typeof isKnownNoninteractiveStatusProbe;
@@ -9,6 +9,7 @@ const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — catch fresh releases within the h
9
9
  const CACHE_TTL_JITTER_MS = 5 * 60 * 1000; // up to 5m early, to spread a fleet's refreshes off a single instant
10
10
  const FETCH_TIMEOUT_MS = 3_000;
11
11
  const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
12
+ export const CLI_VERSION_NUDGE_FLAG = "cli.version-update-nudge";
12
13
  /**
13
14
  * How long to stop auto-retrying an `ineffectiveLatest` target. Long enough to
14
15
  * break the per-invocation loop on a busy agent box, short enough that a box
@@ -67,7 +68,7 @@ function isKnownNoninteractiveStatusProbe(argv = process.argv) {
67
68
  const json = args.includes("--json") || !process.stdout.isTTY;
68
69
  if (!json)
69
70
  return false;
70
- if (positional[0] === "mcp" && positional[1] === "status")
71
+ if (positional[0] === "mcp" && (positional[1] === "status" || positional[1] === "list"))
71
72
  return true;
72
73
  if (positional[0] === "packs" && (positional[1] === "list" || positional[1] === "ls"))
73
74
  return true;
@@ -199,6 +200,34 @@ export async function refreshVersionCache(options = {}) {
199
200
  releaseLock();
200
201
  }
201
202
  }
203
+ /** Write the optional update guidance from the existing npm version cache.
204
+ * This helper never performs a network request; a missing or stale cache means
205
+ * there is no nudge to show.
206
+ */
207
+ export async function printCachedVersionNudge(stderr = process.stderr) {
208
+ if (isOptedOut())
209
+ return;
210
+ let resolveProcessFlagGate;
211
+ try {
212
+ ({ resolveProcessFlagGate } = await import("../lib/flag-registry.js"));
213
+ }
214
+ catch (error) {
215
+ const errorClass = error instanceof Error ? error.name : "unknown";
216
+ stderr.write(`hq: Update nudge unavailable (${errorClass}).\n`);
217
+ return;
218
+ }
219
+ const enabled = resolveProcessFlagGate(CLI_VERSION_NUDGE_FLAG, { fallback: false }, () => false);
220
+ if (!enabled)
221
+ return;
222
+ const latest = staleAgainstCachedLatest();
223
+ if (latest) {
224
+ stderr.write(`Update available: ${latest}. Update with npm install -g @indigoai-us/hq-cli@latest, pnpm add -g @indigoai-us/hq-cli@latest, or bun add -g @indigoai-us/hq-cli@latest.\n`);
225
+ }
226
+ }
227
+ export async function printVersionAndNudge(stdout = process.stdout, stderr = process.stderr) {
228
+ stdout.write(`${CLI_VERSION}\n`);
229
+ await printCachedVersionNudge(stderr);
230
+ }
202
231
  export const __test__ = {
203
232
  CACHE_TTL_MS,
204
233
  isKnownNoninteractiveStatusProbe,
@@ -151,9 +151,9 @@ export declare function resolveRunningManager(): InstallManager;
151
151
  /** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
152
152
  export declare function resolveRunningPrefix(): string | null;
153
153
  /**
154
- * Whether the current process can write the npm global `prefix` — i.e. whether
155
- * an `npm install -g --prefix <prefix>` could actually replace the installed
156
- * CLI, or would fail with EACCES.
154
+ * Whether the current process can write the npm global install targets — i.e.
155
+ * whether an `npm install -g --prefix <prefix>` could actually replace the
156
+ * installed CLI, or would fail with EACCES.
157
157
  *
158
158
  * The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
159
159
  * globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
@@ -167,13 +167,27 @@ export declare function resolveRunningPrefix(): string | null;
167
167
  */
168
168
  export declare function isPrefixWritable(prefix: string, access?: (target: string, mode: number) => void): boolean;
169
169
  /**
170
- * One-line operator explanation for a failed global update whose prefix the
171
- * running user cannot write. This is the agent-box case: the CLI is a
172
- * root-owned `/usr` global install and the runtime is unprivileged, so the
173
- * update genuinely cannot converge from here and re-trying it silently would
174
- * loop. Says so plainly and points at the paths that CAN update it.
170
+ * Check the directories an npm global update must mutate before spawning npm.
171
+ * Npm replaces the package under node_modules and writes executable links
172
+ * under prefix/bin. The package's parent must be writable because npm renames
173
+ * the old package there.
174
+ *
175
+ * When one of those directories has not been created yet, check its nearest
176
+ * existing ancestor: npm can create a child only if that ancestor is writable.
175
177
  */
176
- export declare function nonWritablePrefixNote(prefix: string): string;
178
+ export declare function canWriteNpmInstall(prefix: string, packageRoot: string, access?: (target: string, mode: number) => void): boolean;
179
+ /** Claim the one local warning/report for this installed version and prefix. */
180
+ export declare function claimNonWritableNpmPrefixNotice(install: Pick<RunningInstall, "manager" | "prefix" | "packageRoot">, fromVersion: string, homeDir?: string, kind?: "user" | "report"): boolean;
181
+ /** Emit one path-free warning event for a handled non-writable npm prefix. */
182
+ export declare function captureNonWritableNpmPrefixNotice(input: {
183
+ fromVersion: string;
184
+ targetVersion: string;
185
+ install: Pick<RunningInstall, "manager" | "prefix" | "packageRoot">;
186
+ env?: NodeJS.ProcessEnv;
187
+ homeDir?: string;
188
+ }): Promise<void>;
189
+ /** One-line repair guidance for a global npm install the current user cannot write. */
190
+ export declare function nonWritablePrefixNote(prefix: string, platform?: NodeJS.Platform): string;
177
191
  /**
178
192
  * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
179
193
  * global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
@@ -411,6 +425,8 @@ export declare function checkUpdateConvergence(targetVersion: string, deps?: {
411
425
  interface EnforceUpdateDeps {
412
426
  performUpdateString?: (command: string) => UpdateResult;
413
427
  resolveInstall?: () => RunningInstall;
428
+ canWriteNpmInstall?: (install: RunningInstall) => boolean;
429
+ homeDir?: string;
414
430
  runner?: UpdateRunner;
415
431
  platform?: NodeJS.Platform;
416
432
  cleanStale?: (prefix: string) => string[];
@@ -442,7 +458,7 @@ interface EnforceUpdateDeps {
442
458
  * A timed-out updater is the exception: it has been forcibly reaped, so the
443
459
  * gate returns and lets the user's command run on the current version.
444
460
  */
445
- declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void;
461
+ declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void | Promise<void>;
446
462
  /**
447
463
  * Public entry point. Call before commander parses argv. Blocks the CLI on
448
464
  * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
@@ -484,6 +500,8 @@ export declare const __test__: {
484
500
  buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
485
501
  buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
486
502
  buildSpawnPlan: typeof buildSpawnPlan;
503
+ canWriteNpmInstall: typeof canWriteNpmInstall;
504
+ claimNonWritableNpmPrefixNotice: typeof claimNonWritableNpmPrefixNotice;
487
505
  cleanStalePartialInstall: typeof cleanStalePartialInstall;
488
506
  derivePnpmHome: typeof derivePnpmHome;
489
507
  enforceUpdateRequired: typeof enforceUpdateRequired;
@@ -28,14 +28,17 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
  import { spawnSync } from "node:child_process";
31
+ import { createHash } from "node:crypto";
31
32
  import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
32
- import { accessSync, closeSync, constants as fsConstants, copyFileSync, existsSync, mkdtempSync, openSync, readdirSync, realpathSync, readFileSync, rmSync, } from "node:fs";
33
+ import { accessSync, closeSync, constants as fsConstants, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, realpathSync, readFileSync, rmSync, } from "node:fs";
33
34
  import os from "node:os";
34
35
  import path from "node:path";
35
36
  import { fileURLToPath } from "node:url";
36
37
  import chalk from "chalk";
37
38
  import semver from "semver";
38
39
  import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
40
+ import { Sentry } from "../sentry.js";
41
+ import { isCliTelemetryDisabled } from "./cli-telemetry.js";
39
42
  import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
40
43
  import { acquireUpdateLock, } from "./update-lock.js";
41
44
  const CLIENT_ID = "hq-cli";
@@ -385,9 +388,9 @@ export function resolveRunningPrefix() {
385
388
  return resolveRunningInstall().prefix;
386
389
  }
387
390
  /**
388
- * Whether the current process can write the npm global `prefix` — i.e. whether
389
- * an `npm install -g --prefix <prefix>` could actually replace the installed
390
- * CLI, or would fail with EACCES.
391
+ * Whether the current process can write the npm global install targets — i.e.
392
+ * whether an `npm install -g --prefix <prefix>` could actually replace the
393
+ * installed CLI, or would fail with EACCES.
391
394
  *
392
395
  * The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
393
396
  * globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
@@ -416,17 +419,119 @@ export function isPrefixWritable(prefix, access = (target, mode) => accessSync(t
416
419
  return false;
417
420
  }
418
421
  /**
419
- * One-line operator explanation for a failed global update whose prefix the
420
- * running user cannot write. This is the agent-box case: the CLI is a
421
- * root-owned `/usr` global install and the runtime is unprivileged, so the
422
- * update genuinely cannot converge from here and re-trying it silently would
423
- * loop. Says so plainly and points at the paths that CAN update it.
422
+ * Check the directories an npm global update must mutate before spawning npm.
423
+ * Npm replaces the package under node_modules and writes executable links
424
+ * under prefix/bin. The package's parent must be writable because npm renames
425
+ * the old package there.
426
+ *
427
+ * When one of those directories has not been created yet, check its nearest
428
+ * existing ancestor: npm can create a child only if that ancestor is writable.
424
429
  */
425
- export function nonWritablePrefixNote(prefix) {
426
- return (`The npm global prefix ${prefix} is not writable by the current user, so this update cannot ` +
427
- `take effect here. On an agent box this is expected: the CLI is a root-owned global install and ` +
428
- `updates land as root — via the box's hq-cli-update timer, or \`sudo npm install -g ${CLI_NAME}@latest\` — ` +
429
- `not from the unprivileged runtime.`);
430
+ export function canWriteNpmInstall(prefix, packageRoot, access = (target, mode) => accessSync(target, mode)) {
431
+ let nodeModulesRoot = path.join(prefix, process.platform === "win32" ? "node_modules" : "lib/node_modules");
432
+ let cursor = path.resolve(packageRoot);
433
+ while (true) {
434
+ if (path.basename(cursor).toLowerCase() === "node_modules") {
435
+ nodeModulesRoot = cursor;
436
+ break;
437
+ }
438
+ const parent = path.dirname(cursor);
439
+ if (parent === cursor)
440
+ break;
441
+ cursor = parent;
442
+ }
443
+ const mode = fsConstants.W_OK | fsConstants.X_OK;
444
+ // npm needs write access to the directories it replaces or links into. The
445
+ // prefix root itself need not be writable when those directories already
446
+ // exist; when one is missing, the nearest-existing-ancestor check below
447
+ // still verifies permission to create it.
448
+ const targets = [path.join(prefix, "bin"), nodeModulesRoot, path.dirname(packageRoot)];
449
+ return targets.every((target) => {
450
+ let candidate = path.resolve(target);
451
+ while (true) {
452
+ try {
453
+ access(candidate, mode);
454
+ return true;
455
+ }
456
+ catch (error) {
457
+ if (error?.code !== "ENOENT") {
458
+ return false;
459
+ }
460
+ const parent = path.dirname(candidate);
461
+ if (parent === candidate)
462
+ return false;
463
+ candidate = parent;
464
+ }
465
+ }
466
+ });
467
+ }
468
+ const nonWritablePrefixNotices = new Set();
469
+ /** Claim the one local warning/report for this installed version and prefix. */
470
+ export function claimNonWritableNpmPrefixNotice(install, fromVersion, homeDir = os.homedir(), kind = "user") {
471
+ const installIdentity = path.resolve(install.packageRoot ?? install.prefix ?? `${install.manager}:unknown-install`);
472
+ const identity = createHash("sha256")
473
+ .update(`${installIdentity}\0${fromVersion}`)
474
+ .digest("hex");
475
+ const localKey = path.join(homeDir, ".hq", "self-update", "prefix-not-writable", `${kind}-${identity}`);
476
+ if (nonWritablePrefixNotices.has(localKey))
477
+ return false;
478
+ try {
479
+ mkdirSync(path.dirname(localKey), { recursive: true, mode: 0o700 });
480
+ const descriptor = openSync(localKey, "wx", 0o600);
481
+ closeSync(descriptor);
482
+ nonWritablePrefixNotices.add(localKey);
483
+ return true;
484
+ }
485
+ catch (error) {
486
+ if (error?.code === "EEXIST") {
487
+ nonWritablePrefixNotices.add(localKey);
488
+ return false;
489
+ }
490
+ // Preserve the warning/report for this process if the home directory is
491
+ // unavailable. The marker is normally durable across CLI invocations.
492
+ console.error(chalk.dim("hq: could not persist the self-update permission notice rate limit."));
493
+ nonWritablePrefixNotices.add(localKey);
494
+ return true;
495
+ }
496
+ }
497
+ /** Emit one path-free warning event for a handled non-writable npm prefix. */
498
+ export function captureNonWritableNpmPrefixNotice(input) {
499
+ if (isCliTelemetryDisabled(input.env ?? process.env))
500
+ return Promise.resolve();
501
+ const claimed = claimNonWritableNpmPrefixNotice(input.install, input.fromVersion, input.homeDir, "report");
502
+ if (!claimed)
503
+ return Promise.resolve();
504
+ Sentry.captureMessage("Self-update deferred: npm prefix is not writable", {
505
+ level: "warning",
506
+ fingerprint: ["self-update", "npm-prefix-not-writable"],
507
+ tags: {
508
+ from_version: input.fromVersion,
509
+ target_version: input.targetVersion,
510
+ install_flavor: "npm",
511
+ error_class: "prefix_not_writable",
512
+ error_reason: "permission_denied",
513
+ },
514
+ });
515
+ return Sentry.flush(2_000).then((flushed) => {
516
+ if (!flushed) {
517
+ console.error(chalk.dim("hq: the self-update permission notice could not be sent to Sentry yet."));
518
+ }
519
+ });
520
+ }
521
+ /** One-line repair guidance for a global npm install the current user cannot write. */
522
+ export function nonWritablePrefixNote(prefix, platform = process.platform) {
523
+ const prefixArg = `'${prefix.replace(/'/g, "'\\''")}'`;
524
+ if (platform === "win32") {
525
+ const windowsPrefixArg = `"${prefix.replace(/"/g, '""')}"`;
526
+ return (`The npm global prefix ${prefix} is not writable by the current user, so hq-cli was not updated. ` +
527
+ `Open PowerShell as Administrator and run \`npm install -g --prefix ${windowsPrefixArg} ${CLI_NAME}@latest\`, ` +
528
+ `or set a user-owned prefix with \`npm config set prefix "$env:APPDATA\\npm"\` and run ` +
529
+ `\`npm install -g ${CLI_NAME}@latest\` from that PowerShell session.`);
530
+ }
531
+ return (`The npm global prefix ${prefix} is not writable by the current user, so hq-cli was not updated. ` +
532
+ `Run \`sudo npm install -g --prefix ${prefixArg} ${CLI_NAME}@latest\` to update this install, ` +
533
+ `or switch to a user-owned prefix with \`npm config set prefix ~/.npm-global\`, then run ` +
534
+ `\`npm install -g ${CLI_NAME}@latest\` after adding ~/.npm-global/bin to PATH.`);
430
535
  }
431
536
  /**
432
537
  * Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
@@ -1236,19 +1341,29 @@ function enforceUpdateRequired(decision, deps = {}) {
1236
1341
  console.error(chalk.dim(" Another hq updater is already installing (update lock held); continuing on the current version."));
1237
1342
  return;
1238
1343
  }
1239
- let exitCode;
1344
+ let attempt;
1240
1345
  try {
1241
- exitCode = attemptRequiredUpdate(decision, deps, install);
1346
+ attempt = attemptRequiredUpdate(decision, deps, install);
1242
1347
  }
1243
- finally {
1244
- // `process.exit` skips `finally` blocks, so the exit itself lives OUTSIDE
1245
- // the lock scope; this `finally` releases on the normal path and on any
1246
- // unexpected throw from the install attempt.
1348
+ catch (error) {
1247
1349
  lock.release();
1350
+ throw error;
1248
1351
  }
1249
- if (exitCode === "continue")
1352
+ if (attempt instanceof Promise) {
1353
+ return attempt.then((exitCode) => {
1354
+ lock.release();
1355
+ if (exitCode !== "continue")
1356
+ process.exit(exitCode);
1357
+ }, (error) => {
1358
+ lock.release();
1359
+ throw error;
1360
+ });
1361
+ }
1362
+ // `process.exit` skips `finally`, so release explicitly before exiting.
1363
+ lock.release();
1364
+ if (attempt === "continue")
1250
1365
  return;
1251
- process.exit(exitCode);
1366
+ process.exit(attempt);
1252
1367
  }
1253
1368
  /**
1254
1369
  * The install attempt itself (npm/pnpm/bun routing, stale-partial cleanup,
@@ -1290,6 +1405,7 @@ function attemptRequiredUpdate(decision, deps, install) {
1290
1405
  // npm/bun and when PNPM_HOME is already set.
1291
1406
  const updateEnv = pnpmUpdateEnv(install);
1292
1407
  let result;
1408
+ let noticeFlush = null;
1293
1409
  if (isManagedOutsideNpm) {
1294
1410
  console.error(chalk.dim(` Detected a ${install.manager}-managed global install; updating with ${install.manager}`));
1295
1411
  console.error(chalk.dim(` Running: ${primaryCmd} ${primaryArgs.join(" ")}`));
@@ -1360,12 +1476,22 @@ function attemptRequiredUpdate(decision, deps, install) {
1360
1476
  }
1361
1477
  if (!result.ok) {
1362
1478
  console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
1363
- // A root-owned global prefix the running user cannot write is the agent-box
1364
- // case: even the `sudo -n` retry above cannot help without passwordless
1365
- // sudo, so name the wall plainly rather than leaving `exit 75` to read as a
1366
- // generic failure. Only for npm-prefix installs (pnpm/Bun route elsewhere).
1367
- if (!isManagedOutsideNpm && prefix && !isPrefixWritable(prefix)) {
1479
+ // If the npm prefix is still unwritable after the unprivileged and sudo
1480
+ // attempts, explain the permission wall plainly instead of leaving exit 75
1481
+ // as a generic failure. Only applies to npm-managed installs.
1482
+ const npmPrefixNotWritable = !isManagedOutsideNpm &&
1483
+ prefix &&
1484
+ install.packageRoot &&
1485
+ !(deps.canWriteNpmInstall ?? ((resolved) => canWriteNpmInstall(resolved.prefix, resolved.packageRoot)))(install);
1486
+ if (npmPrefixNotWritable) {
1368
1487
  console.error(chalk.yellow(` ${nonWritablePrefixNote(prefix)}`));
1488
+ noticeFlush = captureNonWritableNpmPrefixNotice({
1489
+ fromVersion: CLI_VERSION,
1490
+ targetVersion: decision.latestVersion,
1491
+ install,
1492
+ env: process.env,
1493
+ homeDir: deps.homeDir,
1494
+ });
1369
1495
  }
1370
1496
  // The package manager itself is missing from this environment — the usual
1371
1497
  // cause is a minimal-PATH parent (launchd, cron, a bare systemd unit) that
@@ -1391,7 +1517,7 @@ function attemptRequiredUpdate(decision, deps, install) {
1391
1517
  console.error(chalk.dim(" Continuing on the current hq-cli version."));
1392
1518
  return "continue";
1393
1519
  }
1394
- return 75;
1520
+ return noticeFlush ? noticeFlush.then(() => 75) : 75;
1395
1521
  }
1396
1522
  // Read-your-writes: a "successful" install into the npm prefix does not
1397
1523
  // prove the user's PATH resolves it. Verify BEFORE announcing, so a ghost
@@ -1427,7 +1553,9 @@ export async function enforceVersionGate(onUpdateRecommended, options = {}) {
1427
1553
  : undefined;
1428
1554
  // Exits the process, EXCEPT when another updater holds the shared update
1429
1555
  // lock — then it returns and the CLI continues on the current version.
1430
- enforceUpdateRequired(decision, { resolveInstall: () => install, installNpmPackageStaged });
1556
+ const requiredUpdate = enforceUpdateRequired(decision, { resolveInstall: () => install, installNpmPackageStaged });
1557
+ if (requiredUpdate)
1558
+ await requiredUpdate;
1431
1559
  return "continue";
1432
1560
  }
1433
1561
  if (decision.updateRecommended) {
@@ -1468,6 +1596,8 @@ export const __test__ = {
1468
1596
  buildPnpmInstallArgv,
1469
1597
  buildPrefixedInstallArgv,
1470
1598
  buildSpawnPlan,
1599
+ canWriteNpmInstall,
1600
+ claimNonWritableNpmPrefixNotice,
1471
1601
  cleanStalePartialInstall,
1472
1602
  derivePnpmHome,
1473
1603
  enforceUpdateRequired,
@@ -13,6 +13,7 @@ export interface VersionReportDeps {
13
13
  hqRoot?: string;
14
14
  readHq?: (hqRoot: string) => string | null;
15
15
  fetchLatest?: () => Promise<string | null>;
16
+ printUpdateNudge?: (stderr: Pick<NodeJS.WriteStream, "write">) => Promise<void>;
16
17
  }
17
18
  export declare function normalizeHqCoreTag(tag: string): string | null;
18
19
  export declare function fetchLatestHqCoreRelease(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;
@@ -22,5 +23,5 @@ export declare function versionReportJson(report: VersionReport): string;
22
23
  export { isVersionRequest, wantsJson } from "../version-request.js";
23
24
  export declare function runVersionCommand(argv?: readonly string[], deps?: VersionReportDeps, stdout?: {
24
25
  write(chunk: string): unknown;
25
- }): Promise<void>;
26
+ }, stderr?: Pick<NodeJS.WriteStream, "write">): Promise<void>;
26
27
  //# sourceMappingURL=version-report.d.ts.map
@@ -91,8 +91,15 @@ export function versionReportJson(report) {
91
91
  }, null, 2)}\n`;
92
92
  }
93
93
  export { isVersionRequest, wantsJson } from "../version-request.js";
94
- export async function runVersionCommand(argv = process.argv, deps = {}, stdout = process.stdout) {
94
+ export async function runVersionCommand(argv = process.argv, deps = {}, stdout = process.stdout, stderr = process.stderr) {
95
95
  const report = await collectVersionReport(deps);
96
96
  stdout.write(wantsJson(argv) ? versionReportJson(report) : formatVersionReport(report));
97
+ if (!wantsJson(argv)) {
98
+ const printUpdateNudge = deps.printUpdateNudge ?? (async (target) => {
99
+ const { printCachedVersionNudge } = await import("./version-check.js");
100
+ await printCachedVersionNudge(target);
101
+ });
102
+ await printUpdateNudge(stderr);
103
+ }
97
104
  }
98
105
  //# sourceMappingURL=version-report.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.303.0",
3
+ "version": "5.305.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,26 +0,0 @@
1
- /**
2
- * Work Context reconciliation package (consumable by hq-cli).
3
- * Contract (US-001) + reconcile / outbox / device default (US-007A)
4
- * + organize / project resolution (US-007B)
5
- * + cross-company correct / migrate (US-017B).
6
- */
7
- export * from "./contract.js";
8
- export * from "./errors.js";
9
- export * from "./paths.js";
10
- export * from "./atomic.js";
11
- export * from "./config.js";
12
- export * from "./state.js";
13
- export * from "./outbox.js";
14
- export * from "./company.js";
15
- export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
16
- export * from "./project.js";
17
- export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
18
- export type { CandidatesResponse, FetchCandidatesFn, OrganizeCandidate, OrganizeListResult, OrganizeReceipt, OrganizeSubmitDecision, OrganizeSubmitResult, PostOrganizeFn, } from "./organize.js";
19
- export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
20
- export type { MigrateDestination, MigrateReceipt, MigrateSubmitResult, PostMigrateFn, } from "./migrate.js";
21
- export { deriveRemoteOwnerSlug, deriveRepoIdentityKey, findWorkTreeRoot, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
22
- export { matchMembershipAnswer, promptRepoCompany, } from "./repo-prompt.js";
23
- export type { RepoPromptCompany, RepoPromptDeps, RepoPromptOutcome, RepoPromptSkipReason, } from "./repo-prompt.js";
24
- export { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "./reconcile.js";
25
- export type { ReconcileDeps, ReconcileObservation, ReconcileOutcome, } from "./reconcile.js";
26
- //# sourceMappingURL=index.d.ts.map
@@ -1,22 +0,0 @@
1
- /**
2
- * Work Context reconciliation package (consumable by hq-cli).
3
- * Contract (US-001) + reconcile / outbox / device default (US-007A)
4
- * + organize / project resolution (US-007B)
5
- * + cross-company correct / migrate (US-017B).
6
- */
7
- export * from "./contract.js";
8
- export * from "./errors.js";
9
- export * from "./paths.js";
10
- export * from "./atomic.js";
11
- export * from "./config.js";
12
- export * from "./state.js";
13
- export * from "./outbox.js";
14
- export * from "./company.js";
15
- export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
16
- export * from "./project.js";
17
- export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
18
- export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
19
- export { deriveRemoteOwnerSlug, deriveRepoIdentityKey, findWorkTreeRoot, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
20
- export { matchMembershipAnswer, promptRepoCompany, } from "./repo-prompt.js";
21
- export { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "./reconcile.js";
22
- //# sourceMappingURL=index.js.map