@indigoai-us/hq-cli 5.94.3 → 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 +24 -0
- package/dist/commands/index-cmd.d.ts +1 -1
- package/dist/commands/index-cmd.js +2 -2
- package/dist/commands/rescue.d.ts +16 -0
- package/dist/commands/rescue.js +21 -0
- package/dist/lib/search-index/background.d.ts +5 -2
- package/dist/lib/search-index/background.js +94 -15
- package/dist/main.js +39 -8
- package/dist/utils/self-update.d.ts +111 -0
- package/dist/utils/self-update.js +247 -0
- package/dist/utils/version-check.d.ts +11 -1
- package/dist/utils/version-check.js +18 -10
- package/dist/utils/version-gate.d.ts +16 -4
- package/dist/utils/version-gate.js +18 -15
- package/package.json +1 -1
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
|
|
@@ -9,7 +9,7 @@ export type SearchIndexDependencies = {
|
|
|
9
9
|
resolveQmdVersion: () => string | undefined;
|
|
10
10
|
runQmd: (args: string[], options?: RunQmdOptions) => QmdProcessResult;
|
|
11
11
|
runBackgroundLauncher?: (dependencies: BackgroundDependencies) => BackgroundResult;
|
|
12
|
-
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult
|
|
12
|
+
runBackgroundWorker?: (dependencies: BackgroundDependencies) => BackgroundResult | Promise<BackgroundResult>;
|
|
13
13
|
backgroundStatus?: (dependencies: BackgroundDependencies) => BackgroundStatus;
|
|
14
14
|
};
|
|
15
15
|
/** Incrementally update qmd, embedding only when an operator explicitly asks. */
|
|
@@ -73,13 +73,13 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
73
73
|
.option('--log <path>', 'Write worker output to this log file')
|
|
74
74
|
.addOption(new Option('--worker').hideHelp())
|
|
75
75
|
.option('--hq-root <path>', 'HQ root to index (defaults to auto-detected root)')
|
|
76
|
-
.action((options) => {
|
|
76
|
+
.action(async (options) => {
|
|
77
77
|
const hqRoot = resolveRoot(options.hqRoot);
|
|
78
78
|
const background = makeBackgroundDependencies(hqRoot, dependencies);
|
|
79
79
|
if (options.log)
|
|
80
80
|
background.env = { ...background.env, QMD_REINDEX_LOG: options.log };
|
|
81
81
|
const result = options.worker
|
|
82
|
-
? (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
82
|
+
? await (dependencies.runBackgroundWorker ?? runBackgroundWorker)(background)
|
|
83
83
|
: (dependencies.runBackgroundLauncher ?? runBackgroundLauncher)(background);
|
|
84
84
|
if (!options.worker && result.state === 'launched')
|
|
85
85
|
console.log(result.pid);
|
|
@@ -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
|
package/dist/commands/rescue.js
CHANGED
|
@@ -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);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type QmdProcessResult, type RunQmdOptions } from './index.js';
|
|
2
2
|
export type BackgroundResult = {
|
|
3
|
-
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed';
|
|
3
|
+
state: 'skipped-agent' | 'skipped' | 'quiet' | 'busy' | 'completed' | 'update-failed' | 'terminated';
|
|
4
4
|
} | {
|
|
5
5
|
state: 'launched';
|
|
6
6
|
pid: number;
|
|
@@ -20,6 +20,9 @@ export type BackgroundDependencies = {
|
|
|
20
20
|
}) => number;
|
|
21
21
|
/** Test seam for simulating a competing owner replacing the atomic record. */
|
|
22
22
|
afterOwnerPublish?: (ownerFile: string) => void;
|
|
23
|
+
/** Test seams for signal delivery; production uses the real process. */
|
|
24
|
+
processEvents?: Pick<NodeJS.Process, 'once'>;
|
|
25
|
+
exit?: (code: number) => void;
|
|
23
26
|
};
|
|
24
27
|
export type BackgroundStatus = {
|
|
25
28
|
lock: 'held' | 'stale' | 'free';
|
|
@@ -33,7 +36,7 @@ export declare function installWorkerCleanup(cleanup: () => void, processEvents?
|
|
|
33
36
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
34
37
|
export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
|
|
35
38
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
36
|
-
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): BackgroundResult
|
|
39
|
+
export declare function runBackgroundWorker(dependencies: BackgroundDependencies): Promise<BackgroundResult>;
|
|
37
40
|
/** Report the background lock and latest successful completion for `hq index status`. */
|
|
38
41
|
export declare function backgroundStatus(dependencies: BackgroundDependencies): BackgroundStatus;
|
|
39
42
|
//# sourceMappingURL=background.d.ts.map
|
|
@@ -170,6 +170,10 @@ function acquireClaim(home, observedGeneration, dependencies) {
|
|
|
170
170
|
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
171
171
|
try {
|
|
172
172
|
fs.mkdirSync(claimant);
|
|
173
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
174
|
+
// parity with the script's claimant write_owner_record injection.
|
|
175
|
+
if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
|
|
176
|
+
throw new Error('forced claimant write failure');
|
|
173
177
|
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
174
178
|
return { claim, claimant };
|
|
175
179
|
}
|
|
@@ -236,6 +240,10 @@ function createAndPublishLock(home, dependencies) {
|
|
|
236
240
|
const ownerFile = path.join(directory, 'owner');
|
|
237
241
|
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
238
242
|
try {
|
|
243
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
244
|
+
// parity: the script's write_owner_record honored the same variable.
|
|
245
|
+
if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
|
|
246
|
+
throw new Error('forced owner write failure');
|
|
239
247
|
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
240
248
|
fs.renameSync(temporary, ownerFile);
|
|
241
249
|
dependencies.afterOwnerPublish?.(ownerFile);
|
|
@@ -290,11 +298,52 @@ function releaseLock(home, dependencies) {
|
|
|
290
298
|
export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
|
|
291
299
|
processEvents.once('exit', cleanup);
|
|
292
300
|
// Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
|
|
293
|
-
// catchable cleanup. SIGINT/SIGTERM are registered here
|
|
294
|
-
//
|
|
295
|
-
//
|
|
301
|
+
// catchable cleanup. SIGINT/SIGTERM/SIGHUP are registered here (the shell
|
|
302
|
+
// worker trapped `INT TERM HUP`; a detached worker's controlling terminal
|
|
303
|
+
// going away delivers HUP, and the default disposition would kill the
|
|
304
|
+
// process without releasing the lock) and the CLI's normal process exit then
|
|
305
|
+
// runs the same idempotent owner release. Exiting prevents a synchronous
|
|
306
|
+
// pipeline from continuing after it has released ownership.
|
|
296
307
|
processEvents.once('SIGINT', () => { cleanup(); exit(0); });
|
|
297
308
|
processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
|
|
309
|
+
processEvents.once('SIGHUP', () => { cleanup(); exit(0); });
|
|
310
|
+
}
|
|
311
|
+
function workerLogPath(env) {
|
|
312
|
+
return env.QMD_REINDEX_LOG
|
|
313
|
+
?? env.QMD_HANDOFF_LOG
|
|
314
|
+
?? path.join(env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
+
}
|
|
316
|
+
function appendWorkerLog(logPath, text) {
|
|
317
|
+
if (!text)
|
|
318
|
+
return;
|
|
319
|
+
try {
|
|
320
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
321
|
+
fs.appendFileSync(logPath, text.endsWith('\n') ? text : `${text}\n`);
|
|
322
|
+
}
|
|
323
|
+
catch { /* logging is best-effort, exactly like the shell worker's >>"$LOG" */ }
|
|
324
|
+
}
|
|
325
|
+
/** Keep only the trailing QMD_HANDOFF_LOG_MAX_BYTES of the worker log (shell cap_log parity). */
|
|
326
|
+
function capWorkerLog(logPath, env) {
|
|
327
|
+
const raw = env.QMD_HANDOFF_LOG_MAX_BYTES ?? '65536';
|
|
328
|
+
if (!/^\d+$/.test(raw))
|
|
329
|
+
return;
|
|
330
|
+
const max = Number(raw);
|
|
331
|
+
if (max === 0)
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
if (!fs.existsSync(logPath) || fs.statSync(logPath).size <= max)
|
|
335
|
+
return;
|
|
336
|
+
const content = fs.readFileSync(logPath);
|
|
337
|
+
fs.writeFileSync(logPath, content.subarray(content.length - max));
|
|
338
|
+
}
|
|
339
|
+
catch { /* best-effort, matching the shell's cap_log */ }
|
|
340
|
+
}
|
|
341
|
+
function stepOutput(result) {
|
|
342
|
+
return `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
343
|
+
}
|
|
344
|
+
function errorOutput(error) {
|
|
345
|
+
const e = error;
|
|
346
|
+
return `${e.stdout ?? ''}${e.stderr ?? ''}` || (e.message ?? '');
|
|
298
347
|
}
|
|
299
348
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
300
349
|
export function runBackgroundLauncher(dependencies) {
|
|
@@ -309,13 +358,10 @@ export function runBackgroundLauncher(dependencies) {
|
|
|
309
358
|
catch {
|
|
310
359
|
return { state: 'skipped' };
|
|
311
360
|
}
|
|
312
|
-
|
|
313
|
-
?? dependencies.env.QMD_HANDOFF_LOG
|
|
314
|
-
?? path.join(dependencies.env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
-
return { state: 'launched', pid: dependencies.spawnWorker({ logPath }) };
|
|
361
|
+
return { state: 'launched', pid: dependencies.spawnWorker({ logPath: workerLogPath(dependencies.env) }) };
|
|
316
362
|
}
|
|
317
363
|
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
318
|
-
export function runBackgroundWorker(dependencies) {
|
|
364
|
+
export async function runBackgroundWorker(dependencies) {
|
|
319
365
|
if (isHostedAgent(dependencies.env))
|
|
320
366
|
return { state: 'skipped-agent' };
|
|
321
367
|
const home = dependencies.env.HOME;
|
|
@@ -329,14 +375,32 @@ export function runBackgroundWorker(dependencies) {
|
|
|
329
375
|
}
|
|
330
376
|
if (isRecentCompletion(home, dependencies) || !acquireLock(home, dependencies))
|
|
331
377
|
return { state: 'busy' };
|
|
378
|
+
// Shell-worker log parity: each step's captured output appends to the
|
|
379
|
+
// handoff log and the log keeps only its trailing QMD_HANDOFF_LOG_MAX_BYTES.
|
|
380
|
+
// The cap lives inside the exit cleanup because a signal-path process.exit
|
|
381
|
+
// never unwinds to a `finally` — the shell capped in its EXIT trap for the
|
|
382
|
+
// same reason.
|
|
383
|
+
const logPath = workerLogPath(dependencies.env);
|
|
332
384
|
let released = false;
|
|
333
385
|
const cleanup = () => {
|
|
334
386
|
if (released)
|
|
335
387
|
return;
|
|
336
388
|
released = true;
|
|
337
389
|
releaseLock(home, dependencies);
|
|
390
|
+
capWorkerLog(logPath, dependencies.env);
|
|
391
|
+
};
|
|
392
|
+
installWorkerCleanup(cleanup, dependencies.processEvents ?? process, dependencies.exit ?? ((code) => process.exit(code)));
|
|
393
|
+
// A signal that lands during a synchronous qmd step cannot interrupt it, and
|
|
394
|
+
// Node defers the handler until the event loop next turns — which a fully
|
|
395
|
+
// synchronous pipeline never lets happen, so SIGTERM used to be processed
|
|
396
|
+
// only AFTER update/embed had already run. Bash traps fire between commands;
|
|
397
|
+
// yielding one event-loop turn between steps restores that contract. If the
|
|
398
|
+
// handler ran (production exits; tests inject `exit`), `released` is set and
|
|
399
|
+
// the pipeline stops before its next step.
|
|
400
|
+
const signalWindow = async () => {
|
|
401
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
402
|
+
return released;
|
|
338
403
|
};
|
|
339
|
-
installWorkerCleanup(cleanup, process, (code) => process.exit(code));
|
|
340
404
|
try {
|
|
341
405
|
if (isRecentCompletion(home, dependencies))
|
|
342
406
|
return { state: 'busy' };
|
|
@@ -349,21 +413,36 @@ export function runBackgroundWorker(dependencies) {
|
|
|
349
413
|
// The shell worker has no collection-registration step. Keep this #306
|
|
350
414
|
// integration best-effort so it cannot suppress a later index update.
|
|
351
415
|
}
|
|
416
|
+
if (await signalWindow())
|
|
417
|
+
return { state: 'terminated' };
|
|
352
418
|
try {
|
|
353
|
-
dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot });
|
|
419
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot })));
|
|
354
420
|
}
|
|
355
|
-
catch {
|
|
421
|
+
catch (error) {
|
|
422
|
+
appendWorkerLog(logPath, errorOutput(error)); // cleanup is intentionally best-effort
|
|
423
|
+
}
|
|
424
|
+
if (await signalWindow())
|
|
425
|
+
return { state: 'terminated' };
|
|
356
426
|
try {
|
|
357
|
-
dependencies.runQmd(['update'], { cwd: dependencies.hqRoot });
|
|
427
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['update'], { cwd: dependencies.hqRoot })));
|
|
358
428
|
}
|
|
359
|
-
catch {
|
|
429
|
+
catch (error) {
|
|
430
|
+
appendWorkerLog(logPath, errorOutput(error));
|
|
431
|
+
appendWorkerLog(logPath, `[qmd-reindex-bg] update-failed ts=${dependencies.now()}`);
|
|
360
432
|
return { state: 'update-failed' };
|
|
361
433
|
}
|
|
434
|
+
if (await signalWindow())
|
|
435
|
+
return { state: 'terminated' };
|
|
362
436
|
try {
|
|
363
|
-
dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot });
|
|
437
|
+
appendWorkerLog(logPath, stepOutput(dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot })));
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
appendWorkerLog(logPath, errorOutput(error)); // a completed embed attempt still permits the stamp
|
|
364
441
|
}
|
|
365
|
-
|
|
442
|
+
if (await signalWindow())
|
|
443
|
+
return { state: 'terminated' };
|
|
366
444
|
writeCompletion(home, dependencies);
|
|
445
|
+
appendWorkerLog(logPath, `[qmd-reindex-bg] done ts=${dependencies.now()}`);
|
|
367
446
|
return { state: 'completed' };
|
|
368
447
|
}
|
|
369
448
|
finally {
|
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 {
|
|
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
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|