@indigoai-us/hq-cli 5.108.17 → 5.108.18
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 +36 -18
- package/dist/main.js +51 -7
- package/dist/startup-registration.d.ts +79 -0
- package/dist/startup-registration.js +134 -0
- package/dist/utils/install-tree-torn.d.ts +204 -0
- package/dist/utils/install-tree-torn.js +359 -0
- package/dist/utils/sentry-fingerprint.js +26 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.18] — 2026-09-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A global `hq` reinstall running underneath a starting command no longer
|
|
10
|
+
crashes it (Sentry HQ-CLI-1G/1H/1J/1K — 7713386011, 7713482655, 7713783135,
|
|
11
|
+
7714501738). `hq` resolves most of its command graph lazily as its first real
|
|
12
|
+
step, so when an external updater — the outpost `hq-cli-update` timer, an
|
|
13
|
+
agent box's self-update, or the desktop app — renames the installed package
|
|
14
|
+
aside and re-extracts it file by file (~16s every 6h on every box), a command
|
|
15
|
+
that started a moment earlier resolved a deferred import against a directory
|
|
16
|
+
that no longer held it and died with `ERR_MODULE_NOT_FOUND` /
|
|
17
|
+
`MODULE_NOT_FOUND`, filing a separate high-priority crash for each missing
|
|
18
|
+
path. hq-cli cannot stop the foreign updater, but the registration phase is
|
|
19
|
+
provably side-effect free (it runs before any command action), so a
|
|
20
|
+
resolution failure there is now caught: the CLI prints one informational line,
|
|
21
|
+
waits — bounded, default 90s, `HQ_INSTALL_SETTLE_TIMEOUT_MS` to override — for
|
|
22
|
+
the install to settle (shared update lock released, the missing target back,
|
|
23
|
+
the retired `.hq-cli-*` sibling gone, a healthy manifest), and then re-runs
|
|
24
|
+
the command once on the settled tree. A genuinely broken or incomplete install
|
|
25
|
+
still reports exactly once, with a reinstall remedy naming the missing module
|
|
26
|
+
and bounded diagnostics, so real packaging faults stay visible. The recovery
|
|
27
|
+
re-execs at most once per invocation and never runs a command twice.
|
|
28
|
+
|
|
5
29
|
## [5.108.17] — 2026-09-07
|
|
6
30
|
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- The Work Mesh Live daemon's transcript watcher no longer rescans every file
|
|
34
|
+
under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
|
|
35
|
+
per-directory mtime cache and only relists directories that changed, re-stats
|
|
36
|
+
recently active files, revalidates every cached directory at least every five
|
|
37
|
+
minutes (content appends do not change a directory's mtime, so a session that
|
|
38
|
+
resumes after idling is still picked up), backs the interval off from 30 s to
|
|
39
|
+
120 s on busy machines, caps the entries examined per tick with per-root
|
|
40
|
+
budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
|
|
41
|
+
disappearance reconciliation so an unvisited session is never ended falsely.
|
|
42
|
+
|
|
7
43
|
## [5.108.16] — 2026-09-07
|
|
8
44
|
|
|
9
45
|
### Fixed
|
|
@@ -41,15 +77,6 @@
|
|
|
41
77
|
at most every five minutes, and `held.jsonl` is capped at 20,000 lines with
|
|
42
78
|
the oldest overflow dead-lettered as `HELD_OVERFLOW` (loss-free under failure
|
|
43
79
|
and concurrent flushers). (#525)
|
|
44
|
-
- The Work Mesh Live daemon's transcript watcher no longer rescans every file
|
|
45
|
-
under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
|
|
46
|
-
per-directory mtime cache and only relists directories that changed, re-stats
|
|
47
|
-
recently active files, revalidates every cached directory at least every five
|
|
48
|
-
minutes (content appends do not change a directory's mtime, so a session that
|
|
49
|
-
resumes after idling is still picked up), backs the interval off from 30 s to
|
|
50
|
-
120 s on busy machines, caps the entries examined per tick with per-root
|
|
51
|
-
budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
|
|
52
|
-
disappearance reconciliation so an unvisited session is never ended falsely.
|
|
53
80
|
|
|
54
81
|
## [5.108.14] — 2026-09-07
|
|
55
82
|
|
|
@@ -81,15 +108,6 @@
|
|
|
81
108
|
`incomplete_install` context so the next occurrence is attributable. The drop
|
|
82
109
|
is wired both at the top-level boundary and in the shared `beforeSend`, so it
|
|
83
110
|
covers every capture route.
|
|
84
|
-
- The Work Mesh Live daemon's transcript watcher no longer rescans every file
|
|
85
|
-
under `~/.claude/projects` and `~/.codex/sessions` every 15 seconds. It keeps a
|
|
86
|
-
per-directory mtime cache and only relists directories that changed, re-stats
|
|
87
|
-
recently active files, revalidates every cached directory at least every five
|
|
88
|
-
minutes (content appends do not change a directory's mtime, so a session that
|
|
89
|
-
resumes after idling is still picked up), backs the interval off from 30 s to
|
|
90
|
-
120 s on busy machines, caps the entries examined per tick with per-root
|
|
91
|
-
budgets, never runs two ticks at once, and bounds its cache. Capped ticks skip
|
|
92
|
-
disappearance reconciliation so an unvisited session is never ended falsely.
|
|
93
111
|
|
|
94
112
|
## [5.108.13] — 2026-09-06
|
|
95
113
|
|
package/dist/main.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
7
7
|
import "./node-preflight.js";
|
|
8
8
|
import "./node-network-compat.js";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
9
11
|
import { Command } from "commander";
|
|
10
12
|
import { initSentry, Sentry } from "./sentry.js";
|
|
11
13
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
@@ -46,10 +48,19 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
|
46
48
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
47
49
|
import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
|
|
48
50
|
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
51
|
+
import { registerCommandsWithRecovery } from "./startup-registration.js";
|
|
52
|
+
import { installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
|
|
49
53
|
import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
|
|
50
54
|
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
51
55
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
52
56
|
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
57
|
+
/**
|
|
58
|
+
* The RUNNING install's own entrypoint (`<pkg>/dist/index.js`), used as the
|
|
59
|
+
* torn-install recovery re-exec target. It must be this resolved path — never
|
|
60
|
+
* `hq` from PATH — because a global reinstall leaves `bin/hq` absent for much of
|
|
61
|
+
* the rewrite window, while this path now holds the settled tree.
|
|
62
|
+
*/
|
|
63
|
+
const RUNNING_ENTRY_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.js");
|
|
53
64
|
const defaultStreamErrorDependencies = {
|
|
54
65
|
stderr: process.stderr,
|
|
55
66
|
exit: (code) => process.exit(code),
|
|
@@ -160,13 +171,31 @@ export async function runCli() {
|
|
|
160
171
|
// `--help`, a bare `hq`, an unknown command, any command not on the
|
|
161
172
|
// manifest — falls back to the complete graph, so its behaviour is
|
|
162
173
|
// unchanged. See register-all.ts for the measurements that motivated this.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
// The same lazy/full registration as before, wrapped so it recovers ONCE if
|
|
175
|
+
// a global reinstall is tearing the install tree out from under these
|
|
176
|
+
// deferred imports (Sentry HQ-CLI-1G/1H/1J/1K). Which modules are imported,
|
|
177
|
+
// and in what order, is unchanged — only the failure handling is added.
|
|
178
|
+
const registration = await registerCommandsWithRecovery({
|
|
179
|
+
register: async () => {
|
|
180
|
+
const lazy = findLazyCommand(process.argv);
|
|
181
|
+
if (lazy) {
|
|
182
|
+
await lazy.register(program);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const { registerAllCommands } = await import("./register-all.js");
|
|
186
|
+
registerAllCommands(program);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
argv: process.argv,
|
|
190
|
+
env: process.env,
|
|
191
|
+
entryPath: RUNNING_ENTRY_PATH,
|
|
192
|
+
stderr: process.stderr,
|
|
193
|
+
});
|
|
194
|
+
if (registration.reexecStatus !== undefined) {
|
|
195
|
+
// The re-exec'd child ran the command on the settled tree; carry its exit
|
|
196
|
+
// status out (after the finally block), exactly as the self-update re-exec.
|
|
197
|
+
reexecStatus = registration.reexecStatus;
|
|
198
|
+
return;
|
|
170
199
|
}
|
|
171
200
|
await program.parseAsync();
|
|
172
201
|
}
|
|
@@ -337,6 +366,21 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
337
366
|
});
|
|
338
367
|
deps.setExitCode(1);
|
|
339
368
|
}
|
|
369
|
+
else if (isInstallTreeTornError(err)) {
|
|
370
|
+
// HQ-CLI-1G/1H/1J/1K: a global reinstall tore the install tree out from
|
|
371
|
+
// under a starting `hq` and the bounded settle-wait + single re-exec could
|
|
372
|
+
// not recover it (a still-incomplete tree, a foreign updater, or a failed
|
|
373
|
+
// re-exec). Print the fixed reinstall remedy naming the bounded missing
|
|
374
|
+
// specifier, and STILL capture WITH bounded diagnostics — a genuinely
|
|
375
|
+
// pruned or incomplete install must stay visible. Mirrors the package-root
|
|
376
|
+
// branch's print-and-capture shape; a class-instance check disjoint from
|
|
377
|
+
// every neighbour, so no existing ordering changes. A SUCCESSFUL recovery
|
|
378
|
+
// never reaches here (it returns the child's status), so this line and this
|
|
379
|
+
// capture only ever mark a real, unrecovered break.
|
|
380
|
+
deps.stderr.write(`hq: ${installTreeTornStderrLine(err)}\n`);
|
|
381
|
+
deps.sentry.captureException(err, installTreeTornCaptureContext(err));
|
|
382
|
+
deps.setExitCode(1);
|
|
383
|
+
}
|
|
340
384
|
else if (isVaultAccessDeniedError(err)) {
|
|
341
385
|
// ARM B (Sentry 7709408531): `hq skill create`'s post-register vault sync
|
|
342
386
|
// was DENIED by S3 — an AWS SDK v3 403 from an hq-pro IAM session-policy
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command-registration recovery seam for the "install tree torn out from
|
|
3
|
+
* under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K).
|
|
4
|
+
*
|
|
5
|
+
* `runCli` registers commands as its last step before `program.parseAsync()` —
|
|
6
|
+
* the lazy command import or `import("./register-all.js")` and everything those
|
|
7
|
+
* pull in. That is the ONE place a module-resolution failure is provably safe to
|
|
8
|
+
* recover from: it is inside hq-cli's own graph, it runs before `parseAsync` and
|
|
9
|
+
* before the preAction hook, so no command action, telemetry, or stdin read has
|
|
10
|
+
* happened yet, and a single re-exec can therefore never run a command twice.
|
|
11
|
+
*
|
|
12
|
+
* This wraps the existing `register()` call with exactly that recovery, driven by
|
|
13
|
+
* the classifier / probe / settle wait / carrier in ./utils/install-tree-torn.ts.
|
|
14
|
+
* It changes nothing about WHICH modules are imported or in what order — only the
|
|
15
|
+
* failure handling around the existing imports. Dependency-injected in the house
|
|
16
|
+
* style of handleTopLevelError so the whole seam is unit-testable without real
|
|
17
|
+
* spawns, waits, or a real install tree.
|
|
18
|
+
*/
|
|
19
|
+
import { type WaitForInstallTreeSettledArgs, type WaitForInstallTreeSettledResult } from "./utils/install-tree-torn.js";
|
|
20
|
+
/**
|
|
21
|
+
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
22
|
+
* from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
|
|
23
|
+
* shadow each other.
|
|
24
|
+
*/
|
|
25
|
+
export declare const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
|
|
26
|
+
/** Operator override for the settle wait's deadline. */
|
|
27
|
+
export declare const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
|
|
28
|
+
/** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
|
|
29
|
+
export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
|
|
30
|
+
/**
|
|
31
|
+
* The single dim, informational line emitted when — and only when — a wait
|
|
32
|
+
* actually happens. It tells a human why the command paused and is the e2e's
|
|
33
|
+
* synchronization point; it is never a remedy and never sets an error exit code.
|
|
34
|
+
*/
|
|
35
|
+
export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
|
|
36
|
+
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
37
|
+
export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
|
+
/** Minimal view of a `spawnSync` result the seam relies on. */
|
|
39
|
+
export interface RecoverySpawnResult {
|
|
40
|
+
status: number | null;
|
|
41
|
+
error?: Error;
|
|
42
|
+
}
|
|
43
|
+
export interface RegisterRecoveryDeps {
|
|
44
|
+
spawn?: (command: string, args: string[], options: {
|
|
45
|
+
stdio: "inherit";
|
|
46
|
+
env: NodeJS.ProcessEnv;
|
|
47
|
+
}) => RecoverySpawnResult;
|
|
48
|
+
resolveInstall?: () => {
|
|
49
|
+
packageRoot: string | null;
|
|
50
|
+
};
|
|
51
|
+
lockPath?: () => string;
|
|
52
|
+
waitForSettled?: (args: WaitForInstallTreeSettledArgs) => Promise<WaitForInstallTreeSettledResult>;
|
|
53
|
+
/** node flags to forward to the re-exec child (defaults to process.execArgv). */
|
|
54
|
+
execArgv?: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
export interface RegisterCommandsWithRecoveryArgs {
|
|
57
|
+
/** Runs the existing registration (lazy command import or register-all import). */
|
|
58
|
+
register: () => Promise<void>;
|
|
59
|
+
argv: readonly string[];
|
|
60
|
+
env: NodeJS.ProcessEnv;
|
|
61
|
+
/** The RUNNING install's own `dist/index.js` — the safe re-exec target. */
|
|
62
|
+
entryPath: string;
|
|
63
|
+
stderr: Pick<typeof process.stderr, "write">;
|
|
64
|
+
deps?: RegisterRecoveryDeps;
|
|
65
|
+
}
|
|
66
|
+
export interface RegisterCommandsWithRecoveryResult {
|
|
67
|
+
/** Set only when a re-exec ran: its exit status becomes this invocation's. */
|
|
68
|
+
reexecStatus?: number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Register commands, recovering once from a torn-install module-resolution
|
|
72
|
+
* failure. On a clean registration this returns `{}` and touches nothing else.
|
|
73
|
+
* On a classified failure it waits (bounded) for the install to settle and
|
|
74
|
+
* re-execs the running install's own entrypoint once; a child (guarded), an
|
|
75
|
+
* unsettled wait, or a failed spawn each throws an {@link InstallTreeTornError}
|
|
76
|
+
* carrying bounded diagnostics so a genuinely broken install stays visible.
|
|
77
|
+
*/
|
|
78
|
+
export declare function registerCommandsWithRecovery(args: RegisterCommandsWithRecoveryArgs): Promise<RegisterCommandsWithRecoveryResult>;
|
|
79
|
+
//# sourceMappingURL=startup-registration.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command-registration recovery seam for the "install tree torn out from
|
|
3
|
+
* under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K).
|
|
4
|
+
*
|
|
5
|
+
* `runCli` registers commands as its last step before `program.parseAsync()` —
|
|
6
|
+
* the lazy command import or `import("./register-all.js")` and everything those
|
|
7
|
+
* pull in. That is the ONE place a module-resolution failure is provably safe to
|
|
8
|
+
* recover from: it is inside hq-cli's own graph, it runs before `parseAsync` and
|
|
9
|
+
* before the preAction hook, so no command action, telemetry, or stdin read has
|
|
10
|
+
* happened yet, and a single re-exec can therefore never run a command twice.
|
|
11
|
+
*
|
|
12
|
+
* This wraps the existing `register()` call with exactly that recovery, driven by
|
|
13
|
+
* the classifier / probe / settle wait / carrier in ./utils/install-tree-torn.ts.
|
|
14
|
+
* It changes nothing about WHICH modules are imported or in what order — only the
|
|
15
|
+
* failure handling around the existing imports. Dependency-injected in the house
|
|
16
|
+
* style of handleTopLevelError so the whole seam is unit-testable without real
|
|
17
|
+
* spawns, waits, or a real install tree.
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
|
+
import { updateLockPath } from "./utils/update-lock.js";
|
|
22
|
+
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
23
|
+
/**
|
|
24
|
+
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
25
|
+
* from self-update's HQ_RESCUE_SELF_UPDATED so the two recovery paths cannot
|
|
26
|
+
* shadow each other.
|
|
27
|
+
*/
|
|
28
|
+
export const INSTALL_TREE_RECOVERY_GUARD_ENV = "HQ_INSTALL_TREE_RECOVERED";
|
|
29
|
+
/** Operator override for the settle wait's deadline. */
|
|
30
|
+
export const INSTALL_SETTLE_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_TIMEOUT_MS";
|
|
31
|
+
/** Default settle deadline (ms). Overridable by {@link INSTALL_SETTLE_TIMEOUT_ENV}. */
|
|
32
|
+
export const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90_000;
|
|
33
|
+
/**
|
|
34
|
+
* The single dim, informational line emitted when — and only when — a wait
|
|
35
|
+
* actually happens. It tells a human why the command paused and is the e2e's
|
|
36
|
+
* synchronization point; it is never a remedy and never sets an error exit code.
|
|
37
|
+
*/
|
|
38
|
+
export const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish…";
|
|
39
|
+
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
40
|
+
export function resolveSettleTimeoutMs(env) {
|
|
41
|
+
const raw = env[INSTALL_SETTLE_TIMEOUT_ENV];
|
|
42
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
43
|
+
return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
|
|
44
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
45
|
+
return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
|
|
46
|
+
return Number.parseInt(raw.trim(), 10);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Register commands, recovering once from a torn-install module-resolution
|
|
50
|
+
* failure. On a clean registration this returns `{}` and touches nothing else.
|
|
51
|
+
* On a classified failure it waits (bounded) for the install to settle and
|
|
52
|
+
* re-execs the running install's own entrypoint once; a child (guarded), an
|
|
53
|
+
* unsettled wait, or a failed spawn each throws an {@link InstallTreeTornError}
|
|
54
|
+
* carrying bounded diagnostics so a genuinely broken install stays visible.
|
|
55
|
+
*/
|
|
56
|
+
export async function registerCommandsWithRecovery(args) {
|
|
57
|
+
const { register, argv, env, entryPath, stderr } = args;
|
|
58
|
+
const deps = args.deps ?? {};
|
|
59
|
+
try {
|
|
60
|
+
await register();
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const classified = classifyModuleNotFound(err);
|
|
65
|
+
// Not a module-resolution failure — rethrow the SAME object to the existing
|
|
66
|
+
// boundary, preserving today's behaviour exactly.
|
|
67
|
+
if (!classified)
|
|
68
|
+
throw err;
|
|
69
|
+
const packageRoot = resolvePackageRoot(deps);
|
|
70
|
+
// A child that already recovered must never wait or re-exec again: report
|
|
71
|
+
// once and let the boundary capture it.
|
|
72
|
+
if (env[INSTALL_TREE_RECOVERY_GUARD_ENV] === "1") {
|
|
73
|
+
throw new InstallTreeTornError({
|
|
74
|
+
cause: err,
|
|
75
|
+
classified,
|
|
76
|
+
packageRoot,
|
|
77
|
+
outcome: "guarded",
|
|
78
|
+
attempt: 2,
|
|
79
|
+
waitedMs: 0,
|
|
80
|
+
sawLock: false,
|
|
81
|
+
sawRetired: false,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
stderr.write(`${INSTALL_TREE_WAIT_NOTICE}\n`);
|
|
85
|
+
const lockPath = (deps.lockPath ?? updateLockPath)();
|
|
86
|
+
const waitFor = deps.waitForSettled ?? waitForInstallTreeSettled;
|
|
87
|
+
const result = await waitFor({
|
|
88
|
+
target: classified.target,
|
|
89
|
+
packageRoot,
|
|
90
|
+
lockPath,
|
|
91
|
+
deadlineMs: resolveSettleTimeoutMs(env),
|
|
92
|
+
});
|
|
93
|
+
if (!result.settled) {
|
|
94
|
+
throw new InstallTreeTornError({
|
|
95
|
+
cause: err,
|
|
96
|
+
classified,
|
|
97
|
+
packageRoot,
|
|
98
|
+
outcome: "unsettled",
|
|
99
|
+
attempt: 1,
|
|
100
|
+
waitedMs: result.waitedMs,
|
|
101
|
+
sawLock: result.sawLock,
|
|
102
|
+
sawRetired: result.sawRetired,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const spawn = deps.spawn ?? spawnSync;
|
|
106
|
+
const execArgv = deps.execArgv ?? process.execArgv;
|
|
107
|
+
const child = spawn(process.execPath, [...execArgv, entryPath, ...argv.slice(2)], { stdio: "inherit", env: { ...env, [INSTALL_TREE_RECOVERY_GUARD_ENV]: "1" } });
|
|
108
|
+
if (child.error) {
|
|
109
|
+
throw new InstallTreeTornError({
|
|
110
|
+
cause: err,
|
|
111
|
+
classified,
|
|
112
|
+
packageRoot,
|
|
113
|
+
outcome: "reexec-failed",
|
|
114
|
+
attempt: 1,
|
|
115
|
+
waitedMs: result.waitedMs,
|
|
116
|
+
sawLock: result.sawLock,
|
|
117
|
+
sawRetired: result.sawRetired,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// A signal-killed child has status null; surface it as a failure exit rather
|
|
121
|
+
// than pretending the command completed (mirrors self-update's reexecHq).
|
|
122
|
+
return { reexecStatus: child.status ?? 1 };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Resolve the running install's package dir, tolerating any resolver failure. */
|
|
126
|
+
function resolvePackageRoot(deps) {
|
|
127
|
+
try {
|
|
128
|
+
return (deps.resolveInstall ?? resolveRunningInstall)().packageRoot;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=startup-registration.js.map
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection and bounded, recovery-safe reporting for the "install tree torn out
|
|
3
|
+
* from under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K —
|
|
4
|
+
* 7713386011, 7713482655, 7713783135, 7714501738).
|
|
5
|
+
*
|
|
6
|
+
* hq-cli resolves most of its module graph lazily INSIDE `runCli` (the lazy
|
|
7
|
+
* command module or `import("./register-all.js")`, plus everything those pull in
|
|
8
|
+
* — nested `@aws-sdk/*`, `js-yaml`, `@indigoai-us/hq-cloud`). An EXTERNAL global
|
|
9
|
+
* reinstall — the outpost `hq-cli-update` systemd timer every 6h, the agent
|
|
10
|
+
* boxes' `self_update`, the desktop app — renames the installed package dir
|
|
11
|
+
* aside (`.hq-cli-<rand>`), recreates it empty, and re-extracts it file by file
|
|
12
|
+
* over ~16s. A process that started just before that rename resolves its
|
|
13
|
+
* deferred imports against a directory that no longer holds them and dies with
|
|
14
|
+
* ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND. None of those writers is the process's
|
|
15
|
+
* OWN updater (the version-gate and self-updater install synchronously under the
|
|
16
|
+
* shared lock and never overlap their own imports), so hq-cli cannot stop them —
|
|
17
|
+
* but it CAN detect the condition at the one seam where it is provably
|
|
18
|
+
* side-effect free (the registration phase, before `parseAsync` and before the
|
|
19
|
+
* preAction hook), wait (bounded) for the install to settle, and re-run itself
|
|
20
|
+
* once on the settled tree.
|
|
21
|
+
*
|
|
22
|
+
* This module is the reusable core of that recovery: a STRUCTURAL, closed
|
|
23
|
+
* classifier for the loader-error dialects Node raises; a readiness probe that
|
|
24
|
+
* re-resolves the missing target exactly as Node's resolver does; a bounded
|
|
25
|
+
* settle wait keyed on the same signals the writer leaves behind; and the typed
|
|
26
|
+
* carrier + boundary helpers (house style of package-root-diagnostics.ts) that
|
|
27
|
+
* keep a genuinely-broken install VISIBLE in Sentry with bounded, hq-derived
|
|
28
|
+
* diagnostics. The recovery seam that drives them lives in
|
|
29
|
+
* ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
|
|
30
|
+
* are only READ (their exported symbols), never modified.
|
|
31
|
+
*/
|
|
32
|
+
/** The two loader-error `code`s that mean "a module could not be resolved". */
|
|
33
|
+
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND";
|
|
34
|
+
/**
|
|
35
|
+
* The closed set of resolution-failure dialects, verified on Node v22.23.1 (the
|
|
36
|
+
* @sentry/node import-in-the-middle hook does not change the shapes):
|
|
37
|
+
* - `esm-path`: ESM importer, an absolute file (or legacyMainResolve dir)
|
|
38
|
+
* target — `err.url` set, or `Cannot find package '<abs>'…`.
|
|
39
|
+
* - `esm-package`: ESM importer, a bare specifier — `Cannot find package
|
|
40
|
+
* '<name>' imported from <importer>`, no `err.url`.
|
|
41
|
+
* - `cjs-path`: CJS require of an absolute path — `Cannot find module
|
|
42
|
+
* '<abs>'` + `requireStack`.
|
|
43
|
+
* - `cjs-package`: CJS require of a bare specifier — `Cannot find module
|
|
44
|
+
* '<name>'` + `requireStack`.
|
|
45
|
+
* - `unknown`: a module-not-found whose message did not parse; recovery
|
|
46
|
+
* still waits on the lock / retired-dir / quiet signals.
|
|
47
|
+
*/
|
|
48
|
+
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "unknown";
|
|
49
|
+
/**
|
|
50
|
+
* The missing thing, re-resolvable by the readiness probe:
|
|
51
|
+
* - `path`: an absolute filesystem path (a `.js` file, or a package dir).
|
|
52
|
+
* - `package`: a bare package `name` resolvable from directory `from` upward.
|
|
53
|
+
* - `unknown`: the message did not parse; treated as "present" by the probe so
|
|
54
|
+
* readiness turns only on the lock / retired-dir / quiet signals.
|
|
55
|
+
*/
|
|
56
|
+
export type ModuleErrorTarget = {
|
|
57
|
+
kind: "path";
|
|
58
|
+
path: string;
|
|
59
|
+
} | {
|
|
60
|
+
kind: "package";
|
|
61
|
+
name: string;
|
|
62
|
+
from: string;
|
|
63
|
+
} | {
|
|
64
|
+
kind: "unknown";
|
|
65
|
+
};
|
|
66
|
+
export interface ClassifiedModuleError {
|
|
67
|
+
code: ModuleNotFoundCode;
|
|
68
|
+
dialect: ModuleErrorDialect;
|
|
69
|
+
/** The raw specifier the message named (absolute path or bare name), or "". */
|
|
70
|
+
specifier: string;
|
|
71
|
+
/** The importer the message named (from-clause or requireStack[0]), or "". */
|
|
72
|
+
importer: string;
|
|
73
|
+
target: ModuleErrorTarget;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
77
|
+
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
78
|
+
* re-resolves (Node looks up `node_modules/<package>/package.json`, never the
|
|
79
|
+
* subpath, when it raises the bare-specifier dialects).
|
|
80
|
+
*/
|
|
81
|
+
export declare function packageNameOf(specifier: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* Classify a thrown value as a module-resolution failure and extract the missing
|
|
84
|
+
* target, or return `null` for anything that is not one. The decision is
|
|
85
|
+
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
|
|
86
|
+
* `MODULE_NOT_FOUND` — no argv, env, or free text is ever consulted, and any
|
|
87
|
+
* other error (including an import-time throw of another class) returns `null`
|
|
88
|
+
* so it is rethrown to the existing boundary unchanged.
|
|
89
|
+
*/
|
|
90
|
+
export declare function classifyModuleNotFound(err: unknown): ClassifiedModuleError | null;
|
|
91
|
+
/** The filesystem surface the probe and wait use, injectable for hermetic tests. */
|
|
92
|
+
export interface InstallTreeFs {
|
|
93
|
+
existsSync(p: string): boolean;
|
|
94
|
+
statSync(p: string): {
|
|
95
|
+
isDirectory(): boolean;
|
|
96
|
+
};
|
|
97
|
+
readFileSync(p: string, encoding: "utf-8"): string;
|
|
98
|
+
readdirSync(p: string): string[];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the missing target is now present on disk — the readiness signal the
|
|
102
|
+
* settle wait polls. It re-resolves the SAME step Node's resolver took:
|
|
103
|
+
* - `path`: the file must exist; a directory target must additionally hold
|
|
104
|
+
* a `package.json` (the legacyMainResolve case).
|
|
105
|
+
* - `package`: walk from `from` toward the filesystem root and return true as
|
|
106
|
+
* soon as `<dir>/node_modules/<name>/package.json` exists — the
|
|
107
|
+
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
108
|
+
* `createRequire` so an ESM-only / export-conditioned package
|
|
109
|
+
* cannot false-negative.
|
|
110
|
+
* - `unknown`: true (readiness turns on the other signals).
|
|
111
|
+
* Any filesystem error reads as "not present" rather than throwing.
|
|
112
|
+
*/
|
|
113
|
+
export declare function installTargetPresent(target: ModuleErrorTarget, fs?: InstallTreeFs): boolean;
|
|
114
|
+
export interface WaitForInstallTreeSettledArgs {
|
|
115
|
+
target: ModuleErrorTarget;
|
|
116
|
+
/** The running install's package dir (from resolveRunningInstall), or null. */
|
|
117
|
+
packageRoot: string | null;
|
|
118
|
+
/** The shared update-lock path ($HOME/.hq/locks/cli-update.lock). */
|
|
119
|
+
lockPath: string;
|
|
120
|
+
now?: () => number;
|
|
121
|
+
sleep?: (ms: number) => Promise<void>;
|
|
122
|
+
fs?: InstallTreeFs;
|
|
123
|
+
isPidAlive?: (pid: number) => boolean;
|
|
124
|
+
pollMs?: number;
|
|
125
|
+
quietMs?: number;
|
|
126
|
+
deadlineMs: number;
|
|
127
|
+
}
|
|
128
|
+
export interface WaitForInstallTreeSettledResult {
|
|
129
|
+
settled: boolean;
|
|
130
|
+
waitedMs: number;
|
|
131
|
+
/** A FRESH update lock held by another pid was observed at least once. */
|
|
132
|
+
sawLock: boolean;
|
|
133
|
+
/** An npm retired/staging sibling (`.hq-cli-<rand>`) was observed at least once. */
|
|
134
|
+
sawRetired: boolean;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
138
|
+
* `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
|
|
139
|
+
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
140
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
141
|
+
* (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
|
|
142
|
+
* error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
|
|
143
|
+
* evaluates readiness exactly once (no waiting); otherwise the whole wait is
|
|
144
|
+
* bounded, so the caller can never hang.
|
|
145
|
+
*/
|
|
146
|
+
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
147
|
+
/** The recovery OUTCOME, which — not the dialect — decides capture. */
|
|
148
|
+
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed";
|
|
149
|
+
export type InstallTreeTornDiagnostics = {
|
|
150
|
+
dialect: ModuleErrorDialect;
|
|
151
|
+
code: ModuleNotFoundCode;
|
|
152
|
+
specifier: string;
|
|
153
|
+
importer: string;
|
|
154
|
+
packageRoot: string;
|
|
155
|
+
outcome: InstallTreeTornOutcome;
|
|
156
|
+
attempt: 1 | 2;
|
|
157
|
+
waitedMs: number;
|
|
158
|
+
sawLock: boolean;
|
|
159
|
+
sawRetired: boolean;
|
|
160
|
+
node: string;
|
|
161
|
+
};
|
|
162
|
+
export interface InstallTreeTornInit {
|
|
163
|
+
/** The original loader error, carried unchanged as `cause`. */
|
|
164
|
+
cause: unknown;
|
|
165
|
+
classified: ClassifiedModuleError;
|
|
166
|
+
packageRoot: string | null;
|
|
167
|
+
outcome: InstallTreeTornOutcome;
|
|
168
|
+
attempt: 1 | 2;
|
|
169
|
+
waitedMs: number;
|
|
170
|
+
sawLock: boolean;
|
|
171
|
+
sawRetired: boolean;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
175
|
+
* diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
|
|
176
|
+
* `cause` = the original loader error, and a bounded `diagnostics` object whose
|
|
177
|
+
* only interpolated strings are the hq-derived specifier/importer/packageRoot,
|
|
178
|
+
* each capped. Mirrors PackageRootResolutionError's construction discipline.
|
|
179
|
+
*/
|
|
180
|
+
export declare class InstallTreeTornError extends Error {
|
|
181
|
+
readonly diagnostics: InstallTreeTornDiagnostics;
|
|
182
|
+
constructor(init: InstallTreeTornInit);
|
|
183
|
+
}
|
|
184
|
+
export declare function isInstallTreeTornError(err: unknown): err is InstallTreeTornError;
|
|
185
|
+
/** The exact Sentry `contexts` payload for a captured torn-install failure. */
|
|
186
|
+
export declare function installTreeTornCaptureContext(err: InstallTreeTornError): {
|
|
187
|
+
contexts: {
|
|
188
|
+
install_tree_torn: InstallTreeTornDiagnostics;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* The fixed, input-free operator remedy. It names the likely writers (so a
|
|
193
|
+
* human knows this was not their command's fault) and the reinstall commands,
|
|
194
|
+
* and interpolates NOTHING — the only per-event value is the bounded specifier
|
|
195
|
+
* appended by {@link installTreeTornStderrLine}.
|
|
196
|
+
*/
|
|
197
|
+
export declare const INSTALL_TREE_TORN_REMEDY: string;
|
|
198
|
+
/**
|
|
199
|
+
* The single actionable stderr line for a captured torn-install failure: the
|
|
200
|
+
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
201
|
+
* interpolated value, never argv.
|
|
202
|
+
*/
|
|
203
|
+
export declare function installTreeTornStderrLine(err: InstallTreeTornError): string;
|
|
204
|
+
//# sourceMappingURL=install-tree-torn.d.ts.map
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection and bounded, recovery-safe reporting for the "install tree torn out
|
|
3
|
+
* from under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K —
|
|
4
|
+
* 7713386011, 7713482655, 7713783135, 7714501738).
|
|
5
|
+
*
|
|
6
|
+
* hq-cli resolves most of its module graph lazily INSIDE `runCli` (the lazy
|
|
7
|
+
* command module or `import("./register-all.js")`, plus everything those pull in
|
|
8
|
+
* — nested `@aws-sdk/*`, `js-yaml`, `@indigoai-us/hq-cloud`). An EXTERNAL global
|
|
9
|
+
* reinstall — the outpost `hq-cli-update` systemd timer every 6h, the agent
|
|
10
|
+
* boxes' `self_update`, the desktop app — renames the installed package dir
|
|
11
|
+
* aside (`.hq-cli-<rand>`), recreates it empty, and re-extracts it file by file
|
|
12
|
+
* over ~16s. A process that started just before that rename resolves its
|
|
13
|
+
* deferred imports against a directory that no longer holds them and dies with
|
|
14
|
+
* ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND. None of those writers is the process's
|
|
15
|
+
* OWN updater (the version-gate and self-updater install synchronously under the
|
|
16
|
+
* shared lock and never overlap their own imports), so hq-cli cannot stop them —
|
|
17
|
+
* but it CAN detect the condition at the one seam where it is provably
|
|
18
|
+
* side-effect free (the registration phase, before `parseAsync` and before the
|
|
19
|
+
* preAction hook), wait (bounded) for the install to settle, and re-run itself
|
|
20
|
+
* once on the settled tree.
|
|
21
|
+
*
|
|
22
|
+
* This module is the reusable core of that recovery: a STRUCTURAL, closed
|
|
23
|
+
* classifier for the loader-error dialects Node raises; a readiness probe that
|
|
24
|
+
* re-resolves the missing target exactly as Node's resolver does; a bounded
|
|
25
|
+
* settle wait keyed on the same signals the writer leaves behind; and the typed
|
|
26
|
+
* carrier + boundary helpers (house style of package-root-diagnostics.ts) that
|
|
27
|
+
* keep a genuinely-broken install VISIBLE in Sentry with bounded, hq-derived
|
|
28
|
+
* diagnostics. The recovery seam that drives them lives in
|
|
29
|
+
* ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
|
|
30
|
+
* are only READ (their exported symbols), never modified.
|
|
31
|
+
*/
|
|
32
|
+
import * as nodeFs from "node:fs";
|
|
33
|
+
import * as path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { CLI_NAME } from "../cli-version.js";
|
|
36
|
+
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
37
|
+
import { isLockStale } from "./update-lock.js";
|
|
38
|
+
/** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
|
|
39
|
+
const SPECIFIER_BYTES = 256;
|
|
40
|
+
const IMPORTER_BYTES = 256;
|
|
41
|
+
const PACKAGE_ROOT_BYTES = 256;
|
|
42
|
+
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
43
|
+
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
44
|
+
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
45
|
+
/**
|
|
46
|
+
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
47
|
+
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
48
|
+
* re-resolves (Node looks up `node_modules/<package>/package.json`, never the
|
|
49
|
+
* subpath, when it raises the bare-specifier dialects).
|
|
50
|
+
*/
|
|
51
|
+
export function packageNameOf(specifier) {
|
|
52
|
+
const parts = specifier.split("/");
|
|
53
|
+
if (specifier.startsWith("@"))
|
|
54
|
+
return parts.slice(0, 2).join("/");
|
|
55
|
+
return parts[0] ?? specifier;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Classify a thrown value as a module-resolution failure and extract the missing
|
|
59
|
+
* target, or return `null` for anything that is not one. The decision is
|
|
60
|
+
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
|
|
61
|
+
* `MODULE_NOT_FOUND` — no argv, env, or free text is ever consulted, and any
|
|
62
|
+
* other error (including an import-time throw of another class) returns `null`
|
|
63
|
+
* so it is rethrown to the existing boundary unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyModuleNotFound(err) {
|
|
66
|
+
if (err === null || typeof err !== "object")
|
|
67
|
+
return null;
|
|
68
|
+
const record = err;
|
|
69
|
+
const code = record.code;
|
|
70
|
+
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
|
71
|
+
return null;
|
|
72
|
+
const message = typeof record.message === "string" ? record.message : "";
|
|
73
|
+
// (a) ESM path — `err.url` is a `file:` URL. This is checked first because the
|
|
74
|
+
// ESM path message ALSO matches the imported-from dialect (d); the url is the
|
|
75
|
+
// authoritative resolved target.
|
|
76
|
+
if (typeof record.url === "string" && record.url.startsWith("file:")) {
|
|
77
|
+
const target = fileURLToPath(record.url);
|
|
78
|
+
const importer = ESM_MODULE_IMPORTED_RE.exec(message)?.[2] ?? "";
|
|
79
|
+
return { code, dialect: "esm-path", specifier: target, importer, target: { kind: "path", path: target } };
|
|
80
|
+
}
|
|
81
|
+
// (b) ESM `Cannot find package '<spec>' imported from <importer>`. `<spec>` is
|
|
82
|
+
// a bare name for the ordinary case, or an ABSOLUTE `<dir>/index.js` token for
|
|
83
|
+
// the legacyMainResolve variant (a package dir that exists but has no usable
|
|
84
|
+
// package.json/main) — the latter is a path target.
|
|
85
|
+
const esmPkg = ESM_PACKAGE_RE.exec(message);
|
|
86
|
+
if (esmPkg) {
|
|
87
|
+
const spec = esmPkg[1];
|
|
88
|
+
const importer = esmPkg[2];
|
|
89
|
+
if (path.isAbsolute(spec)) {
|
|
90
|
+
return { code, dialect: "esm-path", specifier: spec, importer, target: { kind: "path", path: spec } };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
code,
|
|
94
|
+
dialect: "esm-package",
|
|
95
|
+
specifier: spec,
|
|
96
|
+
importer,
|
|
97
|
+
target: { kind: "package", name: spec, from: path.dirname(importer) },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// (c) CJS `Cannot find module '<spec>'` (+ Require stack). Absolute → a path
|
|
101
|
+
// target; bare → the package the require named, resolved from requireStack[0].
|
|
102
|
+
if (code === "MODULE_NOT_FOUND") {
|
|
103
|
+
const cjs = CJS_MODULE_RE.exec(message);
|
|
104
|
+
if (cjs) {
|
|
105
|
+
const spec = cjs[1];
|
|
106
|
+
const requireStack = Array.isArray(record.requireStack) ? record.requireStack : [];
|
|
107
|
+
const importer = typeof requireStack[0] === "string" ? requireStack[0] : "";
|
|
108
|
+
if (path.isAbsolute(spec)) {
|
|
109
|
+
return { code, dialect: "cjs-path", specifier: spec, importer, target: { kind: "path", path: spec } };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
code,
|
|
113
|
+
dialect: "cjs-package",
|
|
114
|
+
specifier: spec,
|
|
115
|
+
importer,
|
|
116
|
+
target: { kind: "package", name: packageNameOf(spec), from: path.dirname(importer) },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// (d) ESM `Cannot find module '<abs>' imported from <importer>` WITHOUT a url
|
|
121
|
+
// (defensive — the url arm normally wins). The specifier is the resolved
|
|
122
|
+
// absolute path.
|
|
123
|
+
const esmMod = ESM_MODULE_IMPORTED_RE.exec(message);
|
|
124
|
+
if (esmMod) {
|
|
125
|
+
const spec = esmMod[1];
|
|
126
|
+
return { code, dialect: "esm-path", specifier: spec, importer: esmMod[2], target: { kind: "path", path: spec } };
|
|
127
|
+
}
|
|
128
|
+
// (e) A module-not-found whose message did not parse — still recover, keyed on
|
|
129
|
+
// the lock / retired-dir / quiet signals only.
|
|
130
|
+
return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
|
|
131
|
+
}
|
|
132
|
+
const nodeInstallTreeFs = {
|
|
133
|
+
existsSync: (p) => nodeFs.existsSync(p),
|
|
134
|
+
statSync: (p) => nodeFs.statSync(p),
|
|
135
|
+
readFileSync: (p, encoding) => nodeFs.readFileSync(p, encoding),
|
|
136
|
+
readdirSync: (p) => nodeFs.readdirSync(p),
|
|
137
|
+
};
|
|
138
|
+
/** Liveness probe with update-lock.ts's semantics (ESRCH dead, EPERM alive). */
|
|
139
|
+
function defaultIsPidAlive(pid) {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(pid, 0);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
return err.code === "EPERM";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether the missing target is now present on disk — the readiness signal the
|
|
150
|
+
* settle wait polls. It re-resolves the SAME step Node's resolver took:
|
|
151
|
+
* - `path`: the file must exist; a directory target must additionally hold
|
|
152
|
+
* a `package.json` (the legacyMainResolve case).
|
|
153
|
+
* - `package`: walk from `from` toward the filesystem root and return true as
|
|
154
|
+
* soon as `<dir>/node_modules/<name>/package.json` exists — the
|
|
155
|
+
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
156
|
+
* `createRequire` so an ESM-only / export-conditioned package
|
|
157
|
+
* cannot false-negative.
|
|
158
|
+
* - `unknown`: true (readiness turns on the other signals).
|
|
159
|
+
* Any filesystem error reads as "not present" rather than throwing.
|
|
160
|
+
*/
|
|
161
|
+
export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
162
|
+
try {
|
|
163
|
+
if (target.kind === "unknown")
|
|
164
|
+
return true;
|
|
165
|
+
if (target.kind === "path") {
|
|
166
|
+
if (!fs.existsSync(target.path))
|
|
167
|
+
return false;
|
|
168
|
+
try {
|
|
169
|
+
if (fs.statSync(target.path).isDirectory()) {
|
|
170
|
+
return fs.existsSync(path.join(target.path, "package.json"));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
// package: ancestor walk from `from`.
|
|
179
|
+
let dir = path.resolve(target.from);
|
|
180
|
+
for (;;) {
|
|
181
|
+
const manifest = path.join(dir, "node_modules", target.name, "package.json");
|
|
182
|
+
if (fs.existsSync(manifest))
|
|
183
|
+
return true;
|
|
184
|
+
const parent = path.dirname(dir);
|
|
185
|
+
if (parent === dir)
|
|
186
|
+
return false;
|
|
187
|
+
dir = parent;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
|
|
195
|
+
function cliNameParts() {
|
|
196
|
+
const slash = CLI_NAME.indexOf("/");
|
|
197
|
+
if (slash === -1)
|
|
198
|
+
return { scope: null, leaf: CLI_NAME };
|
|
199
|
+
return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
|
|
200
|
+
}
|
|
201
|
+
/** Whether a FRESH update lock is held by a DIFFERENT live pid (blocks readiness). */
|
|
202
|
+
function freshForeignLockHeld(lockPath, nowMs, isPidAlive, fs) {
|
|
203
|
+
let raw;
|
|
204
|
+
try {
|
|
205
|
+
raw = fs.readFileSync(lockPath, "utf-8");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false; // no lock (ENOENT) or unreadable — not a fresh foreign holder
|
|
209
|
+
}
|
|
210
|
+
if (isLockStale(raw, nowMs, isPidAlive))
|
|
211
|
+
return false;
|
|
212
|
+
try {
|
|
213
|
+
const info = JSON.parse(raw);
|
|
214
|
+
if (info.pid === process.pid)
|
|
215
|
+
return false; // our own lock never blocks us
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false; // isLockStale already treats unparseable as stale, unreachable
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
|
|
223
|
+
function retiredSiblingPresent(packageRoot, fs) {
|
|
224
|
+
const { leaf } = cliNameParts();
|
|
225
|
+
const stagingPrefix = `.${leaf}-`;
|
|
226
|
+
const parent = path.dirname(packageRoot);
|
|
227
|
+
const entries = fs.readdirSync(parent); // caller treats a throw as "not ready"
|
|
228
|
+
return entries.some((entry) => entry.startsWith(stagingPrefix));
|
|
229
|
+
}
|
|
230
|
+
/** Whether packageRoot's own manifest is healthy (`name === CLI_NAME`). */
|
|
231
|
+
function packageRootHealthy(packageRoot, fs) {
|
|
232
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
|
|
233
|
+
return manifest.name === CLI_NAME;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
237
|
+
* `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
|
|
238
|
+
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
239
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
240
|
+
* (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
|
|
241
|
+
* error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
|
|
242
|
+
* evaluates readiness exactly once (no waiting); otherwise the whole wait is
|
|
243
|
+
* bounded, so the caller can never hang.
|
|
244
|
+
*/
|
|
245
|
+
export async function waitForInstallTreeSettled(args) {
|
|
246
|
+
const now = args.now ?? Date.now;
|
|
247
|
+
const sleep = args.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
248
|
+
const fs = args.fs ?? nodeInstallTreeFs;
|
|
249
|
+
const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
|
|
250
|
+
const pollMs = args.pollMs ?? 250;
|
|
251
|
+
const quietMs = args.quietMs ?? 1500;
|
|
252
|
+
const deadlineMs = args.deadlineMs;
|
|
253
|
+
const start = now();
|
|
254
|
+
let readySince = null;
|
|
255
|
+
let sawLock = false;
|
|
256
|
+
let sawRetired = false;
|
|
257
|
+
for (;;) {
|
|
258
|
+
const t = now();
|
|
259
|
+
const foreignLock = freshForeignLockHeld(args.lockPath, t, isPidAlive, fs);
|
|
260
|
+
if (foreignLock)
|
|
261
|
+
sawLock = true;
|
|
262
|
+
let ready = !foreignLock && installTargetPresent(args.target, fs);
|
|
263
|
+
if (ready && args.packageRoot) {
|
|
264
|
+
try {
|
|
265
|
+
if (!packageRootHealthy(args.packageRoot, fs))
|
|
266
|
+
ready = false;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
ready = false;
|
|
270
|
+
}
|
|
271
|
+
if (ready) {
|
|
272
|
+
try {
|
|
273
|
+
if (retiredSiblingPresent(args.packageRoot, fs)) {
|
|
274
|
+
sawRetired = true;
|
|
275
|
+
ready = false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
ready = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (ready) {
|
|
284
|
+
if (readySince === null)
|
|
285
|
+
readySince = t;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
readySince = null;
|
|
289
|
+
}
|
|
290
|
+
const waitedMs = t - start;
|
|
291
|
+
if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
|
|
292
|
+
return { settled: true, waitedMs, sawLock, sawRetired };
|
|
293
|
+
}
|
|
294
|
+
if (waitedMs >= deadlineMs) {
|
|
295
|
+
return { settled: false, waitedMs, sawLock, sawRetired };
|
|
296
|
+
}
|
|
297
|
+
await sleep(pollMs);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** The fixed, input-free carrier message (grouping cardinality stays bounded). */
|
|
301
|
+
const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
|
|
302
|
+
/**
|
|
303
|
+
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
304
|
+
* diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
|
|
305
|
+
* `cause` = the original loader error, and a bounded `diagnostics` object whose
|
|
306
|
+
* only interpolated strings are the hq-derived specifier/importer/packageRoot,
|
|
307
|
+
* each capped. Mirrors PackageRootResolutionError's construction discipline.
|
|
308
|
+
*/
|
|
309
|
+
export class InstallTreeTornError extends Error {
|
|
310
|
+
diagnostics;
|
|
311
|
+
constructor(init) {
|
|
312
|
+
super(INSTALL_TREE_TORN_MESSAGE);
|
|
313
|
+
this.name = "InstallTreeTornError";
|
|
314
|
+
this.cause = init.cause;
|
|
315
|
+
this.diagnostics = {
|
|
316
|
+
dialect: init.classified.dialect,
|
|
317
|
+
code: init.classified.code,
|
|
318
|
+
specifier: boundedDiagnosticValue(init.classified.specifier, SPECIFIER_BYTES),
|
|
319
|
+
importer: boundedDiagnosticValue(init.classified.importer, IMPORTER_BYTES),
|
|
320
|
+
packageRoot: boundedDiagnosticValue(init.packageRoot ?? "", PACKAGE_ROOT_BYTES),
|
|
321
|
+
outcome: init.outcome,
|
|
322
|
+
attempt: init.attempt,
|
|
323
|
+
waitedMs: init.waitedMs,
|
|
324
|
+
sawLock: init.sawLock,
|
|
325
|
+
sawRetired: init.sawRetired,
|
|
326
|
+
node: process.version,
|
|
327
|
+
};
|
|
328
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export function isInstallTreeTornError(err) {
|
|
332
|
+
return err instanceof InstallTreeTornError;
|
|
333
|
+
}
|
|
334
|
+
/** The exact Sentry `contexts` payload for a captured torn-install failure. */
|
|
335
|
+
export function installTreeTornCaptureContext(err) {
|
|
336
|
+
return { contexts: { install_tree_torn: err.diagnostics } };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The fixed, input-free operator remedy. It names the likely writers (so a
|
|
340
|
+
* human knows this was not their command's fault) and the reinstall commands,
|
|
341
|
+
* and interpolates NOTHING — the only per-event value is the bounded specifier
|
|
342
|
+
* appended by {@link installTreeTornStderrLine}.
|
|
343
|
+
*/
|
|
344
|
+
export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by another process (the box's hq-cli-update " +
|
|
345
|
+
"timer, the desktop app, or another hq command) while this command started. " +
|
|
346
|
+
"Re-run your command; if it keeps failing, reinstall with " +
|
|
347
|
+
"`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
|
|
348
|
+
/**
|
|
349
|
+
* The single actionable stderr line for a captured torn-install failure: the
|
|
350
|
+
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
351
|
+
* interpolated value, never argv.
|
|
352
|
+
*/
|
|
353
|
+
export function installTreeTornStderrLine(err) {
|
|
354
|
+
const specifier = err.diagnostics.specifier;
|
|
355
|
+
return specifier
|
|
356
|
+
? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
|
|
357
|
+
: INSTALL_TREE_TORN_REMEDY;
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=install-tree-torn.js.map
|
|
@@ -91,6 +91,18 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
91
91
|
// listed defensively so a future capture path cannot mint an unbounded group
|
|
92
92
|
// (HQ-CLI-1A).
|
|
93
93
|
"QmdWorkdirMissingError",
|
|
94
|
+
// A torn-install failure the bounded settle-wait + single re-exec could not
|
|
95
|
+
// recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below by the
|
|
96
|
+
// bounded `diagnostics.outcome`, so the whole family groups into ≤3 issues.
|
|
97
|
+
"InstallTreeTornError",
|
|
98
|
+
]);
|
|
99
|
+
/**
|
|
100
|
+
* The CLOSED set of InstallTreeTornError recovery outcomes worth their own
|
|
101
|
+
* group. Anything else collapses to `outcome:other`, so the axis stays finite;
|
|
102
|
+
* the carrier's message is fixed, so this outcome IS the only discriminator.
|
|
103
|
+
*/
|
|
104
|
+
const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
|
|
105
|
+
"guarded", "unsettled", "reexec-failed",
|
|
94
106
|
]);
|
|
95
107
|
/** Fixed bucket for any error name outside the closed allowlist. */
|
|
96
108
|
const FALLBACK_ERROR_NAME = "other";
|
|
@@ -200,6 +212,20 @@ export function sentryFingerprintFor(err) {
|
|
|
200
212
|
qmdDispositionToken(record.status, record.signal),
|
|
201
213
|
];
|
|
202
214
|
}
|
|
215
|
+
// A torn-install failure carries neither an rpcCode nor an HTTP status; its
|
|
216
|
+
// only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
|
|
217
|
+
// BEFORE the rpc/status branches so the family groups into ≤3 predictable
|
|
218
|
+
// issues (guarded / unsettled / reexec-failed) rather than one fungible bucket.
|
|
219
|
+
if (rawName === "InstallTreeTornError") {
|
|
220
|
+
const diagnostics = record.diagnostics;
|
|
221
|
+
const outcome = diagnostics !== null && typeof diagnostics === "object"
|
|
222
|
+
? diagnostics.outcome
|
|
223
|
+
: undefined;
|
|
224
|
+
const token = typeof outcome === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(outcome)
|
|
225
|
+
? `outcome:${outcome}`
|
|
226
|
+
: "outcome:other";
|
|
227
|
+
return [DEFAULT_GROUPING, "InstallTreeTornError", token];
|
|
228
|
+
}
|
|
203
229
|
const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
|
|
204
230
|
const rpcCode = record.rpcCode;
|
|
205
231
|
if (typeof rpcCode === "number" && Number.isInteger(rpcCode)) {
|