@indigoai-us/hq-cli 5.108.17 → 5.108.19
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 +38 -18
- package/dist/commands/sync-manifest.d.ts +213 -0
- package/dist/commands/sync-manifest.js +384 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +46 -1
- package/dist/lib/doctor/checks/sync-health.js +212 -5
- package/dist/lib/hq-cloud-manifest.d.ts +62 -0
- package/dist/lib/hq-cloud-manifest.js +83 -0
- package/dist/main.js +51 -7
- package/dist/register-all.js +2 -0
- 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 +2 -2
|
@@ -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
|