@indigoai-us/hq-cli 5.95.0 → 5.97.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 +41 -0
- package/dist/commands/reindex.js +23 -0
- package/dist/commands/rescue.d.ts +16 -0
- package/dist/commands/rescue.js +21 -0
- package/dist/main.js +39 -8
- package/dist/utils/large-file-guard.d.ts +39 -0
- package/dist/utils/large-file-guard.js +249 -0
- 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,47 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.97.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq reindex` now keeps files over GitHub's 100MB limit out of the HQ repo.
|
|
10
|
+
The HQ root is a git repo that `git add -A` sweeps wholesale after every
|
|
11
|
+
sync, and session logs and package stores routinely pass that limit — once
|
|
12
|
+
such a blob is committed, every later push is rejected with GH001
|
|
13
|
+
permanently, because the blob is in history. Oversized files now gain an
|
|
14
|
+
anchored `.gitignore` rule, and any already in the index are dropped with
|
|
15
|
+
`git rm --cached`, which leaves the working-tree file untouched. Every
|
|
16
|
+
affected path is named on stdout, since untracking mutates the user's index
|
|
17
|
+
and must never be silent. The scan runs through git rather than the
|
|
18
|
+
filesystem, so nested checkouts under `repos/` are excluded automatically,
|
|
19
|
+
and it is throttled to once an hour per root so the hook-driven reindex path
|
|
20
|
+
stays cheap. (#346)
|
|
21
|
+
|
|
22
|
+
## [5.96.0]
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- The CLI now updates itself instead of nagging. When a newer version is
|
|
27
|
+
available, an ordinary command quietly installs it and re-runs on the new
|
|
28
|
+
version rather than printing "a new version is available" on every
|
|
29
|
+
invocation; the warning now appears only if the update itself fails, along
|
|
30
|
+
with the exact command to run by hand. hq-pro's soft "update recommended"
|
|
31
|
+
signal is handled the same way. Updates are serialized across concurrent
|
|
32
|
+
`hq` processes with a lock so a busy multi-agent box can't race
|
|
33
|
+
`npm install -g` into a broken partial install, and the package manager's
|
|
34
|
+
output is captured so it never corrupts a `--json` command's stdout. (#344)
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `hq rescue` now brings the CLI itself up to the latest version (and re-runs
|
|
39
|
+
the rescue on it) before touching the HQ core. The rescue logic ships inside
|
|
40
|
+
the CLI, so an out-of-date CLI was running an out-of-date rescue. Skipped for
|
|
41
|
+
`--check` dry-runs and behind a new `--no-self-update` flag; best-effort, so
|
|
42
|
+
any update failure falls back to rescuing on the current version. (#344)
|
|
43
|
+
|
|
44
|
+
All self-update behavior honors the existing `HQ_NO_UPDATE_CHECK=1` opt-out.
|
|
45
|
+
|
|
5
46
|
## [5.94.3]
|
|
6
47
|
|
|
7
48
|
### Fixed
|
package/dist/commands/reindex.js
CHANGED
|
@@ -30,6 +30,7 @@ import * as yaml from 'js-yaml';
|
|
|
30
30
|
import { reindex, rescue } from '@indigoai-us/hq-cloud';
|
|
31
31
|
import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
|
|
32
32
|
import { findHqRoot } from '../utils/manifest.js';
|
|
33
|
+
import { guardLargeFiles } from '../utils/large-file-guard.js';
|
|
33
34
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
34
35
|
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
35
36
|
/** Resolve the same root the repair/check commands must operate on. */
|
|
@@ -205,6 +206,25 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
|
205
206
|
printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
|
|
206
207
|
}
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
|
|
211
|
+
*
|
|
212
|
+
* Untracking is a mutation of the user's index, so it is never silent: every
|
|
213
|
+
* affected path is named on stdout. Throttled internally to one scan per hour
|
|
214
|
+
* per root, so the hook-driven reindex path stays cheap.
|
|
215
|
+
*/
|
|
216
|
+
function reportLargeFileGuard(hqRoot) {
|
|
217
|
+
const { scanned, ignored, untracked } = guardLargeFiles(hqRoot);
|
|
218
|
+
if (!scanned)
|
|
219
|
+
return;
|
|
220
|
+
const removed = new Set(untracked);
|
|
221
|
+
for (const file of untracked) {
|
|
222
|
+
console.log(`reindex: ${file} exceeds GitHub's 100MB limit — removed from git tracking and ignored (the file itself is untouched)`);
|
|
223
|
+
}
|
|
224
|
+
for (const file of ignored.filter((f) => !removed.has(f))) {
|
|
225
|
+
console.log(`reindex: ${file} exceeds GitHub's 100MB limit — added to .gitignore`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
208
228
|
export function registerReindexCommand(program) {
|
|
209
229
|
program
|
|
210
230
|
.command('reindex')
|
|
@@ -239,6 +259,9 @@ export function registerReindexCommand(program) {
|
|
|
239
259
|
repairExtremeHookDrift(hqRoot, status === 0);
|
|
240
260
|
if (status === 0)
|
|
241
261
|
await trustHqRuntimeHooks(hqRoot);
|
|
262
|
+
// Runs even when reindex failed: a failed reindex does not make an
|
|
263
|
+
// oversized blob any less likely to wedge the next push.
|
|
264
|
+
reportLargeFileGuard(hqRoot);
|
|
242
265
|
process.exit(status);
|
|
243
266
|
});
|
|
244
267
|
}
|
|
@@ -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);
|
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,39 @@
|
|
|
1
|
+
/** GitHub rejects any single file above this size. */
|
|
2
|
+
export declare const DEFAULT_THRESHOLD_BYTES: number;
|
|
3
|
+
/** At most one scan per hour per root. */
|
|
4
|
+
export declare const SCAN_THROTTLE_MS: number;
|
|
5
|
+
export interface LargeFileGuardResult {
|
|
6
|
+
/** False when the root is not a git repo, or the scan was throttled. */
|
|
7
|
+
scanned: boolean;
|
|
8
|
+
/** Repo-relative paths newly added to `.gitignore`. */
|
|
9
|
+
ignored: string[];
|
|
10
|
+
/** Repo-relative paths removed from the index (still on disk). */
|
|
11
|
+
untracked: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface LargeFileGuardOptions {
|
|
14
|
+
/** Defaults to {@link DEFAULT_THRESHOLD_BYTES}. */
|
|
15
|
+
thresholdBytes?: number;
|
|
16
|
+
/** Injectable clock, in epoch milliseconds. */
|
|
17
|
+
now?: number;
|
|
18
|
+
/** Bypass the throttle. */
|
|
19
|
+
force?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Render a repo-relative path as a literal `.gitignore` pattern.
|
|
23
|
+
*
|
|
24
|
+
* The leading slash anchors it to the repo root — without it a bare filename
|
|
25
|
+
* would also ignore same-named files in every subdirectory. Glob and
|
|
26
|
+
* character-class metacharacters are escaped so the rule matches the one real
|
|
27
|
+
* path and nothing else, and a trailing space is escaped because git strips
|
|
28
|
+
* unescaped ones.
|
|
29
|
+
*/
|
|
30
|
+
export declare function toGitignorePattern(relPath: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Scan `hqRoot` for files above the size limit, ignore them, and drop any that
|
|
33
|
+
* are already in the index.
|
|
34
|
+
*
|
|
35
|
+
* Never throws: every failure path degrades to "did nothing", because a guard
|
|
36
|
+
* that can take `hq reindex` down is worse than the problem it prevents.
|
|
37
|
+
*/
|
|
38
|
+
export declare function guardLargeFiles(hqRoot: string, options?: LargeFileGuardOptions): LargeFileGuardResult;
|
|
39
|
+
//# sourceMappingURL=large-file-guard.d.ts.map
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Large-file guard — keep blobs over GitHub's hard limit out of the HQ repo.
|
|
3
|
+
*
|
|
4
|
+
* HQ's root is a git repo that `git add -A` sweeps wholesale (the desktop app's
|
|
5
|
+
* git-mirror does exactly that after every sync). Session logs and package
|
|
6
|
+
* stores routinely blow past GitHub's 100 MB per-file limit, and once such a
|
|
7
|
+
* blob is committed every subsequent push is rejected with GH001 — permanently,
|
|
8
|
+
* since the blob is in history. Recovering means rewriting history.
|
|
9
|
+
*
|
|
10
|
+
* This guard runs from `hq reindex` and closes the door before that happens:
|
|
11
|
+
* - oversized files gain a `.gitignore` rule, so `git add -A` skips them;
|
|
12
|
+
* - oversized files already in the index are dropped from it with
|
|
13
|
+
* `git rm --cached`, which leaves the working-tree file untouched.
|
|
14
|
+
*
|
|
15
|
+
* Design constraints that shaped this:
|
|
16
|
+
*
|
|
17
|
+
* - **Git-aware, not filesystem-aware.** A naive `find -size +100M` over an HQ
|
|
18
|
+
* root takes ~37s and surfaces files inside nested checkouts under `repos/`,
|
|
19
|
+
* which the outer repo does not own. Driving everything through git costs
|
|
20
|
+
* ~13s and gets nested-repo boundaries right for free.
|
|
21
|
+
* - **Index, not HEAD.** `git rm --cached` mutates the index and leaves HEAD
|
|
22
|
+
* alone until the next commit, so enumerating HEAD would re-report the same
|
|
23
|
+
* file on every run.
|
|
24
|
+
* - **Throttled.** `hq reindex` also fires from a PostToolUse hook shim on
|
|
25
|
+
* skill/worker/policy edits. A stamp file in the git dir bounds the scan to
|
|
26
|
+
* once an hour so a policy-editing session doesn't pay the cost repeatedly.
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
/** GitHub rejects any single file above this size. */
|
|
32
|
+
export const DEFAULT_THRESHOLD_BYTES = 100 * 1024 * 1024;
|
|
33
|
+
/** At most one scan per hour per root. */
|
|
34
|
+
export const SCAN_THROTTLE_MS = 60 * 60 * 1000;
|
|
35
|
+
/** Lives in the git dir, so it is never committed, synced, or seen by status. */
|
|
36
|
+
const STAMP_BASENAME = "hq-large-file-scan.stamp";
|
|
37
|
+
const GITIGNORE_HEADER = "# hq reindex: files over GitHub's 100MB limit (added automatically)";
|
|
38
|
+
/**
|
|
39
|
+
* git output for a fully-populated HQ root runs to tens of megabytes
|
|
40
|
+
* (~450k tracked paths). The Node default of 1 MiB would truncate it.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_GIT_BUFFER = 512 * 1024 * 1024;
|
|
43
|
+
const SKIPPED = Object.freeze({
|
|
44
|
+
scanned: false,
|
|
45
|
+
ignored: [],
|
|
46
|
+
untracked: [],
|
|
47
|
+
});
|
|
48
|
+
function git(cwd, args, input) {
|
|
49
|
+
try {
|
|
50
|
+
const out = spawnSync("git", args, {
|
|
51
|
+
cwd,
|
|
52
|
+
input,
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
maxBuffer: MAX_GIT_BUFFER,
|
|
55
|
+
});
|
|
56
|
+
if (out.error || out.status !== 0)
|
|
57
|
+
return { ok: false, stdout: "" };
|
|
58
|
+
return { ok: true, stdout: out.stdout ?? "" };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// A missing cwd or an absent git binary must degrade to "guard did
|
|
62
|
+
// nothing", never take reindex down with it.
|
|
63
|
+
return { ok: false, stdout: "" };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Absolute path to the repo's git dir, or undefined when cwd is not a repo. */
|
|
67
|
+
function resolveGitDir(hqRoot) {
|
|
68
|
+
if (!fs.existsSync(hqRoot))
|
|
69
|
+
return undefined;
|
|
70
|
+
const out = git(hqRoot, ["rev-parse", "--git-dir"]);
|
|
71
|
+
if (!out.ok)
|
|
72
|
+
return undefined;
|
|
73
|
+
const raw = out.stdout.trim();
|
|
74
|
+
if (!raw)
|
|
75
|
+
return undefined;
|
|
76
|
+
return path.isAbsolute(raw) ? raw : path.resolve(hqRoot, raw);
|
|
77
|
+
}
|
|
78
|
+
function isThrottled(stampPath, now) {
|
|
79
|
+
try {
|
|
80
|
+
const last = Number(fs.readFileSync(stampPath, "utf8").trim());
|
|
81
|
+
if (!Number.isFinite(last))
|
|
82
|
+
return false;
|
|
83
|
+
return now - last < SCAN_THROTTLE_MS;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false; // no stamp yet, or unreadable — scan.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function writeStamp(stampPath, now) {
|
|
90
|
+
try {
|
|
91
|
+
fs.writeFileSync(stampPath, `${now}\n`);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Losing the stamp only costs an extra scan next time.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Split a NUL-delimited git record stream, dropping the trailing empty field. */
|
|
98
|
+
function splitNul(raw) {
|
|
99
|
+
return raw.split("\0").filter((entry) => entry.length > 0);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Oversized blobs in the *index*.
|
|
103
|
+
*
|
|
104
|
+
* `git ls-files -s` yields `<mode> <object> <stage>\t<path>`; the object ids are
|
|
105
|
+
* piped through `cat-file --batch-check` so sizes come from the object database
|
|
106
|
+
* rather than ~450k filesystem stats.
|
|
107
|
+
*/
|
|
108
|
+
function oversizedInIndex(hqRoot, threshold) {
|
|
109
|
+
const listed = git(hqRoot, ["ls-files", "-s", "-z"]);
|
|
110
|
+
if (!listed.ok)
|
|
111
|
+
return [];
|
|
112
|
+
const entries = [];
|
|
113
|
+
for (const record of splitNul(listed.stdout)) {
|
|
114
|
+
const tab = record.indexOf("\t");
|
|
115
|
+
if (tab === -1)
|
|
116
|
+
continue;
|
|
117
|
+
const fields = record.slice(0, tab).split(" ");
|
|
118
|
+
if (fields.length < 2)
|
|
119
|
+
continue;
|
|
120
|
+
entries.push({ oid: fields[1], file: record.slice(tab + 1) });
|
|
121
|
+
}
|
|
122
|
+
if (entries.length === 0)
|
|
123
|
+
return [];
|
|
124
|
+
const sizes = git(hqRoot, ["cat-file", "--batch-check=%(objectsize)"], `${entries.map((e) => e.oid).join("\n")}\n`);
|
|
125
|
+
if (!sizes.ok)
|
|
126
|
+
return [];
|
|
127
|
+
// One output line per input line, in order.
|
|
128
|
+
const lines = sizes.stdout.split("\n");
|
|
129
|
+
const oversized = [];
|
|
130
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
131
|
+
const size = Number(lines[i]);
|
|
132
|
+
if (Number.isFinite(size) && size > threshold)
|
|
133
|
+
oversized.push(entries[i].file);
|
|
134
|
+
}
|
|
135
|
+
return oversized;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Oversized files git would pick up on the next `add -A` — untracked or
|
|
139
|
+
* modified. `git status` already honours `.gitignore` and stops at nested
|
|
140
|
+
* repository boundaries, so neither needs handling here.
|
|
141
|
+
*/
|
|
142
|
+
function oversizedInWorktree(hqRoot, threshold) {
|
|
143
|
+
const status = git(hqRoot, ["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
144
|
+
if (!status.ok)
|
|
145
|
+
return [];
|
|
146
|
+
const records = splitNul(status.stdout);
|
|
147
|
+
const oversized = [];
|
|
148
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
149
|
+
const record = records[i];
|
|
150
|
+
if (record.length < 4)
|
|
151
|
+
continue;
|
|
152
|
+
const code = record.slice(0, 2);
|
|
153
|
+
const file = record.slice(3);
|
|
154
|
+
// Renames and copies emit the source path as a second record; consume it
|
|
155
|
+
// so it is not parsed as a status line of its own.
|
|
156
|
+
if (code.startsWith("R") || code.startsWith("C"))
|
|
157
|
+
i += 1;
|
|
158
|
+
if (code.includes("D"))
|
|
159
|
+
continue; // going away — nothing to guard
|
|
160
|
+
try {
|
|
161
|
+
const stat = fs.statSync(path.join(hqRoot, file));
|
|
162
|
+
if (stat.isFile() && stat.size > threshold)
|
|
163
|
+
oversized.push(file);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Vanished between status and stat; nothing to do.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return oversized;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Render a repo-relative path as a literal `.gitignore` pattern.
|
|
173
|
+
*
|
|
174
|
+
* The leading slash anchors it to the repo root — without it a bare filename
|
|
175
|
+
* would also ignore same-named files in every subdirectory. Glob and
|
|
176
|
+
* character-class metacharacters are escaped so the rule matches the one real
|
|
177
|
+
* path and nothing else, and a trailing space is escaped because git strips
|
|
178
|
+
* unescaped ones.
|
|
179
|
+
*/
|
|
180
|
+
export function toGitignorePattern(relPath) {
|
|
181
|
+
const escaped = relPath.replace(/([\\*?[\]!#])/g, "\\$1");
|
|
182
|
+
const anchored = `/${escaped}`;
|
|
183
|
+
return anchored.endsWith(" ") ? `${anchored.slice(0, -1)}\\ ` : anchored;
|
|
184
|
+
}
|
|
185
|
+
function appendGitignore(hqRoot, patterns) {
|
|
186
|
+
if (patterns.length === 0)
|
|
187
|
+
return true;
|
|
188
|
+
const file = path.join(hqRoot, ".gitignore");
|
|
189
|
+
let existing = "";
|
|
190
|
+
try {
|
|
191
|
+
existing = fs.readFileSync(file, "utf8");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// No .gitignore yet — it gets created below.
|
|
195
|
+
}
|
|
196
|
+
const present = new Set(existing.split("\n").map((line) => line.trim()));
|
|
197
|
+
const fresh = patterns.filter((p) => !present.has(p));
|
|
198
|
+
if (fresh.length === 0)
|
|
199
|
+
return true;
|
|
200
|
+
const needsNewline = existing.length > 0 && !existing.endsWith("\n");
|
|
201
|
+
const header = existing.includes(GITIGNORE_HEADER) ? "" : `${GITIGNORE_HEADER}\n`;
|
|
202
|
+
try {
|
|
203
|
+
fs.appendFileSync(file, `${needsNewline ? "\n" : ""}${header}${fresh.join("\n")}\n`);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Scan `hqRoot` for files above the size limit, ignore them, and drop any that
|
|
212
|
+
* are already in the index.
|
|
213
|
+
*
|
|
214
|
+
* Never throws: every failure path degrades to "did nothing", because a guard
|
|
215
|
+
* that can take `hq reindex` down is worse than the problem it prevents.
|
|
216
|
+
*/
|
|
217
|
+
export function guardLargeFiles(hqRoot, options = {}) {
|
|
218
|
+
const threshold = options.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
|
|
219
|
+
const now = options.now ?? Date.now();
|
|
220
|
+
const gitDir = resolveGitDir(hqRoot);
|
|
221
|
+
if (!gitDir)
|
|
222
|
+
return SKIPPED;
|
|
223
|
+
const stampPath = path.join(gitDir, STAMP_BASENAME);
|
|
224
|
+
if (!options.force && isThrottled(stampPath, now))
|
|
225
|
+
return SKIPPED;
|
|
226
|
+
writeStamp(stampPath, now);
|
|
227
|
+
const tracked = oversizedInIndex(hqRoot, threshold);
|
|
228
|
+
const trackedSet = new Set(tracked);
|
|
229
|
+
const worktree = oversizedInWorktree(hqRoot, threshold).filter((p) => !trackedSet.has(p));
|
|
230
|
+
const all = [...tracked, ...worktree];
|
|
231
|
+
if (all.length === 0)
|
|
232
|
+
return { scanned: true, ignored: [], untracked: [] };
|
|
233
|
+
// Ignore first, so a concurrent `git add -A` cannot re-stage what we are
|
|
234
|
+
// about to remove from the index.
|
|
235
|
+
const patterns = all.map(toGitignorePattern);
|
|
236
|
+
const wroteIgnore = appendGitignore(hqRoot, patterns);
|
|
237
|
+
const untracked = [];
|
|
238
|
+
for (const file of tracked) {
|
|
239
|
+
const removed = git(hqRoot, ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", file]);
|
|
240
|
+
if (removed.ok)
|
|
241
|
+
untracked.push(file);
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
scanned: true,
|
|
245
|
+
ignored: wroteIgnore ? all : [],
|
|
246
|
+
untracked,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=large-file-guard.js.map
|
|
@@ -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
|