@indigoai-us/hq-cli 5.95.0 → 5.96.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.96.0]
6
+
7
+ ### Changed
8
+
9
+ - The CLI now updates itself instead of nagging. When a newer version is
10
+ available, an ordinary command quietly installs it and re-runs on the new
11
+ version rather than printing "a new version is available" on every
12
+ invocation; the warning now appears only if the update itself fails, along
13
+ with the exact command to run by hand. hq-pro's soft "update recommended"
14
+ signal is handled the same way. Updates are serialized across concurrent
15
+ `hq` processes with a lock so a busy multi-agent box can't race
16
+ `npm install -g` into a broken partial install, and the package manager's
17
+ output is captured so it never corrupts a `--json` command's stdout. (#344)
18
+
19
+ ### Fixed
20
+
21
+ - `hq rescue` now brings the CLI itself up to the latest version (and re-runs
22
+ the rescue on it) before touching the HQ core. The rescue logic ships inside
23
+ the CLI, so an out-of-date CLI was running an out-of-date rescue. Skipped for
24
+ `--check` dry-runs and behind a new `--no-self-update` flag; best-effort, so
25
+ any update failure falls back to rescuing on the current version. (#344)
26
+
27
+ All self-update behavior honors the existing `HQ_NO_UPDATE_CHECK=1` opt-out.
28
+
5
29
  ## [5.94.3]
6
30
 
7
31
  ### Fixed
@@ -12,6 +12,12 @@
12
12
  * installed version (read from core/core.yaml). Staging (`--staging`): targets
13
13
  * `indigoai-us/hq-core-staging@main` and lets the script read its on-disk
14
14
  * sync stamp for the floor.
15
+ *
16
+ * Before touching the HQ root, rescue also self-updates hq-cli itself to npm
17
+ * latest and re-execs (`selfUpdateAndReexec`) so the bundled rescue script is
18
+ * current too — see utils/self-update.ts. Skipped for `--check` (a dry-run
19
+ * must not mutate the global install), `--no-self-update`, and
20
+ * `HQ_NO_UPDATE_CHECK=1`; always best-effort.
15
21
  */
16
22
  import { Command } from 'commander';
17
23
  export interface RescueTarget {
@@ -29,5 +35,15 @@ export declare function resolveRescueTarget(opts: {
29
35
  source?: string;
30
36
  ref?: string;
31
37
  }, latestTag?: string): RescueTarget;
38
+ /**
39
+ * Whether this invocation should try the CLI self-update. Pure + exported for
40
+ * tests. A dry-run (`--check`) must not mutate the global install, and
41
+ * commander maps `--no-self-update` to `selfUpdate: false`. Env-based opt-outs
42
+ * (HQ_NO_UPDATE_CHECK, the re-exec guard) live inside selfUpdateAndReexec.
43
+ */
44
+ export declare function shouldAttemptSelfUpdate(opts: {
45
+ check?: boolean;
46
+ selfUpdate?: boolean;
47
+ }): boolean;
32
48
  export declare function registerRescueCommand(program: Command): void;
33
49
  //# sourceMappingURL=rescue.d.ts.map
@@ -5,6 +5,7 @@ import * as yaml from 'js-yaml';
5
5
  import chalk from 'chalk';
6
6
  import { rescue } from '@indigoai-us/hq-cloud';
7
7
  import { findHqRoot } from '../utils/manifest.js';
8
+ import { selfUpdateAndReexec } from '../utils/self-update.js';
8
9
  const PROD_SOURCE = 'indigoai-us/hq-core';
9
10
  const STAGING_SOURCE = 'indigoai-us/hq-core-staging';
10
11
  /**
@@ -18,6 +19,15 @@ export function resolveRescueTarget(opts, latestTag) {
18
19
  }
19
20
  return { source: opts.source ?? PROD_SOURCE, ref: opts.ref ?? latestTag };
20
21
  }
22
+ /**
23
+ * Whether this invocation should try the CLI self-update. Pure + exported for
24
+ * tests. A dry-run (`--check`) must not mutate the global install, and
25
+ * commander maps `--no-self-update` to `selfUpdate: false`. Env-based opt-outs
26
+ * (HQ_NO_UPDATE_CHECK, the re-exec guard) live inside selfUpdateAndReexec.
27
+ */
28
+ export function shouldAttemptSelfUpdate(opts) {
29
+ return opts.selfUpdate !== false && !opts.check;
30
+ }
21
31
  /** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
22
32
  function resolveGhToken() {
23
33
  try {
@@ -105,9 +115,20 @@ export function registerRescueCommand(program) {
105
115
  .option('--check', 'Plan only — classify and report, change nothing on disk (--dry-run)')
106
116
  .option('-y, --yes', 'Skip the confirmation prompt')
107
117
  .option('--no-backup', 'Skip the pre-op safety snapshot under ~/.hq/backups')
118
+ .option('--no-self-update', 'Skip updating hq-cli itself to the latest version first')
108
119
  .option('--cloud-update', 'Cloud-update mode')
109
120
  .action(async (opts) => {
110
121
  try {
122
+ // Bring the CLI itself to latest first: the rescue script is bundled
123
+ // with this install, so an outdated CLI would run an outdated rescue.
124
+ // On a successful update the rescue re-runs on the new version and we
125
+ // exit with its status; on any failure we continue on this version.
126
+ if (shouldAttemptSelfUpdate(opts)) {
127
+ const outcome = await selfUpdateAndReexec(process.argv);
128
+ if (outcome.action === 'reexec') {
129
+ process.exit(outcome.reexecStatus ?? 0);
130
+ }
131
+ }
111
132
  const hqRoot = opts.hqRoot ?? findHqRoot();
112
133
  const token = resolveGhToken();
113
134
  let { source, ref } = resolveRescueTarget(opts);
package/dist/main.js CHANGED
@@ -67,8 +67,9 @@ import { isEpipe } from "./utils/epipe.js";
67
67
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
68
68
  import { isAuthError } from "./utils/auth-error.js";
69
69
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
70
- import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
70
+ import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
71
71
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
72
+ import { autoUpdateAndReexec } from "./utils/self-update.js";
72
73
  import { CLI_VERSION } from "./cli-version.js";
73
74
  import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
74
75
  import { settleWithin } from "./utils/settle-with-timeout.js";
@@ -91,7 +92,6 @@ const onPipeError = (err) => {
91
92
  process.stdout.on("error", onPipeError);
92
93
  process.stderr.on("error", onPipeError);
93
94
  initSentry();
94
- maybeWarnNewVersion();
95
95
  const program = new Command();
96
96
  program
97
97
  .name("hq")
@@ -239,19 +239,46 @@ program.hook("preAction", async () => {
239
239
  await emitCliSessionStarted();
240
240
  });
241
241
  export async function runCli() {
242
+ // Set when a self-update re-exec'd this command on a newer CLI: the child
243
+ // already did the work, so this process only has to carry its exit status
244
+ // out (after the finally block's telemetry, hence not process.exit here).
245
+ let reexecStatus = null;
242
246
  try {
243
247
  Sentry.addBreadcrumb({
244
248
  category: "command",
245
249
  message: sanitizeArgv(process.argv.slice(2)).join(" "),
246
250
  level: "info",
247
251
  });
248
- // Hard version gate: ask hq-pro whether this CLI is below the floor and
249
- // auto-update if so (exits the process on update). Skipped for inspection
250
- // flags (`--version`, `--help`) so users debugging a broken install can
251
- // still introspect what they have. Silent on any failure never blocks
252
- // the CLI on a flaky network or hq-pro hiccup. See `utils/version-gate.ts`.
252
+ // Version handling, both halves. Skipped for inspection flags
253
+ // (`--version`, `--help`) so users debugging a broken install can still
254
+ // introspect what they have, and silent on any failure — neither half may
255
+ // block the CLI on a flaky network or an hq-pro hiccup.
256
+ //
257
+ // 1. Hard gate: hq-pro says this version is below the enforced floor →
258
+ // update and exit (the user reruns). See `utils/version-gate.ts`.
259
+ // 2. Soft: we're merely behind npm `latest` → update in place and re-run
260
+ // this exact command on the new version, so the user never sees the
261
+ // old "a new version is available" nag. Two independent signals feed
262
+ // it (hq-pro's `updateRecommended` and the cached npm latest from
263
+ // `version-check.ts`); whichever fires first re-execs, and the child
264
+ // carries a guard env so it can never update again.
253
265
  if (!shouldSkipGate(process.argv)) {
254
- await enforceVersionGate();
266
+ const gate = await enforceVersionGate(async (decision) => {
267
+ const outcome = await autoUpdateAndReexec(process.argv, decision.latestVersion);
268
+ if (outcome.action === "reexec")
269
+ reexecStatus = outcome.reexecStatus ?? 0;
270
+ return outcome.action === "reexec";
271
+ });
272
+ if (gate === "reexec")
273
+ return;
274
+ const cachedLatest = staleAgainstCachedLatest();
275
+ if (cachedLatest) {
276
+ const outcome = await autoUpdateAndReexec(process.argv, cachedLatest);
277
+ if (outcome.action === "reexec") {
278
+ reexecStatus = outcome.reexecStatus ?? 0;
279
+ return;
280
+ }
281
+ }
255
282
  }
256
283
  await program.parseAsync();
257
284
  }
@@ -269,6 +296,10 @@ export async function runCli() {
269
296
  // command has completed, so a bounded best-effort wait is the terminal
270
297
  // lifecycle boundary for this invocation.
271
298
  await settleWithin([refreshVersionCache(), Sentry.flush(2000)], RELEASE_HEALTH_SETTLE_TIMEOUT_MS);
299
+ // Last, so it wins over anything the (skipped) command path would have
300
+ // set: the re-exec'd child's status IS this invocation's result.
301
+ if (reexecStatus !== null)
302
+ process.exitCode = reexecStatus;
272
303
  }
273
304
  }
274
305
  const defaultTopLevelErrorDependencies = {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * CLI self-update: replace the running hq-cli global install with npm `latest`
3
+ * and re-exec the user's command so it runs on the fresh version.
4
+ *
5
+ * Two entry points, one mechanism:
6
+ *
7
+ * - `autoUpdateAndReexec` — the default startup path. Previously the CLI just
8
+ * printed "⚠ A new version is available" on every single command; now it
9
+ * performs the update and re-runs the command, and only warns when the
10
+ * update itself fails. Installs quietly (the package manager's stdout is
11
+ * captured, never forwarded) because `--json` consumers parse ours.
12
+ *
13
+ * - `selfUpdateAndReexec` — `hq rescue`. The rescue script ships inside this
14
+ * install's `@indigoai-us/hq-cloud` dependency, so a stale CLI runs a stale
15
+ * rescue; rescue therefore updates unconditionally-if-stale rather than
16
+ * waiting for the cached npm signal, and shows the install output because
17
+ * the user is watching a long recovery operation.
18
+ *
19
+ * Relationship to the other two update surfaces:
20
+ * - `version-gate.ts` hard-updates when hq-pro reports the version is below
21
+ * the enforced `minVersion` (and now delegates its softer
22
+ * "updateRecommended" case here instead of nagging).
23
+ * - `version-check.ts` maintains the cached npm `latest` that the startup
24
+ * path reads, so the common case costs a small file read, not a fetch.
25
+ *
26
+ * Best-effort by design: registry unreachable, npm/pnpm missing, install
27
+ * failure, or `hq` not on PATH for the re-exec all degrade to running the
28
+ * command on the current version. Self-updating must never make a command less
29
+ * available than it was before.
30
+ *
31
+ * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
32
+ * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
33
+ */
34
+ import { type RunningInstall, type UpdateResult } from "./version-gate.js";
35
+ /**
36
+ * Set on the re-exec'd child so it can never self-update (and re-exec) again.
37
+ * One update + one re-exec per user invocation, ever.
38
+ */
39
+ export declare const REEXEC_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
40
+ export type SelfUpdateAction =
41
+ /** Opted out, guard set, lock held elsewhere, or latest unknown — nothing attempted. */
42
+ "skipped"
43
+ /** Already at (or ahead of) npm latest. */
44
+ | "current"
45
+ /** An update was attempted and failed; continue on the current version. */
46
+ | "update-failed"
47
+ /** Updated, but the re-exec couldn't start; continue on the current (in-memory) version. */
48
+ | "updated-no-reexec"
49
+ /** Updated and the command re-ran on the new version; exit with `reexecStatus`. */
50
+ | "reexec";
51
+ export interface SelfUpdateOutcome {
52
+ action: SelfUpdateAction;
53
+ /** Exit status of the re-exec'd `hq …` (action === "reexec"). */
54
+ reexecStatus?: number;
55
+ latest?: string;
56
+ }
57
+ /**
58
+ * The manager-aware install argv for this layout — same routing as the hard
59
+ * gate: a pnpm-managed install must be updated by pnpm (npm would drop a copy
60
+ * the pnpm shim never reads), and an npm install goes through the resolved
61
+ * prefix so the copy that is actually running is the one replaced.
62
+ */
63
+ export declare function buildSelfUpdatePlan(install: RunningInstall): {
64
+ cmd: string;
65
+ args: string[];
66
+ };
67
+ /**
68
+ * Install without letting the package manager write to OUR stdout. The startup
69
+ * path runs ahead of every command, including `--json` ones whose stdout is
70
+ * parsed by scripts and by HQ itself, and `npm install -g` prints its summary
71
+ * to stdout. Progress is summarised on stderr by the caller instead; captured
72
+ * stderr is kept only to explain a failure.
73
+ */
74
+ export declare function runUpdateQuiet(cmd: string, args: string[]): UpdateResult;
75
+ /**
76
+ * Serialize self-updates across concurrent `hq` processes. Without this, a
77
+ * machine running several HQ agents can fire many `npm install -g` at the same
78
+ * global prefix at once, and the losers fail with ENOTEMPTY mid-rename — the
79
+ * exact partial-install state `cleanStalePartialInstall` exists to repair.
80
+ * A caller that cannot take the lock simply skips its update: another process
81
+ * is already installing the very version it wanted.
82
+ *
83
+ * `mkdir` is the atomic primitive (same approach as version-check's refresh
84
+ * lock); a lock left behind by a killed process goes stale and is reclaimed.
85
+ */
86
+ export declare function acquireUpdateLock(now?: number): (() => void) | null;
87
+ /** Injectable surface so the flow is unit-testable without network or spawns. */
88
+ export interface SelfUpdateDeps {
89
+ env?: NodeJS.ProcessEnv;
90
+ currentVersion?: string;
91
+ fetchLatest?: () => Promise<string | null>;
92
+ resolveInstall?: () => RunningInstall;
93
+ runner?: (cmd: string, args: string[]) => UpdateResult;
94
+ reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
95
+ acquireLock?: () => (() => void) | null;
96
+ }
97
+ /**
98
+ * Startup path: the running CLI is behind npm `latest`, so update in place and
99
+ * re-run the user's command on the new version. `latest` is normally the cached
100
+ * value from `version-check.ts` (no fetch on the hot path); pass null to let
101
+ * this resolve it from the registry.
102
+ */
103
+ export declare function autoUpdateAndReexec(argv: readonly string[], latest: string | null, deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
104
+ /**
105
+ * Rescue path: update to npm latest and re-exec the rescue on the new version.
106
+ * `argv` is the full process argv (`process.argv`); the re-exec re-runs
107
+ * `argv.slice(2)` verbatim so flags like `--staging` / `--yes` / `--hq-root`
108
+ * are preserved.
109
+ */
110
+ export declare function selfUpdateAndReexec(argv: readonly string[], deps?: SelfUpdateDeps): Promise<SelfUpdateOutcome>;
111
+ //# sourceMappingURL=self-update.d.ts.map
@@ -0,0 +1,247 @@
1
+ /**
2
+ * CLI self-update: replace the running hq-cli global install with npm `latest`
3
+ * and re-exec the user's command so it runs on the fresh version.
4
+ *
5
+ * Two entry points, one mechanism:
6
+ *
7
+ * - `autoUpdateAndReexec` — the default startup path. Previously the CLI just
8
+ * printed "⚠ A new version is available" on every single command; now it
9
+ * performs the update and re-runs the command, and only warns when the
10
+ * update itself fails. Installs quietly (the package manager's stdout is
11
+ * captured, never forwarded) because `--json` consumers parse ours.
12
+ *
13
+ * - `selfUpdateAndReexec` — `hq rescue`. The rescue script ships inside this
14
+ * install's `@indigoai-us/hq-cloud` dependency, so a stale CLI runs a stale
15
+ * rescue; rescue therefore updates unconditionally-if-stale rather than
16
+ * waiting for the cached npm signal, and shows the install output because
17
+ * the user is watching a long recovery operation.
18
+ *
19
+ * Relationship to the other two update surfaces:
20
+ * - `version-gate.ts` hard-updates when hq-pro reports the version is below
21
+ * the enforced `minVersion` (and now delegates its softer
22
+ * "updateRecommended" case here instead of nagging).
23
+ * - `version-check.ts` maintains the cached npm `latest` that the startup
24
+ * path reads, so the common case costs a small file read, not a fetch.
25
+ *
26
+ * Best-effort by design: registry unreachable, npm/pnpm missing, install
27
+ * failure, or `hq` not on PATH for the re-exec all degrade to running the
28
+ * command on the current version. Self-updating must never make a command less
29
+ * available than it was before.
30
+ *
31
+ * Opt-outs: `HQ_NO_UPDATE_CHECK=1` (the shared knob that also silences
32
+ * version-check), `hq rescue --no-self-update`, and the re-exec guard env.
33
+ */
34
+ import { spawnSync } from "node:child_process";
35
+ import * as fs from "node:fs";
36
+ import * as os from "node:os";
37
+ import * as path from "node:path";
38
+ import semver from "semver";
39
+ import chalk from "chalk";
40
+ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
41
+ import { buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
42
+ /**
43
+ * Set on the re-exec'd child so it can never self-update (and re-exec) again.
44
+ * One update + one re-exec per user invocation, ever.
45
+ */
46
+ export const REEXEC_GUARD_ENV = "HQ_RESCUE_SELF_UPDATED";
47
+ const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
48
+ const FETCH_TIMEOUT_MS = 3_000;
49
+ /** A held update lock older than this is treated as abandoned (crashed owner). */
50
+ const UPDATE_LOCK_STALE_MS = 10 * 60 * 1000;
51
+ /** Tail of captured package-manager stderr kept for the failure warning. */
52
+ const DETAIL_MAX_CHARS = 400;
53
+ /** npm `latest` for this package, or null on any failure (offline, 5xx, bad body). */
54
+ async function fetchLatestVersion() {
55
+ try {
56
+ const res = await fetch(REGISTRY_URL, {
57
+ headers: { Accept: "application/json" },
58
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
59
+ });
60
+ if (!res.ok)
61
+ return null;
62
+ const body = (await res.json());
63
+ return typeof body.version === "string" ? body.version : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * The manager-aware install argv for this layout — same routing as the hard
71
+ * gate: a pnpm-managed install must be updated by pnpm (npm would drop a copy
72
+ * the pnpm shim never reads), and an npm install goes through the resolved
73
+ * prefix so the copy that is actually running is the one replaced.
74
+ */
75
+ export function buildSelfUpdatePlan(install) {
76
+ if (install.manager === "pnpm")
77
+ return { cmd: "pnpm", args: buildPnpmInstallArgv() };
78
+ if (install.prefix)
79
+ return { cmd: "npm", args: buildPrefixedInstallArgv(install.prefix) };
80
+ return { cmd: "npm", args: ["install", "-g", `${CLI_NAME}@latest`] };
81
+ }
82
+ /**
83
+ * Install without letting the package manager write to OUR stdout. The startup
84
+ * path runs ahead of every command, including `--json` ones whose stdout is
85
+ * parsed by scripts and by HQ itself, and `npm install -g` prints its summary
86
+ * to stdout. Progress is summarised on stderr by the caller instead; captured
87
+ * stderr is kept only to explain a failure.
88
+ */
89
+ export function runUpdateQuiet(cmd, args) {
90
+ try {
91
+ const plan = buildSpawnPlan(cmd, args);
92
+ const result = spawnSync(plan.cmd, plan.args, {
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ shell: plan.shell,
95
+ encoding: "utf-8",
96
+ });
97
+ if (result.error) {
98
+ const code = result.error.code;
99
+ return { ok: false, code, detail: result.error.message };
100
+ }
101
+ if (result.status !== 0) {
102
+ const stderr = (result.stderr ?? "").trim();
103
+ const tail = stderr ? stderr.slice(-DETAIL_MAX_CHARS) : "";
104
+ return {
105
+ ok: false,
106
+ detail: tail || `exit ${result.status ?? "signal"}`,
107
+ };
108
+ }
109
+ return { ok: true };
110
+ }
111
+ catch (err) {
112
+ return {
113
+ ok: false,
114
+ code: err?.code,
115
+ detail: err instanceof Error ? err.message : String(err),
116
+ };
117
+ }
118
+ }
119
+ function lockDir() {
120
+ return path.join(os.homedir(), ".hq", "self-update.lock");
121
+ }
122
+ /**
123
+ * Serialize self-updates across concurrent `hq` processes. Without this, a
124
+ * machine running several HQ agents can fire many `npm install -g` at the same
125
+ * global prefix at once, and the losers fail with ENOTEMPTY mid-rename — the
126
+ * exact partial-install state `cleanStalePartialInstall` exists to repair.
127
+ * A caller that cannot take the lock simply skips its update: another process
128
+ * is already installing the very version it wanted.
129
+ *
130
+ * `mkdir` is the atomic primitive (same approach as version-check's refresh
131
+ * lock); a lock left behind by a killed process goes stale and is reclaimed.
132
+ */
133
+ export function acquireUpdateLock(now = Date.now()) {
134
+ const dir = lockDir();
135
+ const release = () => {
136
+ try {
137
+ fs.rmSync(dir, { recursive: true, force: true });
138
+ }
139
+ catch {
140
+ // best-effort lock cleanup
141
+ }
142
+ };
143
+ try {
144
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
145
+ fs.mkdirSync(dir);
146
+ return release;
147
+ }
148
+ catch {
149
+ try {
150
+ const stat = fs.statSync(dir);
151
+ if (now - stat.mtimeMs > UPDATE_LOCK_STALE_MS) {
152
+ fs.rmSync(dir, { recursive: true, force: true });
153
+ fs.mkdirSync(dir);
154
+ return release;
155
+ }
156
+ }
157
+ catch {
158
+ // lock vanished or is unreadable — treat as held and skip
159
+ }
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * Re-run `hq <argv…>` from PATH so the freshly-installed version handles the
165
+ * command. Returns the child's exit status, or null when the child could not be
166
+ * started at all (e.g. `hq` not on PATH in a minimal-PATH parent).
167
+ */
168
+ function reexecHq(argv, env) {
169
+ const plan = buildSpawnPlan("hq", argv);
170
+ const result = spawnSync(plan.cmd, plan.args, {
171
+ stdio: "inherit",
172
+ shell: plan.shell,
173
+ env,
174
+ });
175
+ if (result.error)
176
+ return null;
177
+ // A signal-killed child has status null; surface it as a failure exit rather
178
+ // than pretending the command completed.
179
+ return result.status ?? 1;
180
+ }
181
+ async function updateAndReexec(argv, flavor, known, deps) {
182
+ const env = deps.env ?? process.env;
183
+ if (env[REEXEC_GUARD_ENV] === "1" || env.HQ_NO_UPDATE_CHECK === "1") {
184
+ return { action: "skipped" };
185
+ }
186
+ const latest = known ?? (await (deps.fetchLatest ?? fetchLatestVersion)());
187
+ if (!latest)
188
+ return { action: "skipped" };
189
+ const current = semver.valid(deps.currentVersion ?? CLI_VERSION);
190
+ const latestValid = semver.valid(latest);
191
+ if (!current || !latestValid)
192
+ return { action: "skipped" };
193
+ if (!semver.gt(latestValid, current))
194
+ return { action: "current", latest };
195
+ const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
196
+ if (!releaseLock)
197
+ return { action: "skipped", latest };
198
+ let result;
199
+ const install = (deps.resolveInstall ?? resolveRunningInstall)();
200
+ const plan = buildSelfUpdatePlan(install);
201
+ try {
202
+ console.error(chalk.dim(`Updating hq-cli ${current} → ${latest}…`));
203
+ const defaultRunner = flavor.verbose ? runUpdateCommand : runUpdateQuiet;
204
+ result = (deps.runner ?? defaultRunner)(plan.cmd, plan.args);
205
+ }
206
+ finally {
207
+ releaseLock();
208
+ }
209
+ if (!result.ok) {
210
+ // The one case that still warrants the old yellow banner: we could not get
211
+ // the user onto the new version, so they need to know it exists and how to
212
+ // install it by hand.
213
+ console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
214
+ `${result.detail ? `: ${result.detail}` : ""}`));
215
+ console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
216
+ console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
217
+ return { action: "update-failed", latest };
218
+ }
219
+ const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
220
+ const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
221
+ if (status === null) {
222
+ console.error(chalk.yellow(`⚠ Updated hq-cli to ${latest}, but couldn't re-launch \`hq\` from PATH; ` +
223
+ `continuing the ${flavor.noun} on ${current}.`));
224
+ return { action: "updated-no-reexec", latest };
225
+ }
226
+ console.error(chalk.dim(`✓ hq-cli updated to ${latest}.`));
227
+ return { action: "reexec", reexecStatus: status, latest };
228
+ }
229
+ /**
230
+ * Startup path: the running CLI is behind npm `latest`, so update in place and
231
+ * re-run the user's command on the new version. `latest` is normally the cached
232
+ * value from `version-check.ts` (no fetch on the hot path); pass null to let
233
+ * this resolve it from the registry.
234
+ */
235
+ export async function autoUpdateAndReexec(argv, latest, deps = {}) {
236
+ return updateAndReexec(argv, { noun: "command", verbose: false, lock: true }, latest, deps);
237
+ }
238
+ /**
239
+ * Rescue path: update to npm latest and re-exec the rescue on the new version.
240
+ * `argv` is the full process argv (`process.argv`); the re-exec re-runs
241
+ * `argv.slice(2)` verbatim so flags like `--staging` / `--yes` / `--hq-root`
242
+ * are preserved.
243
+ */
244
+ export async function selfUpdateAndReexec(argv, deps = {}) {
245
+ return updateAndReexec(argv, { noun: "rescue", verbose: true, lock: true }, null, deps);
246
+ }
247
+ //# sourceMappingURL=self-update.js.map
@@ -1,5 +1,15 @@
1
1
  declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boolean;
2
- export declare function maybeWarnNewVersion(): void;
2
+ /**
3
+ * The cached npm `latest` when it is newer than the running version, else null.
4
+ *
5
+ * This replaced `maybeWarnNewVersion`, which printed a yellow "a new version is
6
+ * available" banner on EVERY command. The signal is the same (a fresh cache
7
+ * entry written by `refreshVersionCache` on a previous run, so the hot path
8
+ * stays a single file read); what changed is what the CLI does with it — see
9
+ * `utils/self-update.ts`, which installs the new version and re-runs the
10
+ * command, and warns only if that fails.
11
+ */
12
+ export declare function staleAgainstCachedLatest(now?: number): string | null;
3
13
  export declare function refreshVersionCache(): Promise<void>;
4
14
  export declare const __test__: {
5
15
  CACHE_TTL_MS: number;
@@ -2,7 +2,6 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import semver from "semver";
5
- import chalk from "chalk";
6
5
  import { CLI_VERSION } from "../cli-version.js";
7
6
  const PACKAGE_NAME = "@indigoai-us/hq-cli";
8
7
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
@@ -96,22 +95,31 @@ function acquireRefreshLock(now = Date.now()) {
96
95
  return null;
97
96
  }
98
97
  }
99
- export function maybeWarnNewVersion() {
98
+ /**
99
+ * The cached npm `latest` when it is newer than the running version, else null.
100
+ *
101
+ * This replaced `maybeWarnNewVersion`, which printed a yellow "a new version is
102
+ * available" banner on EVERY command. The signal is the same (a fresh cache
103
+ * entry written by `refreshVersionCache` on a previous run, so the hot path
104
+ * stays a single file read); what changed is what the CLI does with it — see
105
+ * `utils/self-update.ts`, which installs the new version and re-runs the
106
+ * command, and warns only if that fails.
107
+ */
108
+ export function staleAgainstCachedLatest(now = Date.now()) {
100
109
  if (isOptedOut())
101
- return;
110
+ return null;
102
111
  const entry = readCache();
103
112
  if (!entry)
104
- return;
105
- if (Date.now() - entry.fetchedAt > CACHE_TTL_MS)
106
- return;
113
+ return null;
114
+ if (now - entry.fetchedAt > CACHE_TTL_MS)
115
+ return null;
107
116
  const current = semver.valid(CLI_VERSION);
108
117
  const latest = semver.valid(entry.latest);
109
118
  if (!current || !latest)
110
- return;
119
+ return null;
111
120
  if (!semver.gt(latest, current))
112
- return;
113
- const msg = chalk.yellow(`⚠ A new version of hq is available: ${entry.latest} (current: ${CLI_VERSION}). It's recommended to update.`);
114
- console.error(msg);
121
+ return null;
122
+ return entry.latest;
115
123
  }
116
124
  export async function refreshVersionCache() {
117
125
  if (isOptedOut())
@@ -29,7 +29,7 @@
29
29
  */
30
30
  /** Which package manager owns the running global install. */
31
31
  export type InstallManager = "npm" | "pnpm";
32
- interface VersionCheckResponse {
32
+ export interface VersionCheckResponse {
33
33
  clientId: string;
34
34
  currentVersion: string;
35
35
  minVersion: string;
@@ -140,7 +140,7 @@ export declare function cleanStalePartialInstall(prefix: string, fs?: StaleInsta
140
140
  * forcing a re-invocation would run twice on the same process and feel
141
141
  * janky; instead we print a clear "rerun your command" message and exit.
142
142
  */
143
- type UpdateResult = {
143
+ export type UpdateResult = {
144
144
  ok: boolean;
145
145
  detail?: string;
146
146
  code?: string;
@@ -166,7 +166,7 @@ export declare function buildSpawnPlan(cmd: string, args: readonly string[], pla
166
166
  args: string[];
167
167
  shell: boolean;
168
168
  };
169
- declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
169
+ export declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
170
170
  declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner): UpdateResult;
171
171
  declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
172
172
  /**
@@ -176,6 +176,11 @@ declare function performUpdate(command: string, runner?: UpdateRunner): UpdateRe
176
176
  * This path fires for EVERY version below latest (the hard gate only fires
177
177
  * below `minVersion`), so it is the far more frequently seen of the two and
178
178
  * must be manager-aware for the same reason the gate is.
179
+ *
180
+ * It is now only the FALLBACK: `enforceVersionGate` prefers the
181
+ * `onUpdateRecommended` handler that main.ts wires to the self-updater, which
182
+ * installs the new version and re-runs the command instead of nagging. This
183
+ * remains for callers that pass no handler.
179
184
  */
180
185
  declare function nudgeUpdateRecommended(decision: VersionCheckResponse, install?: RunningInstall): void;
181
186
  /**
@@ -203,8 +208,15 @@ declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: {
203
208
  * `--version` / `-v` callers MUST skip the gate (the user is debugging a
204
209
  * broken install and shouldn't be force-upgraded mid-investigation). Caller
205
210
  * is responsible for checking argv before invoking us — see index.ts.
211
+ *
212
+ * `onUpdateRecommended` takes over the soft (below-latest, above-minimum)
213
+ * case. main.ts passes the self-updater there so the CLI updates and re-runs
214
+ * the command rather than printing a banner on every invocation; if that
215
+ * handler reports it re-exec'd, the gate returns `"reexec"` and the caller
216
+ * must exit rather than running the command a second time.
206
217
  */
207
- export declare function enforceVersionGate(): Promise<void>;
218
+ export type VersionGateOutcome = "continue" | "reexec";
219
+ export declare function enforceVersionGate(onUpdateRecommended?: (decision: VersionCheckResponse, install: RunningInstall) => Promise<boolean>): Promise<VersionGateOutcome>;
208
220
  /**
209
221
  * Cheap argv pre-check: skip the gate for `--version` / `-V` so users
210
222
  * inspecting a broken install can still see what they have without being
@@ -306,7 +306,7 @@ export function buildSpawnPlan(cmd, args, platform = process.platform) {
306
306
  return { cmd, args: [...args], shell: false };
307
307
  return { cmd, args: args.map(quoteForWindowsShell), shell: true };
308
308
  }
309
- function runUpdateCommand(cmd, args) {
309
+ export function runUpdateCommand(cmd, args) {
310
310
  try {
311
311
  const plan = buildSpawnPlan(cmd, args);
312
312
  const result = spawnSync(plan.cmd, plan.args, {
@@ -368,6 +368,11 @@ function manualUpdateCommand(install, decision) {
368
368
  * This path fires for EVERY version below latest (the hard gate only fires
369
369
  * below `minVersion`), so it is the far more frequently seen of the two and
370
370
  * must be manager-aware for the same reason the gate is.
371
+ *
372
+ * It is now only the FALLBACK: `enforceVersionGate` prefers the
373
+ * `onUpdateRecommended` handler that main.ts wires to the self-updater, which
374
+ * installs the new version and re-runs the command instead of nagging. This
375
+ * remains for callers that pass no handler.
371
376
  */
372
377
  function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
373
378
  const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
@@ -500,24 +505,14 @@ function enforceUpdateRequired(decision, deps = {}) {
500
505
  console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
501
506
  process.exit(0);
502
507
  }
503
- /**
504
- * Public entry point. Call before commander parses argv. Blocks the CLI on
505
- * network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
506
- * (a fire-and-forget background check) gives the user no chance to bail out
507
- * of a known-bad version before it does damage.
508
- *
509
- * `--version` / `-v` callers MUST skip the gate (the user is debugging a
510
- * broken install and shouldn't be force-upgraded mid-investigation). Caller
511
- * is responsible for checking argv before invoking us — see index.ts.
512
- */
513
- export async function enforceVersionGate() {
508
+ export async function enforceVersionGate(onUpdateRecommended) {
514
509
  if (isOptedOut())
515
- return;
510
+ return "continue";
516
511
  const decision = await fetchVersionDecision();
517
512
  if (!decision)
518
- return; // best-effort: silent on any failure
513
+ return "continue"; // best-effort: silent on any failure
519
514
  if (!decision.updateRequired && !decision.updateRecommended)
520
- return;
515
+ return "continue";
521
516
  // Resolved once, here, so the up-to-date case never pays for the walk and
522
517
  // neither downstream path repeats it.
523
518
  const install = resolveRunningInstall();
@@ -525,8 +520,16 @@ export async function enforceVersionGate() {
525
520
  enforceUpdateRequired(decision, { resolveInstall: () => install }); // exits process
526
521
  }
527
522
  if (decision.updateRecommended) {
523
+ if (onUpdateRecommended) {
524
+ // The handler owns its own failure messaging (it warns only when the
525
+ // update it attempted did not land), so there is no nudge fallback here.
526
+ if (await onUpdateRecommended(decision, install))
527
+ return "reexec";
528
+ return "continue";
529
+ }
528
530
  nudgeUpdateRecommended(decision, install);
529
531
  }
532
+ return "continue";
530
533
  }
531
534
  /**
532
535
  * Cheap argv pre-check: skip the gate for `--version` / `-V` so users
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.95.0",
3
+ "version": "5.96.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {