@indigoai-us/hq-cli 5.106.1 → 5.106.3
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 +20 -0
- package/dist/commands/run.js +25 -2
- package/dist/index.js +12 -1
- package/dist/main.js +12 -0
- package/dist/run/env-graph-guard.d.ts +83 -0
- package/dist/run/env-graph-guard.js +155 -0
- package/dist/unhandled-rejection-boundary.d.ts +30 -0
- package/dist/unhandled-rejection-boundary.js +106 -0
- package/dist/utils/cli-telemetry.js +1 -0
- package/dist/utils/self-update.js +11 -2
- package/dist/utils/version-gate.d.ts +64 -2
- package/dist/utils/version-gate.js +173 -7
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.106.3] — 2026-09-02
|
|
6
|
+
|
|
7
|
+
## [5.106.2] — 2026-09-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- An `hq` installed as a local project dependency no longer updates itself in an
|
|
12
|
+
endless loop. The updater used to treat a pnpm virtual store
|
|
13
|
+
(`node_modules/.pnpm/...`) as an npm install prefix and install there — a
|
|
14
|
+
directory nothing on PATH ever reads — then report success, so the next
|
|
15
|
+
command found the same old build and updated again. On one outpost this ran
|
|
16
|
+
`npm install -g` up to 31 times an hour for days. Such copies are now
|
|
17
|
+
identified as local, reported with the path to fix, and left for their owning
|
|
18
|
+
project to update.
|
|
19
|
+
- The updater refuses an "update" to a version that is not newer than the one
|
|
20
|
+
already installed, instead of reinstalling it on every invocation.
|
|
21
|
+
- Success is now reported only after verifying the new version is the one PATH
|
|
22
|
+
actually resolves. An install that lands somewhere the running `hq` never
|
|
23
|
+
reads is reported as a failed update rather than as a success.
|
|
24
|
+
|
|
5
25
|
## [5.106.1] — 2026-09-02
|
|
6
26
|
|
|
7
27
|
## [5.106.0] — 2026-09-02
|
package/dist/commands/run.js
CHANGED
|
@@ -8,6 +8,8 @@ import { computeSha256 } from '../utils/integrity.js';
|
|
|
8
8
|
import { vaultApiFetch, getCompanyUid } from '../utils/vault-api.js';
|
|
9
9
|
import { discoverSchemas } from '../run/discover-schemas.js';
|
|
10
10
|
import { installHqPlugin, prewarmHqSecrets } from '../run/hq-plugin.js';
|
|
11
|
+
import { assertEnvGraphLoaded, resolveEnvValuesOrThrow, EnvGraphLoadError, EnvResolutionError, } from '../run/env-graph-guard.js';
|
|
12
|
+
import { hadFatalRejection } from '../unhandled-rejection-boundary.js';
|
|
11
13
|
const SECRET_LOAD_TIMEOUT_MS = 30_000;
|
|
12
14
|
export async function buildRunUsage(scriptPath, scriptId) {
|
|
13
15
|
if (scriptId && !scriptPath) {
|
|
@@ -119,8 +121,18 @@ export function registerRunCommand(program) {
|
|
|
119
121
|
entryFilePaths: paths,
|
|
120
122
|
afterInit: async (g) => { state = installHqPlugin(g, pluginOpts); },
|
|
121
123
|
});
|
|
124
|
+
// Refuse to resolve a graph varlock never finished loading (a malformed
|
|
125
|
+
// .env.schema / .env.local): resolving an unprocessed item whose key is
|
|
126
|
+
// also in process.env reaches varlock's `expected dataType to be set`
|
|
127
|
+
// invariant as a FLOATED, never-settling rejection — the CLI then exits
|
|
128
|
+
// 0 with the command never run (HQ-CLI-W). Run this BEFORE prewarm so a
|
|
129
|
+
// bad file can never trigger a secret fetch.
|
|
130
|
+
assertEnvGraphLoaded(graph);
|
|
122
131
|
await prewarmHqSecrets(graph, pluginOpts, state);
|
|
123
|
-
|
|
132
|
+
// Defence in depth: convert a floated resolution rejection into a thrown
|
|
133
|
+
// error instead of hanging on varlock's never-settling promise, in case
|
|
134
|
+
// the shape-dependent guard above ever silently degrades.
|
|
135
|
+
await resolveEnvValuesOrThrow(graph);
|
|
124
136
|
const schemaErrors = Object.entries(graph.configSchema)
|
|
125
137
|
.filter(([, item]) => item.errors?.length > 0);
|
|
126
138
|
if (schemaErrors.length > 0) {
|
|
@@ -147,10 +159,21 @@ export function registerRunCommand(program) {
|
|
|
147
159
|
if (signal) {
|
|
148
160
|
process.kill(process.pid, signal);
|
|
149
161
|
}
|
|
150
|
-
process.
|
|
162
|
+
// A fatal floated rejection during the child run set process.exitCode,
|
|
163
|
+
// which this explicit exit would otherwise erase. Force a non-zero
|
|
164
|
+
// code so such a rejection is never masked by a child that exited 0,
|
|
165
|
+
// while still propagating a genuine non-zero child status.
|
|
166
|
+
process.exit(hadFatalRejection() ? (code || 1) : (code ?? 1));
|
|
151
167
|
});
|
|
152
168
|
}
|
|
153
169
|
catch (err) {
|
|
170
|
+
// Typed varlock env-schema failures are the user's file, not an hq-cli
|
|
171
|
+
// defect. Re-throw them so main.ts's handleTopLevelError prints one
|
|
172
|
+
// actionable `hq:` line and skips Sentry capture; everything else keeps
|
|
173
|
+
// run.ts's existing `Error:`-prefixed exit-1 behaviour.
|
|
174
|
+
if (err instanceof EnvGraphLoadError || err instanceof EnvResolutionError) {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
154
177
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
155
178
|
process.exit(1);
|
|
156
179
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,10 +7,20 @@ import { CLI_VERSION } from "./cli-version.js";
|
|
|
7
7
|
// Dependency-light: a pure parser + table, no command graph. Safe to load on
|
|
8
8
|
// every path (including --version) without reintroducing the heavy startup.
|
|
9
9
|
import { isFastCoreRequest } from "./commands/scaffold-fast.js";
|
|
10
|
+
// Also dependency-light: registers a process-level rejection boundary; its only
|
|
11
|
+
// heavy import (main.js) is lazy and reached only when a rejection fires.
|
|
12
|
+
import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
|
|
10
13
|
function isVersionRequest(argv) {
|
|
11
14
|
const args = argv.slice(2);
|
|
12
15
|
return args.length === 1 && (args[0] === "--version" || args[0] === "-V" || args[0] === "-v");
|
|
13
16
|
}
|
|
17
|
+
// Install BEFORE any dispatch so a floated rejection on ANY path (the command
|
|
18
|
+
// graph or the fast-core forwarder) becomes a non-zero exit. @sentry/node's
|
|
19
|
+
// onUnhandledRejection integration defaults to 'warn' — it captures but does
|
|
20
|
+
// NOT rethrow — so without this the event loop drains and Node exits 0 with the
|
|
21
|
+
// command never run (HQ-CLI-W). Sets process.exitCode only, never process.exit,
|
|
22
|
+
// so runCli's finally still finalizes release health and flushes Sentry.
|
|
23
|
+
installProcessRejectionBoundary();
|
|
14
24
|
if (isVersionRequest(process.argv)) {
|
|
15
25
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
16
26
|
}
|
|
@@ -25,7 +35,8 @@ else {
|
|
|
25
35
|
// before the fast --version split. Some best-effort teardown work uses
|
|
26
36
|
// unref'd handles; top-level-awaiting runCli() makes Node turn an otherwise
|
|
27
37
|
// successful command into exit 13 when that teardown promise remains pending.
|
|
28
|
-
//
|
|
38
|
+
// A floated rejection here no longer exits 0 silently: the boundary installed
|
|
39
|
+
// above turns it into a non-zero exit (HQ-CLI-W).
|
|
29
40
|
void import("./main.js").then(({ runCli }) => runCli());
|
|
30
41
|
}
|
|
31
42
|
export const __test__ = { isVersionRequest, isFastCoreRequest };
|
package/dist/main.js
CHANGED
|
@@ -75,6 +75,7 @@ import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
|
|
|
75
75
|
import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
|
|
76
76
|
import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
|
|
77
77
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
78
|
+
import { isVarlockEnvError } from "./run/env-graph-guard.js";
|
|
78
79
|
import { isEpipe } from "./utils/epipe.js";
|
|
79
80
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
80
81
|
import { isAuthError } from "./utils/auth-error.js";
|
|
@@ -463,6 +464,17 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
463
464
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
464
465
|
deps.setExitCode(1);
|
|
465
466
|
}
|
|
467
|
+
else if (isVarlockEnvError(err)) {
|
|
468
|
+
// HQ-CLI-W: `hq run` could not load or resolve a .env.schema/.env.local
|
|
469
|
+
// because the file itself is malformed. The typed carrier
|
|
470
|
+
// (EnvGraphLoadError / EnvResolutionError) already names the offending
|
|
471
|
+
// file and the pinned varlock version, so print that one actionable line
|
|
472
|
+
// and skip Sentry capture — a user's un-parseable env file is their file,
|
|
473
|
+
// not an hq-cli defect. A genuinely unclassified floated rejection is not
|
|
474
|
+
// one of these carriers, so it keeps falling through to the capture arm.
|
|
475
|
+
deps.stderr.write(`hq: ${err.message}\n`);
|
|
476
|
+
deps.setExitCode(1);
|
|
477
|
+
}
|
|
466
478
|
else if (isPackageRootResolutionError(err)) {
|
|
467
479
|
deps.stderr.write(`hq: ${err.message}\n`);
|
|
468
480
|
deps.sentry.captureException(err, {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** The varlock version hq-cli is pinned to (package.json `dependencies`). */
|
|
2
|
+
export declare const VARLOCK_PINNED_VERSION = "1.0.0";
|
|
3
|
+
/** A schema/plugin source varlock could not finish loading. */
|
|
4
|
+
interface GuardDataSource {
|
|
5
|
+
readonly isValid: boolean;
|
|
6
|
+
readonly loadingError?: {
|
|
7
|
+
readonly message?: string;
|
|
8
|
+
} | null;
|
|
9
|
+
readonly schemaErrors?: ReadonlyArray<{
|
|
10
|
+
readonly message?: string;
|
|
11
|
+
readonly isWarning?: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
readonly resolutionErrors?: ReadonlyArray<{
|
|
14
|
+
readonly message?: string;
|
|
15
|
+
}>;
|
|
16
|
+
readonly label?: string;
|
|
17
|
+
/** Present on file-based sources (.env.schema / .env.local). */
|
|
18
|
+
readonly fullPath?: string;
|
|
19
|
+
}
|
|
20
|
+
interface GuardPlugin {
|
|
21
|
+
readonly loadingError?: {
|
|
22
|
+
readonly message?: string;
|
|
23
|
+
} | null;
|
|
24
|
+
readonly localPath?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The structural subset of varlock's EnvGraph this module reads. varlock's
|
|
28
|
+
* concrete EnvGraph is assignable to it, so `hq run` passes its graph with no
|
|
29
|
+
* cast. Everything the guard reads is optional so a varlock shape change
|
|
30
|
+
* degrades to "no problems found" rather than a crash — the resolve-time layer
|
|
31
|
+
* and the varlock-shape smoke test (src/run/varlock-shape.test.ts) are what
|
|
32
|
+
* make such a degradation fail loudly.
|
|
33
|
+
*/
|
|
34
|
+
export interface LoadedEnvGraph {
|
|
35
|
+
readonly sortedDataSources?: ReadonlyArray<GuardDataSource>;
|
|
36
|
+
readonly plugins?: ReadonlyArray<GuardPlugin>;
|
|
37
|
+
resolveEnvValues(keys?: Array<string>): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A schema/entry file could not be loaded, so the env graph never finished
|
|
41
|
+
* processing. Carries an actionable, file-naming message; classified by
|
|
42
|
+
* handleTopLevelError as a user-file error (printed, not reported to Sentry).
|
|
43
|
+
*/
|
|
44
|
+
export declare class EnvGraphLoadError extends Error {
|
|
45
|
+
constructor(message: string);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolving the env graph floated a rejection (varlock's un-awaited resolver).
|
|
49
|
+
* Converted from the process-level unhandled rejection by resolveEnvValuesOrThrow
|
|
50
|
+
* so the promise `hq run` awaits can never hang. Classified as a user-file error.
|
|
51
|
+
*/
|
|
52
|
+
export declare class EnvResolutionError extends Error {
|
|
53
|
+
readonly reason: unknown;
|
|
54
|
+
constructor(reason: unknown);
|
|
55
|
+
}
|
|
56
|
+
/** True for either typed carrier this module raises. */
|
|
57
|
+
export declare function isVarlockEnvError(err: unknown): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Throw a typed, actionable EnvGraphLoadError when varlock did not finish
|
|
60
|
+
* loading the graph — i.e. when it would have bailed out of finishLoad() before
|
|
61
|
+
* processing items, leaving them unresolvable. Mirrors finishLoad's two
|
|
62
|
+
* early-return conditions exactly: any data source with `isValid === false`, or
|
|
63
|
+
* any plugin with a `loadingError`. A fully-valid graph passes silently.
|
|
64
|
+
*/
|
|
65
|
+
export declare function assertEnvGraphLoaded(graph: LoadedEnvGraph): void;
|
|
66
|
+
/**
|
|
67
|
+
* Await `graph.resolveEnvValues()`, but convert varlock's floated resolution
|
|
68
|
+
* rejection into a thrown EnvResolutionError instead of hanging on the promise
|
|
69
|
+
* that never settles.
|
|
70
|
+
*
|
|
71
|
+
* varlock@1.0.0's resolveEnvValues dispatches its per-item resolver un-awaited
|
|
72
|
+
* and with a dead `_reject`, so a throw inside it becomes a process-level
|
|
73
|
+
* unhandled rejection AND leaves the returned promise permanently pending. We
|
|
74
|
+
* race the resolve against a scoped `unhandledRejection` listener: whichever
|
|
75
|
+
* settles first wins. The listener is prepended (so the prior listeners still
|
|
76
|
+
* run — nothing is swallowed) and removed in `finally` (restoring the exact
|
|
77
|
+
* prior listener set, including the no-prior-listener case). If resolve settles
|
|
78
|
+
* normally the listener never fires; a stray unrelated rejection during the
|
|
79
|
+
* window is still delivered to the pre-existing listeners.
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveEnvValuesOrThrow(graph: LoadedEnvGraph): Promise<void>;
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=env-graph-guard.d.ts.map
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// src/run/env-graph-guard.ts
|
|
2
|
+
//
|
|
3
|
+
// Guards for `hq run`'s use of varlock's env graph. Two independent layers
|
|
4
|
+
// close the same class of failure that produced Sentry HQ-CLI-W
|
|
5
|
+
// ("expected dataType to be set", varlock ConfigItem.resolve):
|
|
6
|
+
//
|
|
7
|
+
// 1. assertEnvGraphLoaded(graph) — refuse to resolve a graph varlock never
|
|
8
|
+
// finished loading. When ANY discovered entry file (.env.schema /
|
|
9
|
+
// .env.local) fails to parse, varlock's EnvGraph.finishLoad() returns
|
|
10
|
+
// early — BEFORE it processes items — so every ConfigItem is left with
|
|
11
|
+
// `dataType === undefined` and NO error attached. Resolving such a graph
|
|
12
|
+
// reaches varlock's internal invariant `expected dataType to be set` for
|
|
13
|
+
// any item whose key also exists in process.env (varlock treats the whole
|
|
14
|
+
// process env as overrides), and — because varlock fires that resolution
|
|
15
|
+
// as an un-awaited, un-caught promise — the throw becomes a process-level
|
|
16
|
+
// unhandled rejection while the promise `hq run` awaits NEVER settles.
|
|
17
|
+
// finishLoad's two early-return conditions are: any data source with
|
|
18
|
+
// `isValid === false`, or any plugin with a `loadingError`. This guard
|
|
19
|
+
// mirrors exactly those conditions and throws a typed, actionable error
|
|
20
|
+
// first, so resolution is never reached with an unprocessed graph.
|
|
21
|
+
//
|
|
22
|
+
// 2. resolveEnvValuesOrThrow(graph) — defence in depth for the case the
|
|
23
|
+
// shape-dependent guard above cannot see (a future varlock could change
|
|
24
|
+
// the members it reads). It wraps `graph.resolveEnvValues()` in a
|
|
25
|
+
// narrowly-scoped `unhandledRejection` window: if varlock floats the
|
|
26
|
+
// resolution rejection, this converts it into a thrown EnvResolutionError
|
|
27
|
+
// instead of hanging forever on the never-settling promise. The window is
|
|
28
|
+
// restored to the exact prior listener set afterwards, and every rejection
|
|
29
|
+
// it observes is still forwarded to the pre-existing listeners, so it never
|
|
30
|
+
// swallows an unrelated rejection or permanently alters process semantics.
|
|
31
|
+
//
|
|
32
|
+
// Both failures are a user's malformed schema file, not an hq-cli defect, so
|
|
33
|
+
// main.ts's handleTopLevelError classifies these typed carriers as an
|
|
34
|
+
// actionable `hq: …` line WITHOUT a Sentry capture (see isVarlockEnvError).
|
|
35
|
+
import { claimRejection, releaseRejection, } from "../unhandled-rejection-boundary.js";
|
|
36
|
+
/** The varlock version hq-cli is pinned to (package.json `dependencies`). */
|
|
37
|
+
export const VARLOCK_PINNED_VERSION = "1.0.0";
|
|
38
|
+
/**
|
|
39
|
+
* A schema/entry file could not be loaded, so the env graph never finished
|
|
40
|
+
* processing. Carries an actionable, file-naming message; classified by
|
|
41
|
+
* handleTopLevelError as a user-file error (printed, not reported to Sentry).
|
|
42
|
+
*/
|
|
43
|
+
export class EnvGraphLoadError extends Error {
|
|
44
|
+
constructor(message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "EnvGraphLoadError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolving the env graph floated a rejection (varlock's un-awaited resolver).
|
|
51
|
+
* Converted from the process-level unhandled rejection by resolveEnvValuesOrThrow
|
|
52
|
+
* so the promise `hq run` awaits can never hang. Classified as a user-file error.
|
|
53
|
+
*/
|
|
54
|
+
export class EnvResolutionError extends Error {
|
|
55
|
+
reason;
|
|
56
|
+
constructor(reason) {
|
|
57
|
+
const underlying = reason instanceof Error ? reason.message : String(reason);
|
|
58
|
+
super(`could not resolve your env schema (varlock ${VARLOCK_PINNED_VERSION}): ${underlying}. ` +
|
|
59
|
+
`This usually means a .env.schema or .env.local file could not be parsed — ` +
|
|
60
|
+
`check them for syntax errors and try again.`);
|
|
61
|
+
this.name = "EnvResolutionError";
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** True for either typed carrier this module raises. */
|
|
66
|
+
export function isVarlockEnvError(err) {
|
|
67
|
+
return err instanceof EnvGraphLoadError || err instanceof EnvResolutionError;
|
|
68
|
+
}
|
|
69
|
+
/** Collect the human-readable reason(s) a source is invalid, mirroring varlock. */
|
|
70
|
+
function describeSourceFailure(source) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
const loadingMessage = source.loadingError?.message;
|
|
73
|
+
if (loadingMessage)
|
|
74
|
+
parts.push(loadingMessage);
|
|
75
|
+
for (const err of source.schemaErrors ?? []) {
|
|
76
|
+
if (!err.isWarning && err.message)
|
|
77
|
+
parts.push(err.message);
|
|
78
|
+
}
|
|
79
|
+
for (const err of source.resolutionErrors ?? []) {
|
|
80
|
+
if (err.message)
|
|
81
|
+
parts.push(err.message);
|
|
82
|
+
}
|
|
83
|
+
return parts.length > 0 ? parts.join("; ") : "failed to load";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Throw a typed, actionable EnvGraphLoadError when varlock did not finish
|
|
87
|
+
* loading the graph — i.e. when it would have bailed out of finishLoad() before
|
|
88
|
+
* processing items, leaving them unresolvable. Mirrors finishLoad's two
|
|
89
|
+
* early-return conditions exactly: any data source with `isValid === false`, or
|
|
90
|
+
* any plugin with a `loadingError`. A fully-valid graph passes silently.
|
|
91
|
+
*/
|
|
92
|
+
export function assertEnvGraphLoaded(graph) {
|
|
93
|
+
const problems = [];
|
|
94
|
+
for (const source of graph.sortedDataSources ?? []) {
|
|
95
|
+
if (source.isValid === false) {
|
|
96
|
+
const where = source.fullPath ?? source.label ?? "unknown source";
|
|
97
|
+
problems.push(`${where}: ${describeSourceFailure(source)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const plugin of graph.plugins ?? []) {
|
|
101
|
+
if (plugin.loadingError) {
|
|
102
|
+
const where = plugin.localPath ?? "plugin";
|
|
103
|
+
problems.push(`${where}: ${plugin.loadingError.message ?? String(plugin.loadingError)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (problems.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
throw new EnvGraphLoadError(`hq run could not load your env schema (varlock ${VARLOCK_PINNED_VERSION}). ` +
|
|
109
|
+
`Fix the following and re-run:\n` +
|
|
110
|
+
problems.map((p) => ` - ${p}`).join("\n"));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Await `graph.resolveEnvValues()`, but convert varlock's floated resolution
|
|
114
|
+
* rejection into a thrown EnvResolutionError instead of hanging on the promise
|
|
115
|
+
* that never settles.
|
|
116
|
+
*
|
|
117
|
+
* varlock@1.0.0's resolveEnvValues dispatches its per-item resolver un-awaited
|
|
118
|
+
* and with a dead `_reject`, so a throw inside it becomes a process-level
|
|
119
|
+
* unhandled rejection AND leaves the returned promise permanently pending. We
|
|
120
|
+
* race the resolve against a scoped `unhandledRejection` listener: whichever
|
|
121
|
+
* settles first wins. The listener is prepended (so the prior listeners still
|
|
122
|
+
* run — nothing is swallowed) and removed in `finally` (restoring the exact
|
|
123
|
+
* prior listener set, including the no-prior-listener case). If resolve settles
|
|
124
|
+
* normally the listener never fires; a stray unrelated rejection during the
|
|
125
|
+
* window is still delivered to the pre-existing listeners.
|
|
126
|
+
*/
|
|
127
|
+
export async function resolveEnvValuesOrThrow(graph) {
|
|
128
|
+
let claimedReason;
|
|
129
|
+
let didClaim = false;
|
|
130
|
+
const onRejection = (reason) => {
|
|
131
|
+
// Claim this rejection so the process-wide boundary (index.ts) defers to us
|
|
132
|
+
// and does not ALSO report it — one failure, one diagnostic. The
|
|
133
|
+
// pre-existing listeners still fire (this is a prependOnceListener), so the
|
|
134
|
+
// rejection is never swallowed from the process's normal handling.
|
|
135
|
+
claimedReason = reason;
|
|
136
|
+
didClaim = true;
|
|
137
|
+
claimRejection(reason);
|
|
138
|
+
rejectRace(new EnvResolutionError(reason));
|
|
139
|
+
};
|
|
140
|
+
let rejectRace = () => { };
|
|
141
|
+
const rejection = new Promise((_resolve, reject) => {
|
|
142
|
+
rejectRace = reject;
|
|
143
|
+
});
|
|
144
|
+
process.prependOnceListener("unhandledRejection", onRejection);
|
|
145
|
+
try {
|
|
146
|
+
await Promise.race([graph.resolveEnvValues(), rejection]);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
// No-op if the once-listener already fired; restores the prior set exactly.
|
|
150
|
+
process.removeListener("unhandledRejection", onRejection);
|
|
151
|
+
if (didClaim)
|
|
152
|
+
releaseRejection(claimedReason);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=env-graph-guard.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare function hadFatalRejection(): boolean;
|
|
2
|
+
export declare function claimRejection(reason: unknown): void;
|
|
3
|
+
export declare function releaseRejection(reason: unknown): void;
|
|
4
|
+
/** Reset the process-level rejection state. Test-only. */
|
|
5
|
+
export declare function __resetRejectionStateForTests(): void;
|
|
6
|
+
export interface RejectionBoundaryDependencies {
|
|
7
|
+
/** Reads the current process exit code (undefined when still unset). */
|
|
8
|
+
readonly getExitCode: () => number | undefined;
|
|
9
|
+
readonly setExitCode: (code: number) => void;
|
|
10
|
+
/** Best-effort actionable report; may be async and may not finish before exit. */
|
|
11
|
+
readonly report: (reason: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Handle one floated rejection. Synchronously fixes the exit code — a broken
|
|
15
|
+
* pipe (EPIPE) is a clean close (0), anything else is a failure (1) — WITHOUT
|
|
16
|
+
* downgrading a non-zero code a command path already set, then kicks off a
|
|
17
|
+
* best-effort actionable report. Never calls process.exit, so the command's
|
|
18
|
+
* finally block (release-health session end + bounded Sentry flush) still runs.
|
|
19
|
+
*/
|
|
20
|
+
export declare function handleFatalRejection(reason: unknown, deps: RejectionBoundaryDependencies): void;
|
|
21
|
+
/**
|
|
22
|
+
* Register the boundary on `process`, once, before index.ts dispatches into the
|
|
23
|
+
* command graph. Idempotent so repeated imports/installs add a single listener.
|
|
24
|
+
* The actionable report routes through main.ts's existing handleTopLevelError
|
|
25
|
+
* with a NO-OP capture: @sentry/node's integration has already captured the
|
|
26
|
+
* rejection, so this only adds the classified `hq:` line (and its EPIPE / typed
|
|
27
|
+
* carve-outs) without a second Sentry event.
|
|
28
|
+
*/
|
|
29
|
+
export declare function installProcessRejectionBoundary(): void;
|
|
30
|
+
//# sourceMappingURL=unhandled-rejection-boundary.d.ts.map
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/unhandled-rejection-boundary.ts
|
|
2
|
+
//
|
|
3
|
+
// A process-level unhandledRejection boundary for the CLI entrypoint.
|
|
4
|
+
//
|
|
5
|
+
// @sentry/node's onUnhandledRejection integration defaults to mode 'warn': it
|
|
6
|
+
// CAPTURES a floated rejection but does NOT rethrow it. hq-cli installs no
|
|
7
|
+
// rejection handler of its own, so without this boundary ANY floated rejection
|
|
8
|
+
// (the class that produced Sentry HQ-CLI-W) leaves process.exitCode unset, the
|
|
9
|
+
// event loop drains, and Node exits 0 — a silent success in which the command
|
|
10
|
+
// never ran. index.ts's own comment ("Rejections still become unhandled and
|
|
11
|
+
// preserve a genuine non-zero failure") described the pre-Sentry world and is
|
|
12
|
+
// no longer true; this boundary restores it.
|
|
13
|
+
//
|
|
14
|
+
// The logic is factored out of index.ts so it is unit-testable: importing
|
|
15
|
+
// index.ts for a test would trigger the real command dispatch.
|
|
16
|
+
import { isEpipe } from "./utils/epipe.js";
|
|
17
|
+
// Sticky once a non-EPIPE floated rejection has been handled. `process.exitCode`
|
|
18
|
+
// alone is not enough: a command path that later calls `process.exit(0)` — e.g.
|
|
19
|
+
// `hq run`'s child-`close` handler after a child that exited 0 — would erase the
|
|
20
|
+
// failure. Exit paths that propagate their own status consult this to force a
|
|
21
|
+
// non-zero code instead (see src/commands/run.ts).
|
|
22
|
+
let fatalRejectionSeen = false;
|
|
23
|
+
export function hadFatalRejection() {
|
|
24
|
+
return fatalRejectionSeen;
|
|
25
|
+
}
|
|
26
|
+
// Rejections a scoped handler (e.g. resolveEnvValuesOrThrow) has already claimed
|
|
27
|
+
// and will surface through the normal command path. The process-wide boundary
|
|
28
|
+
// defers to the claimer so one failure yields exactly one diagnostic rather than
|
|
29
|
+
// both a scoped throw AND a boundary report of the same rejection.
|
|
30
|
+
const claimedRejections = new Set();
|
|
31
|
+
export function claimRejection(reason) {
|
|
32
|
+
claimedRejections.add(reason);
|
|
33
|
+
}
|
|
34
|
+
export function releaseRejection(reason) {
|
|
35
|
+
claimedRejections.delete(reason);
|
|
36
|
+
}
|
|
37
|
+
/** Reset the process-level rejection state. Test-only. */
|
|
38
|
+
export function __resetRejectionStateForTests() {
|
|
39
|
+
fatalRejectionSeen = false;
|
|
40
|
+
claimedRejections.clear();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Handle one floated rejection. Synchronously fixes the exit code — a broken
|
|
44
|
+
* pipe (EPIPE) is a clean close (0), anything else is a failure (1) — WITHOUT
|
|
45
|
+
* downgrading a non-zero code a command path already set, then kicks off a
|
|
46
|
+
* best-effort actionable report. Never calls process.exit, so the command's
|
|
47
|
+
* finally block (release-health session end + bounded Sentry flush) still runs.
|
|
48
|
+
*/
|
|
49
|
+
export function handleFatalRejection(reason, deps) {
|
|
50
|
+
// A scoped handler already owns this exact rejection and will surface it
|
|
51
|
+
// through the normal command path; do not exit-code or report it twice.
|
|
52
|
+
if (claimedRejections.has(reason))
|
|
53
|
+
return;
|
|
54
|
+
if (isEpipe(reason)) {
|
|
55
|
+
// A clean downstream close. Only assert 0 when nothing has already failed.
|
|
56
|
+
if (deps.getExitCode() === undefined)
|
|
57
|
+
deps.setExitCode(0);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// A genuine fatal rejection: mark it sticky, and mark the run failed unless
|
|
61
|
+
// a command already set a non-zero code (e.g. a child's exit status).
|
|
62
|
+
fatalRejectionSeen = true;
|
|
63
|
+
if (!deps.getExitCode())
|
|
64
|
+
deps.setExitCode(1);
|
|
65
|
+
}
|
|
66
|
+
deps.report(reason);
|
|
67
|
+
}
|
|
68
|
+
let installed = false;
|
|
69
|
+
/**
|
|
70
|
+
* Register the boundary on `process`, once, before index.ts dispatches into the
|
|
71
|
+
* command graph. Idempotent so repeated imports/installs add a single listener.
|
|
72
|
+
* The actionable report routes through main.ts's existing handleTopLevelError
|
|
73
|
+
* with a NO-OP capture: @sentry/node's integration has already captured the
|
|
74
|
+
* rejection, so this only adds the classified `hq:` line (and its EPIPE / typed
|
|
75
|
+
* carve-outs) without a second Sentry event.
|
|
76
|
+
*/
|
|
77
|
+
export function installProcessRejectionBoundary() {
|
|
78
|
+
if (installed)
|
|
79
|
+
return;
|
|
80
|
+
installed = true;
|
|
81
|
+
process.on("unhandledRejection", (reason) => {
|
|
82
|
+
handleFatalRejection(reason, {
|
|
83
|
+
getExitCode: () => process.exitCode,
|
|
84
|
+
setExitCode: (code) => {
|
|
85
|
+
process.exitCode = code;
|
|
86
|
+
},
|
|
87
|
+
report: (r) => {
|
|
88
|
+
void import("./main.js")
|
|
89
|
+
.then(({ handleTopLevelError }) => handleTopLevelError(r, {
|
|
90
|
+
sentry: { captureException: () => undefined },
|
|
91
|
+
stderr: process.stderr,
|
|
92
|
+
// Print-only. handleFatalRejection already set the authoritative,
|
|
93
|
+
// downgrade-guarded exit code synchronously; letting the async
|
|
94
|
+
// report re-set it would let handleTopLevelError's EPIPE branch
|
|
95
|
+
// (setExitCode(0)) reset an earlier failure to success.
|
|
96
|
+
setExitCode: () => { },
|
|
97
|
+
}))
|
|
98
|
+
.catch(() => {
|
|
99
|
+
// main.js could not be loaded (a rejection on a path that never
|
|
100
|
+
// imported it). The synchronous exit code above still stands.
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=unhandled-rejection-boundary.js.map
|
|
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
|
|
|
60
60
|
import semver from "semver";
|
|
61
61
|
import chalk from "chalk";
|
|
62
62
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
63
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
63
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
/**
|
|
66
66
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
@@ -222,11 +222,20 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
222
222
|
if (!interactive && flavor.onlyWhenAttended) {
|
|
223
223
|
return { action: "deferred", latest };
|
|
224
224
|
}
|
|
225
|
+
// A local copy (a project dependency, a `pnpm dlx` cache) has no self-update
|
|
226
|
+
// path: every install argv here targets a GLOBAL install, so the copy that is
|
|
227
|
+
// actually running would stay exactly as stale as it started while the CLI
|
|
228
|
+
// reported success and re-exec'd into the same build. That is the shape of
|
|
229
|
+
// the 2026-09-02 gate loop — see isLocalDependencyInstall in version-gate.ts.
|
|
230
|
+
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
231
|
+
if (isLocalDependencyInstall(install)) {
|
|
232
|
+
console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
|
|
233
|
+
return { action: "skipped", latest };
|
|
234
|
+
}
|
|
225
235
|
const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
|
|
226
236
|
if (!releaseLock)
|
|
227
237
|
return { action: "skipped", latest };
|
|
228
238
|
let result;
|
|
229
|
-
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
230
239
|
const plan = buildSelfUpdatePlan(install);
|
|
231
240
|
// A pnpm global install needs PNPM_HOME to find its global bin dir. A
|
|
232
241
|
// minimal-environment parent (systemd, cron, non-login shell) lacks it and
|
|
@@ -70,6 +70,59 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
|
70
70
|
export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
|
|
71
71
|
/** Whether the running package lives inside Bun's global install tree. */
|
|
72
72
|
export declare function isBunManagedPackageDir(pkgDir: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Whether the package dir sits inside a pnpm **virtual store** — the adjacent
|
|
75
|
+
* `node_modules/.pnpm` segment pair pnpm creates for every install, global or
|
|
76
|
+
* not:
|
|
77
|
+
*
|
|
78
|
+
* <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
|
|
79
|
+
*
|
|
80
|
+
* A virtual store is a content-addressed cache keyed by the EXACT version in
|
|
81
|
+
* the directory name. Nothing on PATH ever resolves through
|
|
82
|
+
* `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
|
|
83
|
+
* one thing that must never happen is treating it as one.
|
|
84
|
+
*
|
|
85
|
+
* Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
|
|
86
|
+
* answers a different question ("is this the copy `pnpm add -g` updates?") and
|
|
87
|
+
* is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
|
|
88
|
+
* decides anything, a pnpm *global* store has already been classified.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isPnpmVirtualStorePackageDir(pkgDir: string): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Whether the running CLI is a **local** copy — a project dependency or a
|
|
93
|
+
* `pnpm dlx` cache — rather than a global install this process may replace.
|
|
94
|
+
*
|
|
95
|
+
* This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
|
|
96
|
+
* `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
|
|
97
|
+
* sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
|
|
98
|
+
* it. The gate saw a build below `minVersion`, derived an npm prefix from the
|
|
99
|
+
* store path, ran
|
|
100
|
+
*
|
|
101
|
+
* npm install -g --prefix <store> @indigoai-us/hq-cli@latest
|
|
102
|
+
*
|
|
103
|
+
* which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
|
|
104
|
+
* pnpm's shim never reads — then reported success. The next invocation resolved
|
|
105
|
+
* the same 5.69.0 shim and did it all again, ~25 times an hour forever.
|
|
106
|
+
*
|
|
107
|
+
* A local copy has no self-update path at all: replacing it is its owning
|
|
108
|
+
* project's job, and installing globally would leave the copy that is actually
|
|
109
|
+
* running untouched. So the gate must say so and stop, not install.
|
|
110
|
+
*/
|
|
111
|
+
export declare function isLocalDependencyPackageDir(pkgDir: string, platform?: NodeJS.Platform): boolean;
|
|
112
|
+
export declare function isLocalDependencyInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Whether an update to `target` would actually move the install forward.
|
|
115
|
+
*
|
|
116
|
+
* Loop protection, independent of the layout bug above: an "update" to a
|
|
117
|
+
* version that is not strictly newer than what is running can never converge,
|
|
118
|
+
* so however the target was resolved — a stale dist-tag, a bad cache, a
|
|
119
|
+
* misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
|
|
120
|
+
* every invocation and announce success each time.
|
|
121
|
+
*
|
|
122
|
+
* Unparseable versions return true: this guard exists to stop a provable
|
|
123
|
+
* no-op, not to become a new way for the gate to refuse to work.
|
|
124
|
+
*/
|
|
125
|
+
export declare function isNewerVersion(target: string, current: string): boolean;
|
|
73
126
|
/**
|
|
74
127
|
* Where the running CLI is installed and who owns it. Resolved in ONE pass so
|
|
75
128
|
* the package-root walk (which reads and parses a `package.json` per directory
|
|
@@ -278,7 +331,7 @@ export declare function probeCliVersion(bin: string): string | null;
|
|
|
278
331
|
export declare function checkUpdateConvergence(targetVersion: string, deps?: {
|
|
279
332
|
resolveBin?: () => string | null;
|
|
280
333
|
probeVersion?: (bin: string) => string | null;
|
|
281
|
-
}):
|
|
334
|
+
}): boolean;
|
|
282
335
|
/** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
|
|
283
336
|
interface EnforceUpdateDeps {
|
|
284
337
|
performUpdateString?: (command: string) => UpdateResult;
|
|
@@ -286,7 +339,12 @@ interface EnforceUpdateDeps {
|
|
|
286
339
|
runner?: UpdateRunner;
|
|
287
340
|
cleanStale?: (prefix: string) => string[];
|
|
288
341
|
acquireLock?: () => UpdateLockHandle | null;
|
|
289
|
-
|
|
342
|
+
/**
|
|
343
|
+
* `false` means the probe *disproved* convergence. `void`/`undefined` keeps
|
|
344
|
+
* the historical "unverified is fine" behaviour, so an injected stub that
|
|
345
|
+
* returns nothing still exercises the success path.
|
|
346
|
+
*/
|
|
347
|
+
checkConvergence?: (targetVersion: string) => boolean | void;
|
|
290
348
|
}
|
|
291
349
|
/**
|
|
292
350
|
* Hard enforcement when the server says we're below `minVersion`. Print a
|
|
@@ -346,7 +404,11 @@ export declare const __test__: {
|
|
|
346
404
|
enforceUpdateRequired: typeof enforceUpdateRequired;
|
|
347
405
|
isBunManagedPackageDir: typeof isBunManagedPackageDir;
|
|
348
406
|
pnpmUpdateEnv: typeof pnpmUpdateEnv;
|
|
407
|
+
isLocalDependencyInstall: typeof isLocalDependencyInstall;
|
|
408
|
+
isLocalDependencyPackageDir: typeof isLocalDependencyPackageDir;
|
|
409
|
+
isNewerVersion: typeof isNewerVersion;
|
|
349
410
|
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
411
|
+
isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
|
|
350
412
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
351
413
|
nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
|
|
352
414
|
performUpdate: typeof performUpdate;
|
|
@@ -34,6 +34,7 @@ import os from "node:os";
|
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
36
|
import chalk from "chalk";
|
|
37
|
+
import semver from "semver";
|
|
37
38
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
38
39
|
import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
|
|
39
40
|
import { acquireUpdateLock, } from "./update-lock.js";
|
|
@@ -140,6 +141,106 @@ export function isBunManagedPackageDir(pkgDir) {
|
|
|
140
141
|
}
|
|
141
142
|
return false;
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether the package dir sits inside a pnpm **virtual store** — the adjacent
|
|
146
|
+
* `node_modules/.pnpm` segment pair pnpm creates for every install, global or
|
|
147
|
+
* not:
|
|
148
|
+
*
|
|
149
|
+
* <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
|
|
150
|
+
*
|
|
151
|
+
* A virtual store is a content-addressed cache keyed by the EXACT version in
|
|
152
|
+
* the directory name. Nothing on PATH ever resolves through
|
|
153
|
+
* `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
|
|
154
|
+
* one thing that must never happen is treating it as one.
|
|
155
|
+
*
|
|
156
|
+
* Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
|
|
157
|
+
* answers a different question ("is this the copy `pnpm add -g` updates?") and
|
|
158
|
+
* is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
|
|
159
|
+
* decides anything, a pnpm *global* store has already been classified.
|
|
160
|
+
*/
|
|
161
|
+
export function isPnpmVirtualStorePackageDir(pkgDir) {
|
|
162
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
163
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
164
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
165
|
+
if (segments[i] === "node_modules" && segments[i + 1] === ".pnpm")
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether the running CLI is a **local** copy — a project dependency or a
|
|
172
|
+
* `pnpm dlx` cache — rather than a global install this process may replace.
|
|
173
|
+
*
|
|
174
|
+
* This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
|
|
175
|
+
* `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
|
|
176
|
+
* sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
|
|
177
|
+
* it. The gate saw a build below `minVersion`, derived an npm prefix from the
|
|
178
|
+
* store path, ran
|
|
179
|
+
*
|
|
180
|
+
* npm install -g --prefix <store> @indigoai-us/hq-cli@latest
|
|
181
|
+
*
|
|
182
|
+
* which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
|
|
183
|
+
* pnpm's shim never reads — then reported success. The next invocation resolved
|
|
184
|
+
* the same 5.69.0 shim and did it all again, ~25 times an hour forever.
|
|
185
|
+
*
|
|
186
|
+
* A local copy has no self-update path at all: replacing it is its owning
|
|
187
|
+
* project's job, and installing globally would leave the copy that is actually
|
|
188
|
+
* running untouched. So the gate must say so and stop, not install.
|
|
189
|
+
*/
|
|
190
|
+
export function isLocalDependencyPackageDir(pkgDir, platform = process.platform) {
|
|
191
|
+
// A pnpm virtual store is local by construction on every platform. (A pnpm
|
|
192
|
+
// *global* store is classified earlier, by isPnpmManagedPackageDir.)
|
|
193
|
+
if (isPnpmVirtualStorePackageDir(pkgDir))
|
|
194
|
+
return true;
|
|
195
|
+
// On every non-Windows platform npm's global root is ALWAYS
|
|
196
|
+
// `<prefix>/lib/node_modules` — that is what `npm root -g` reports for
|
|
197
|
+
// /usr/local, Homebrew, nvm and a user-level `--prefix` alike. So a package
|
|
198
|
+
// under a `node_modules` with no `lib` parent is not a global install: it is
|
|
199
|
+
// a project dependency (`<proj>/node_modules/@scope/pkg`) or an npx cache
|
|
200
|
+
// (`~/.npm/_npx/<hash>/node_modules/@scope/pkg`). Both reproduce the same
|
|
201
|
+
// loop as the pnpm store — `npm install -g --prefix <dir>` writes
|
|
202
|
+
// `<dir>/lib/node_modules` while the `.bin` shim keeps resolving
|
|
203
|
+
// `<dir>/node_modules`.
|
|
204
|
+
//
|
|
205
|
+
// Windows is the exception and must keep the old behaviour: its global
|
|
206
|
+
// layout is `<prefix>\node_modules` with no `lib` segment, so the same test
|
|
207
|
+
// would misread a genuine global install as local.
|
|
208
|
+
if (platform === "win32")
|
|
209
|
+
return false;
|
|
210
|
+
const segments = pkgDir
|
|
211
|
+
.replace(/\\/g, "/")
|
|
212
|
+
.replace(/\/+$/, "")
|
|
213
|
+
.split("/")
|
|
214
|
+
.filter(Boolean);
|
|
215
|
+
const nodeModulesIndex = segments.lastIndexOf("node_modules");
|
|
216
|
+
if (nodeModulesIndex <= 0)
|
|
217
|
+
return false;
|
|
218
|
+
return segments[nodeModulesIndex - 1] !== "lib";
|
|
219
|
+
}
|
|
220
|
+
export function isLocalDependencyInstall(install, platform = process.platform) {
|
|
221
|
+
return (install.manager === "npm" &&
|
|
222
|
+
install.packageRoot !== null &&
|
|
223
|
+
isLocalDependencyPackageDir(install.packageRoot, platform));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Whether an update to `target` would actually move the install forward.
|
|
227
|
+
*
|
|
228
|
+
* Loop protection, independent of the layout bug above: an "update" to a
|
|
229
|
+
* version that is not strictly newer than what is running can never converge,
|
|
230
|
+
* so however the target was resolved — a stale dist-tag, a bad cache, a
|
|
231
|
+
* misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
|
|
232
|
+
* every invocation and announce success each time.
|
|
233
|
+
*
|
|
234
|
+
* Unparseable versions return true: this guard exists to stop a provable
|
|
235
|
+
* no-op, not to become a new way for the gate to refuse to work.
|
|
236
|
+
*/
|
|
237
|
+
export function isNewerVersion(target, current) {
|
|
238
|
+
const t = semver.valid(target);
|
|
239
|
+
const c = semver.valid(current);
|
|
240
|
+
if (!t || !c)
|
|
241
|
+
return true;
|
|
242
|
+
return semver.gt(t, c);
|
|
243
|
+
}
|
|
143
244
|
export function resolveRunningInstall() {
|
|
144
245
|
try {
|
|
145
246
|
const packageRoot = findRunningPackageRoot();
|
|
@@ -151,6 +252,13 @@ export function resolveRunningInstall() {
|
|
|
151
252
|
if (isBunManagedPackageDir(packageRoot)) {
|
|
152
253
|
return { manager: "bun", prefix: null, packageRoot };
|
|
153
254
|
}
|
|
255
|
+
// A local copy — project dependency, pnpm virtual store, or npx cache.
|
|
256
|
+
// `npmPrefixFromPackageDir` would hand back the project/cache directory and
|
|
257
|
+
// `npm install -g --prefix <dir>` then writes where nothing loads from —
|
|
258
|
+
// see isLocalDependencyPackageDir. There is no npm prefix to report here.
|
|
259
|
+
if (isLocalDependencyPackageDir(packageRoot)) {
|
|
260
|
+
return { manager: "npm", prefix: null, packageRoot };
|
|
261
|
+
}
|
|
154
262
|
return {
|
|
155
263
|
manager: "npm",
|
|
156
264
|
prefix: npmPrefixFromPackageDir(packageRoot),
|
|
@@ -506,6 +614,14 @@ function manualUpdateCommand(install, decision) {
|
|
|
506
614
|
function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
|
|
507
615
|
const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
|
|
508
616
|
console.error(msg);
|
|
617
|
+
// A local copy is not updated by any global command. Printing the server's
|
|
618
|
+
// npm-shaped one would send the user to install a second copy that this
|
|
619
|
+
// invocation still would not use — the same wrong advice the pnpm-global
|
|
620
|
+
// carve-out exists to avoid.
|
|
621
|
+
if (isLocalDependencyInstall(install)) {
|
|
622
|
+
console.error(chalk.dim(` This copy is a local dependency (${install.packageRoot}); update the project that owns it, or invoke the global \`hq\`.`));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
509
625
|
const command = install.manager === "pnpm"
|
|
510
626
|
? `pnpm ${buildPnpmInstallArgv().join(" ")}`
|
|
511
627
|
: install.manager === "bun"
|
|
@@ -584,21 +700,41 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
|
|
|
584
700
|
const bin = (deps.resolveBin ?? resolveHqOnPath)();
|
|
585
701
|
if (!bin) {
|
|
586
702
|
console.error(chalk.yellow("⚠ Updated, but couldn't resolve `hq` on PATH to verify the new version took effect."));
|
|
587
|
-
|
|
703
|
+
// Unverifiable, not disproven — the caller keeps its success report.
|
|
704
|
+
return true;
|
|
588
705
|
}
|
|
589
706
|
const reported = (deps.probeVersion ?? probeCliVersion)(bin);
|
|
590
707
|
if (!reported) {
|
|
591
708
|
console.error(chalk.yellow(`⚠ Updated, but \`${bin} --version\` did not respond — couldn't verify the new version took effect.`));
|
|
592
|
-
return;
|
|
709
|
+
return true; // unverifiable, see above
|
|
593
710
|
}
|
|
594
711
|
if (reported === targetVersion)
|
|
595
|
-
return; // converged — the normal case
|
|
712
|
+
return true; // converged — the normal case
|
|
596
713
|
console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
|
|
597
714
|
`a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
|
|
598
715
|
"or the updater will loop forever."));
|
|
716
|
+
// The warning above fires for ANY mismatch — something other than the copy
|
|
717
|
+
// we just wrote is winning PATH resolution, which is worth saying either
|
|
718
|
+
// way. But only a STALE result disproves convergence.
|
|
719
|
+
//
|
|
720
|
+
// The install runs `@latest`, not `@<targetVersion>`, so it can legitimately
|
|
721
|
+
// land a version NEWER than the `latestVersion` the gate was handed: the npm
|
|
722
|
+
// dist-tag moves between the version-check response and the install, or the
|
|
723
|
+
// service's value was briefly behind. That is a successful upgrade. Failing
|
|
724
|
+
// it would exit 75 and reinstall on the next invocation — the very loop this
|
|
725
|
+
// guard exists to stop.
|
|
726
|
+
const reportedSemver = semver.valid(reported);
|
|
727
|
+
const targetSemver = semver.valid(targetVersion);
|
|
728
|
+
if (reportedSemver &&
|
|
729
|
+
targetSemver &&
|
|
730
|
+
semver.gte(reportedSemver, targetSemver)) {
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
return false;
|
|
599
734
|
}
|
|
600
735
|
catch {
|
|
601
736
|
// Verification is best-effort; never break the CLI over a probe.
|
|
737
|
+
return true;
|
|
602
738
|
}
|
|
603
739
|
}
|
|
604
740
|
/**
|
|
@@ -629,6 +765,26 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
629
765
|
// `resolveRunningInstall`
|
|
630
766
|
// already reports `prefix: null` there and neither is consulted below.
|
|
631
767
|
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
768
|
+
// Loop protection #1 — nothing here can update a copy this process does not
|
|
769
|
+
// own. Installing globally would leave the local copy that is actually
|
|
770
|
+
// running stale, so the gate would fire again on the very next invocation.
|
|
771
|
+
// Say what to fix and stop; do NOT spend an install.
|
|
772
|
+
if (isLocalDependencyInstall(install)) {
|
|
773
|
+
console.error(chalk.red(` This copy is a local dependency, not a global install: ${install.packageRoot}`));
|
|
774
|
+
console.error(chalk.dim(" Self-update cannot replace it — an install would land where this copy is never loaded from, and the gate would fire again on the next run."));
|
|
775
|
+
console.error(chalk.dim(` Update the project that owns it (e.g. \`pnpm update ${CLI_NAME}\` in its root, or remove the stale dependency), or invoke the global \`hq\` instead.`));
|
|
776
|
+
process.exit(75);
|
|
777
|
+
}
|
|
778
|
+
// Loop protection #2 — an "update" to a version that is not strictly newer
|
|
779
|
+
// than what is running can never converge. Whatever produced the target (a
|
|
780
|
+
// stale dist-tag, a bad cache, a misconfigured pin), reinstalling it on every
|
|
781
|
+
// invocation and announcing success each time is strictly worse than saying
|
|
782
|
+
// so once.
|
|
783
|
+
if (!isNewerVersion(decision.latestVersion, decision.currentVersion)) {
|
|
784
|
+
console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the installed ${decision.currentVersion}.`));
|
|
785
|
+
console.error(chalk.dim(" This is a server-side or registry problem, not a local one — reinstalling would loop without ever converging."));
|
|
786
|
+
process.exit(75);
|
|
787
|
+
}
|
|
632
788
|
const isManagedOutsideNpm = install.manager !== "npm";
|
|
633
789
|
const prefix = install.prefix;
|
|
634
790
|
if (!isManagedOutsideNpm && !command && !prefix) {
|
|
@@ -768,11 +924,17 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
768
924
|
}
|
|
769
925
|
return 75;
|
|
770
926
|
}
|
|
771
|
-
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
772
927
|
// Read-your-writes: a "successful" install into the npm prefix does not
|
|
773
|
-
// prove the user's PATH resolves it.
|
|
774
|
-
// install
|
|
775
|
-
|
|
928
|
+
// prove the user's PATH resolves it. Verify BEFORE announcing, so a ghost
|
|
929
|
+
// install shadowing the copy we just wrote is reported as the failed update
|
|
930
|
+
// it is rather than as a success the caller will keep retrying — see
|
|
931
|
+
// checkUpdateConvergence.
|
|
932
|
+
const converged = (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
|
|
933
|
+
if (converged === false) {
|
|
934
|
+
console.error(chalk.red(`✗ Update did not take effect: \`hq\` on PATH still resolves a different build than ${decision.latestVersion}.`));
|
|
935
|
+
return 75;
|
|
936
|
+
}
|
|
937
|
+
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
776
938
|
return 0;
|
|
777
939
|
}
|
|
778
940
|
export async function enforceVersionGate(onUpdateRecommended) {
|
|
@@ -829,7 +991,11 @@ export const __test__ = {
|
|
|
829
991
|
enforceUpdateRequired,
|
|
830
992
|
isBunManagedPackageDir,
|
|
831
993
|
pnpmUpdateEnv,
|
|
994
|
+
isLocalDependencyInstall,
|
|
995
|
+
isLocalDependencyPackageDir,
|
|
996
|
+
isNewerVersion,
|
|
832
997
|
isPnpmManagedPackageDir,
|
|
998
|
+
isPnpmVirtualStorePackageDir,
|
|
833
999
|
npmPrefixFromPackageDir,
|
|
834
1000
|
nudgeUpdateRecommended,
|
|
835
1001
|
performUpdate,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.106.
|
|
3
|
+
"version": "5.106.3",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
32
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
33
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
33
|
+
"@indigoai-us/hq-cloud": "~6.16.6",
|
|
34
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
35
|
"@sentry/node": "^10.49.0",
|
|
36
36
|
"@tobilu/qmd": "2.5.3",
|