@indigoai-us/hq-cli 5.77.5 → 5.77.7
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 +14 -0
- package/dist/commands/integrations.js +24 -1
- package/dist/commands/reindex.d.ts +5 -23
- package/dist/commands/reindex.js +206 -1
- package/dist/utils/version-gate.d.ts +36 -0
- package/dist/utils/version-gate.js +102 -1
- package/package.json +2 -2
- package/pnpm-workspace.yaml +1 -1
- package/src/commands/integrations.test.ts +118 -0
- package/src/commands/integrations.ts +26 -0
- package/src/commands/reindex.test.ts +168 -3
- package/src/commands/reindex.ts +207 -1
- package/src/utils/version-gate.test.ts +176 -0
- package/src/utils/version-gate.ts +127 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.77.7]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Updated `@indigoai-us/hq-cloud` to 6.14.19 so watched local deletes and
|
|
10
|
+
renames propagate during `hq sync`.
|
|
11
|
+
|
|
12
|
+
## [5.77.6]
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Updated `@indigoai-us/hq-cloud` to 6.14.18, which includes the DEV-1974
|
|
17
|
+
symlink-loop convergence fix.
|
|
18
|
+
|
|
5
19
|
## [5.77.5]
|
|
6
20
|
|
|
7
21
|
### Added
|
|
@@ -53,6 +53,29 @@ export class IntegrationsCliError extends Error {
|
|
|
53
53
|
function isClientError(status) {
|
|
54
54
|
return status >= 400 && status < 500;
|
|
55
55
|
}
|
|
56
|
+
// The integration gateway answers `POST /v1/integrations/mcp` JSON-RPC-style: a
|
|
57
|
+
// transport failure is a non-2xx HTTP status, but a GOVERNED refusal arrives as
|
|
58
|
+
// HTTP 200 carrying a JSON-RPC `error` object (mirrors hq-pro's
|
|
59
|
+
// integration-mcp/server.ts error mapping). These caller-side codes are the
|
|
60
|
+
// JSON-RPC analog of a client 4xx — the caller's request/state/permission,
|
|
61
|
+
// expected and actionable, not an hq-cli defect — so they are printed to the
|
|
62
|
+
// user and skipped for Sentry capture (HQ-CLI-B):
|
|
63
|
+
// -32003 UNAUTHORIZED — connection-level access denial, e.g. the
|
|
64
|
+
// "You do not have access to this integration. Ask its
|
|
65
|
+
// owner to share it with you." (IntegrationAccessDenied)
|
|
66
|
+
// that flooded Sentry, plus ConnectionNotFound / a
|
|
67
|
+
// read-only share rejecting a write.
|
|
68
|
+
// -32602 INVALID_PARAMS — an unknown tool or unsupported provider for the
|
|
69
|
+
// connection (a bad request the caller can correct).
|
|
70
|
+
// Everything else stays unexpected so a genuine fault still reaches Sentry:
|
|
71
|
+
// PROVIDER_ERROR (-32050, an upstream provider fault), INTERNAL_ERROR (-32603),
|
|
72
|
+
// CONFLICT (-32009, which the gateway also raises for a confirm queue being
|
|
73
|
+
// unavailable or an owner notification failing — real backend faults worth a
|
|
74
|
+
// report), METHOD_NOT_FOUND / PARSE_ERROR, and any absent or unrecognized code.
|
|
75
|
+
const EXPECTED_GATEWAY_ERROR_CODES = new Set([-32003, -32602]);
|
|
76
|
+
function isExpectedGatewayError(code) {
|
|
77
|
+
return code != null && EXPECTED_GATEWAY_ERROR_CODES.has(code);
|
|
78
|
+
}
|
|
56
79
|
// A 401 from ANY integration-gateway vault call means the caller's HQ session
|
|
57
80
|
// is expired or missing — an expected auth state fixed by `hq login`, not an
|
|
58
81
|
// hq-cli defect. Raise the same typed AuthError the vault company-resolution
|
|
@@ -142,7 +165,7 @@ export async function callGateway(token, params) {
|
|
|
142
165
|
throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status) });
|
|
143
166
|
}
|
|
144
167
|
if (message.error) {
|
|
145
|
-
throw new IntegrationsCliError(message.error.message ?? "Integration gateway returned an error.");
|
|
168
|
+
throw new IntegrationsCliError(message.error.message ?? "Integration gateway returned an error.", { expected: isExpectedGatewayError(message.error.code) });
|
|
146
169
|
}
|
|
147
170
|
return message;
|
|
148
171
|
}
|
|
@@ -1,27 +1,9 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Thin wrapper over @indigoai-us/hq-cloud's reindex(), which execs the bundled
|
|
6
|
-
* scripts/reindex.sh against the HQ root. This command is what the hq-core
|
|
7
|
-
* reindex hook shim calls on Stop / PostToolUse, and what sync()/rescue() call
|
|
8
|
-
* after they change on-disk sources.
|
|
9
|
-
*
|
|
10
|
-
* Keeps a hidden `master-sync` alias for one release so an updated CLI still
|
|
11
|
-
* answers a not-yet-updated hook shim (and vice-versa) during rollout.
|
|
12
|
-
*
|
|
13
|
-
* Lock-wait policy: reindex shares one per-root operation lock with `sync` and
|
|
14
|
-
* `rescue`. By default acquisition waits UNBOUNDED for a live holder, which is
|
|
15
|
-
* what a human running `hq reindex` interactively wants. But when invoked from a
|
|
16
|
-
* Claude/Codex lifecycle hook (SessionStart / UserPromptSubmit / Stop /
|
|
17
|
-
* PostToolUse), waiting is exactly wrong: if a sync/rescue is mid-flight, the
|
|
18
|
-
* hook blocks the agent up to the host's per-hook timeout (the multi-minute
|
|
19
|
-
* "Claude won't load in the HQ folder" spinner). `--from-hook` makes the hook
|
|
20
|
-
* path refuse-fast (never wait); `--lock-timeout <sec>` bounds it explicitly.
|
|
21
|
-
* The bound is applied via the HQ_OP_LOCK_TIMEOUT env var that the hq-cloud
|
|
22
|
-
* operation lock honors, so it works even against an installed hq-cloud build
|
|
23
|
-
* that predates a typed lock-timeout option.
|
|
3
|
+
* Check hook health without relying on lifecycle hooks. A fully disabled
|
|
4
|
+
* configuration is repaired only after a successful reindex; partial and
|
|
5
|
+
* malformed configurations remain untouched and receive recovery guidance.
|
|
24
6
|
*/
|
|
25
|
-
|
|
7
|
+
export declare function repairExtremeHookDrift(hqRoot: string, allowRepair?: boolean): void;
|
|
26
8
|
export declare function registerReindexCommand(program: Command): void;
|
|
27
9
|
//# sourceMappingURL=reindex.d.ts.map
|
package/dist/commands/reindex.js
CHANGED
|
@@ -1,4 +1,208 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* hq reindex — surface namespaced skills, mirror the personal overlay into
|
|
3
|
+
* core/, and regenerate the workers registry.
|
|
4
|
+
*
|
|
5
|
+
* Thin wrapper over @indigoai-us/hq-cloud's reindex(), which execs the bundled
|
|
6
|
+
* scripts/reindex.sh against the HQ root. This command is what the hq-core
|
|
7
|
+
* reindex hook shim calls on Stop / PostToolUse, and what sync()/rescue() call
|
|
8
|
+
* after they change on-disk sources.
|
|
9
|
+
*
|
|
10
|
+
* Keeps a hidden `master-sync` alias for one release so an updated CLI still
|
|
11
|
+
* answers a not-yet-updated hook shim (and vice-versa) during rollout.
|
|
12
|
+
*
|
|
13
|
+
* Lock-wait policy: reindex shares one per-root operation lock with `sync` and
|
|
14
|
+
* `rescue`. By default acquisition waits UNBOUNDED for a live holder, which is
|
|
15
|
+
* what a human running `hq reindex` interactively wants. But when invoked from a
|
|
16
|
+
* Claude/Codex lifecycle hook (SessionStart / UserPromptSubmit / Stop /
|
|
17
|
+
* PostToolUse), waiting is exactly wrong: if a sync/rescue is mid-flight, the
|
|
18
|
+
* hook blocks the agent up to the host's per-hook timeout (the multi-minute
|
|
19
|
+
* "Claude won't load in the HQ folder" spinner). `--from-hook` makes the hook
|
|
20
|
+
* path refuse-fast (never wait); `--lock-timeout <sec>` bounds it explicitly.
|
|
21
|
+
* The bound is applied via the HQ_OP_LOCK_TIMEOUT env var that the hq-cloud
|
|
22
|
+
* operation lock honors, so it works even against an installed hq-cloud build
|
|
23
|
+
* that predates a typed lock-timeout option.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
import * as yaml from 'js-yaml';
|
|
29
|
+
import { reindex, rescue } from '@indigoai-us/hq-cloud';
|
|
30
|
+
import { findHqRoot } from '../utils/manifest.js';
|
|
31
|
+
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
32
|
+
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
33
|
+
/** Resolve the same root the repair/check commands must operate on. */
|
|
34
|
+
function resolveHqRoot(repoRoot) {
|
|
35
|
+
const root = repoRoot ?? findHqRoot();
|
|
36
|
+
try {
|
|
37
|
+
return fs.realpathSync(root);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return path.resolve(root);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function isHqRoot(hqRoot) {
|
|
44
|
+
return (fs.existsSync(path.join(hqRoot, 'companies')) &&
|
|
45
|
+
(fs.existsSync(path.join(hqRoot, '.claude')) ||
|
|
46
|
+
fs.existsSync(path.join(hqRoot, 'core')) ||
|
|
47
|
+
fs.existsSync(path.join(hqRoot, 'personal'))));
|
|
48
|
+
}
|
|
49
|
+
function hasCommandHook(value) {
|
|
50
|
+
if (!Array.isArray(value))
|
|
51
|
+
return false;
|
|
52
|
+
return value.some((entry) => {
|
|
53
|
+
if (!entry || typeof entry !== 'object')
|
|
54
|
+
return false;
|
|
55
|
+
const hooks = entry.hooks;
|
|
56
|
+
if (!Array.isArray(hooks))
|
|
57
|
+
return false;
|
|
58
|
+
return hooks.some((hook) => !!hook &&
|
|
59
|
+
typeof hook === 'object' &&
|
|
60
|
+
hook.type === 'command' &&
|
|
61
|
+
typeof hook.command === 'string' &&
|
|
62
|
+
hook.command.trim().length > 0);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run the release health checker when it is available. Its diagnostics cover
|
|
67
|
+
* runtime/configuration issues outside the safe repair scope; the JSON check
|
|
68
|
+
* below additionally recognizes UserPromptSubmit, which older checkers omit.
|
|
69
|
+
*/
|
|
70
|
+
function runShippedHookCheck(hqRoot) {
|
|
71
|
+
const checker = path.join(hqRoot, HOOK_CHECK_RELATIVE_PATH);
|
|
72
|
+
if (!fs.existsSync(checker))
|
|
73
|
+
return undefined;
|
|
74
|
+
try {
|
|
75
|
+
const result = spawnSync('bash', [checker, '--root', hqRoot], {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: 'pipe',
|
|
78
|
+
});
|
|
79
|
+
if (result.error)
|
|
80
|
+
return undefined;
|
|
81
|
+
return {
|
|
82
|
+
status: result.status ?? 1,
|
|
83
|
+
output: `${result.stderr ?? ''}${result.stdout ?? ''}`.trim(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function inspectHookHealth(hqRoot) {
|
|
91
|
+
const checkerResult = runShippedHookCheck(hqRoot);
|
|
92
|
+
const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
|
|
93
|
+
if (!fs.existsSync(settingsPath)) {
|
|
94
|
+
return { state: 'extreme', reason: '.claude/settings.json is missing' };
|
|
95
|
+
}
|
|
96
|
+
let settings;
|
|
97
|
+
try {
|
|
98
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { state: 'minor', reason: '.claude/settings.json is not valid JSON' };
|
|
102
|
+
}
|
|
103
|
+
if (!settings || typeof settings !== 'object') {
|
|
104
|
+
return { state: 'minor', reason: '.claude/settings.json is not an object' };
|
|
105
|
+
}
|
|
106
|
+
const hooks = settings.hooks;
|
|
107
|
+
if (!hooks || typeof hooks !== 'object') {
|
|
108
|
+
return { state: 'extreme', reason: 'no command hooks are declared' };
|
|
109
|
+
}
|
|
110
|
+
const commandEvents = HOOK_EVENTS.filter((event) => hasCommandHook(hooks[event]));
|
|
111
|
+
if (commandEvents.length === 0) {
|
|
112
|
+
return { state: 'extreme', reason: 'no SessionStart, UserPromptSubmit, or PreToolUse command hook is declared' };
|
|
113
|
+
}
|
|
114
|
+
if (commandEvents.length !== HOOK_EVENTS.length) {
|
|
115
|
+
return {
|
|
116
|
+
state: 'minor',
|
|
117
|
+
reason: `missing command hook wiring for ${HOOK_EVENTS.filter((event) => !commandEvents.includes(event)).join(', ')}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (checkerResult !== undefined && checkerResult.status !== 0) {
|
|
121
|
+
return {
|
|
122
|
+
state: 'minor',
|
|
123
|
+
reason: checkerResult.output || 'the shipped core/scripts/check-hq-hooks.sh check failed',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { state: 'healthy' };
|
|
127
|
+
}
|
|
128
|
+
function printHookHealthWarning(hqRoot, reason) {
|
|
129
|
+
console.warn(`
|
|
130
|
+
HQ hook health warning: lifecycle hooks may not fire in ${hqRoot}.
|
|
131
|
+
- ${reason}
|
|
132
|
+
|
|
133
|
+
Repair the project settings with:
|
|
134
|
+
hq rescue -y --paths .claude
|
|
135
|
+
|
|
136
|
+
If the repair is unavailable, update HQ and re-run \`hq reindex\`.
|
|
137
|
+
For Claude Desktop, open the HQ root itself as the project (not a parent or child folder).
|
|
138
|
+
For an SDK launch, set both \`cwd\` to the HQ root and \`settingSources: ["project"]\`.
|
|
139
|
+
See core/docs/hq/HOOKS-NOT-FIRING.md for the recovery procedure.`);
|
|
140
|
+
}
|
|
141
|
+
function installedCoreRef(hqRoot) {
|
|
142
|
+
for (const relativePath of [path.join('core', 'core.yaml'), 'core.yaml']) {
|
|
143
|
+
try {
|
|
144
|
+
const core = yaml.load(fs.readFileSync(path.join(hqRoot, relativePath), 'utf8'));
|
|
145
|
+
if (typeof core?.hqVersion === 'string' && core.hqVersion.trim()) {
|
|
146
|
+
return core.hqVersion.startsWith('v') ? core.hqVersion : `v${core.hqVersion}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// The installed version is optional; rescue will use the production default.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
/** Rescue writes detailed progress directly to stdio. Reindex only needs a one-line notice. */
|
|
156
|
+
function runSilently(operation) {
|
|
157
|
+
const writeStdout = process.stdout.write;
|
|
158
|
+
const writeStderr = process.stderr.write;
|
|
159
|
+
const discard = (() => true);
|
|
160
|
+
process.stdout.write = discard;
|
|
161
|
+
process.stderr.write = discard;
|
|
162
|
+
try {
|
|
163
|
+
return operation();
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
process.stdout.write = writeStdout;
|
|
167
|
+
process.stderr.write = writeStderr;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Check hook health without relying on lifecycle hooks. A fully disabled
|
|
172
|
+
* configuration is repaired only after a successful reindex; partial and
|
|
173
|
+
* malformed configurations remain untouched and receive recovery guidance.
|
|
174
|
+
*/
|
|
175
|
+
export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
176
|
+
// `hq reindex` can still be invoked from an arbitrary directory. Never turn
|
|
177
|
+
// an absent settings file there into a rescue attempt.
|
|
178
|
+
if (!isHqRoot(hqRoot))
|
|
179
|
+
return;
|
|
180
|
+
const health = inspectHookHealth(hqRoot);
|
|
181
|
+
if (health.state === 'healthy')
|
|
182
|
+
return;
|
|
183
|
+
printHookHealthWarning(hqRoot, health.reason);
|
|
184
|
+
if (health.state === 'minor' || !allowRepair)
|
|
185
|
+
return;
|
|
186
|
+
try {
|
|
187
|
+
const result = runSilently(() => rescue({
|
|
188
|
+
hqRoot,
|
|
189
|
+
source: 'indigoai-us/hq-core',
|
|
190
|
+
ref: installedCoreRef(hqRoot),
|
|
191
|
+
paths: ['.claude'],
|
|
192
|
+
assumeYes: true,
|
|
193
|
+
}));
|
|
194
|
+
const afterRepair = inspectHookHealth(hqRoot);
|
|
195
|
+
if (result.status === 0 && afterRepair.state === 'healthy') {
|
|
196
|
+
console.log('reindex: repaired HQ hook config');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
printHookHealthWarning(hqRoot, afterRepair.state === 'healthy' ? `rescue exited ${result.status}` : afterRepair.reason);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// A transient clone/transport failure must not turn reindex into a fatal command.
|
|
203
|
+
printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
2
206
|
export function registerReindexCommand(program) {
|
|
3
207
|
program
|
|
4
208
|
.command('reindex')
|
|
@@ -29,6 +233,7 @@ export function registerReindexCommand(program) {
|
|
|
29
233
|
process.env.HQ_OP_LOCK_TIMEOUT = String(lockTimeoutSec);
|
|
30
234
|
}
|
|
31
235
|
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
236
|
+
repairExtremeHookDrift(resolveHqRoot(opts.repoRoot), status === 0);
|
|
32
237
|
process.exit(status);
|
|
33
238
|
});
|
|
34
239
|
}
|
|
@@ -41,6 +41,40 @@ interface VersionCheckResponse {
|
|
|
41
41
|
export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
42
42
|
export declare function resolveRunningPrefix(): string | null;
|
|
43
43
|
export declare function buildPrefixedInstallArgv(prefix: string): string[];
|
|
44
|
+
/**
|
|
45
|
+
* Filesystem surface used by {@link cleanStalePartialInstall}. Injected so the
|
|
46
|
+
* cleanup logic is unit-testable without touching a real global prefix.
|
|
47
|
+
*/
|
|
48
|
+
export interface StaleInstallFs {
|
|
49
|
+
readdirSync: (dir: string) => string[];
|
|
50
|
+
existsSync: (target: string) => boolean;
|
|
51
|
+
readFileSync: (target: string, encoding: "utf-8") => string;
|
|
52
|
+
rmSync: (target: string, options: {
|
|
53
|
+
recursive: boolean;
|
|
54
|
+
force: boolean;
|
|
55
|
+
}) => void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Remove leftover artifacts from an interrupted `npm install -g` so a retry can
|
|
59
|
+
* succeed. npm unpacks a package into a `.<pkg>-<rand>` staging dir alongside
|
|
60
|
+
* the final location and then renames it into place; if a previous run was
|
|
61
|
+
* killed mid-rename (or a half-written package dir survives), every subsequent
|
|
62
|
+
* install fails with `ENOTEMPTY` because npm cannot atomically rename over the
|
|
63
|
+
* non-empty leftover. npm does not self-heal this — the stale dir must be
|
|
64
|
+
* removed first.
|
|
65
|
+
*
|
|
66
|
+
* To stay safe we only ever delete:
|
|
67
|
+
* - dot-prefixed npm staging dirs for THIS package (`.hq-cli-*`), and
|
|
68
|
+
* - a package dir whose `package.json` is missing/unreadable or whose `name`
|
|
69
|
+
* is not exactly {@link CLI_NAME} (i.e. a genuinely partial/foreign dir).
|
|
70
|
+
*
|
|
71
|
+
* A healthy install (valid `package.json`, `name === CLI_NAME`) is never
|
|
72
|
+
* touched, so an ordinary version bump still flows through npm untouched.
|
|
73
|
+
*
|
|
74
|
+
* Returns the list of removed paths — empty when there was nothing to clean, so
|
|
75
|
+
* callers can gate a reinstall retry on `removed.length > 0`.
|
|
76
|
+
*/
|
|
77
|
+
export declare function cleanStalePartialInstall(prefix: string, fs?: StaleInstallFs): string[];
|
|
44
78
|
/**
|
|
45
79
|
* Run the upgrade command in a blocking subprocess. Inherits stdio so the
|
|
46
80
|
* user sees the npm progress. We do NOT auto-rerun the CLI on completion —
|
|
@@ -69,6 +103,7 @@ declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: {
|
|
|
69
103
|
performUpdateString?: (command: string) => UpdateResult;
|
|
70
104
|
resolvePrefix?: () => string | null;
|
|
71
105
|
runner?: UpdateRunner;
|
|
106
|
+
cleanStale?: (prefix: string) => string[];
|
|
72
107
|
}): never;
|
|
73
108
|
/**
|
|
74
109
|
* Public entry point. Call before commander parses argv. Blocks the CLI on
|
|
@@ -92,6 +127,7 @@ export declare const __test__: {
|
|
|
92
127
|
ENDPOINT_PATH: string;
|
|
93
128
|
FETCH_TIMEOUT_MS: number;
|
|
94
129
|
buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
|
|
130
|
+
cleanStalePartialInstall: typeof cleanStalePartialInstall;
|
|
95
131
|
enforceUpdateRequired: typeof enforceUpdateRequired;
|
|
96
132
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
97
133
|
performUpdate: typeof performUpdate;
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* to silence both check + gate).
|
|
29
29
|
*/
|
|
30
30
|
import { spawnSync } from "node:child_process";
|
|
31
|
-
import { readFileSync } from "node:fs";
|
|
31
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import chalk from "chalk";
|
|
@@ -86,6 +86,90 @@ export function resolveRunningPrefix() {
|
|
|
86
86
|
export function buildPrefixedInstallArgv(prefix) {
|
|
87
87
|
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
88
88
|
}
|
|
89
|
+
const nodeStaleInstallFs = {
|
|
90
|
+
readdirSync: (dir) => readdirSync(dir),
|
|
91
|
+
existsSync,
|
|
92
|
+
readFileSync: (target, encoding) => readFileSync(target, encoding),
|
|
93
|
+
rmSync,
|
|
94
|
+
};
|
|
95
|
+
function isHealthyPackageDir(pkgDir, fs) {
|
|
96
|
+
try {
|
|
97
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"));
|
|
98
|
+
return pkg.name === CLI_NAME;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false; // missing / unreadable / malformed package.json ⇒ partial
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Remove leftover artifacts from an interrupted `npm install -g` so a retry can
|
|
106
|
+
* succeed. npm unpacks a package into a `.<pkg>-<rand>` staging dir alongside
|
|
107
|
+
* the final location and then renames it into place; if a previous run was
|
|
108
|
+
* killed mid-rename (or a half-written package dir survives), every subsequent
|
|
109
|
+
* install fails with `ENOTEMPTY` because npm cannot atomically rename over the
|
|
110
|
+
* non-empty leftover. npm does not self-heal this — the stale dir must be
|
|
111
|
+
* removed first.
|
|
112
|
+
*
|
|
113
|
+
* To stay safe we only ever delete:
|
|
114
|
+
* - dot-prefixed npm staging dirs for THIS package (`.hq-cli-*`), and
|
|
115
|
+
* - a package dir whose `package.json` is missing/unreadable or whose `name`
|
|
116
|
+
* is not exactly {@link CLI_NAME} (i.e. a genuinely partial/foreign dir).
|
|
117
|
+
*
|
|
118
|
+
* A healthy install (valid `package.json`, `name === CLI_NAME`) is never
|
|
119
|
+
* touched, so an ordinary version bump still flows through npm untouched.
|
|
120
|
+
*
|
|
121
|
+
* Returns the list of removed paths — empty when there was nothing to clean, so
|
|
122
|
+
* callers can gate a reinstall retry on `removed.length > 0`.
|
|
123
|
+
*/
|
|
124
|
+
export function cleanStalePartialInstall(prefix, fs = nodeStaleInstallFs) {
|
|
125
|
+
const removed = [];
|
|
126
|
+
const slash = CLI_NAME.indexOf("/");
|
|
127
|
+
const scope = slash === -1 ? null : CLI_NAME.slice(0, slash);
|
|
128
|
+
const leaf = slash === -1 ? CLI_NAME : CLI_NAME.slice(slash + 1);
|
|
129
|
+
const stagingPrefix = `.${leaf}-`;
|
|
130
|
+
// Global npm keeps packages under `<prefix>/lib/node_modules` (unix) while a
|
|
131
|
+
// bare `--prefix` dir (windows / some sandboxes) uses `<prefix>/node_modules`.
|
|
132
|
+
const nmRoots = [
|
|
133
|
+
path.join(prefix, "lib", "node_modules"),
|
|
134
|
+
path.join(prefix, "node_modules"),
|
|
135
|
+
];
|
|
136
|
+
for (const nmRoot of nmRoots) {
|
|
137
|
+
// For a scoped package the staging dir + final dir both live inside the
|
|
138
|
+
// scope dir (`.../@indigoai-us/.hq-cli-<rand>`, `.../@indigoai-us/hq-cli`).
|
|
139
|
+
const parentDir = scope ? path.join(nmRoot, scope) : nmRoot;
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = fs.readdirSync(parentDir);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
continue; // this node_modules / scope dir doesn't exist here
|
|
146
|
+
}
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if (!entry.startsWith(stagingPrefix))
|
|
149
|
+
continue;
|
|
150
|
+
const target = path.join(parentDir, entry);
|
|
151
|
+
try {
|
|
152
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
153
|
+
removed.push(target);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// best-effort: a dir we can't remove (perms) just means the retry
|
|
157
|
+
// still fails and we fall through to the sudo / manual path.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const pkgDir = path.join(parentDir, leaf);
|
|
161
|
+
if (fs.existsSync(pkgDir) && !isHealthyPackageDir(pkgDir, fs)) {
|
|
162
|
+
try {
|
|
163
|
+
fs.rmSync(pkgDir, { recursive: true, force: true });
|
|
164
|
+
removed.push(pkgDir);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// best-effort (see above)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return removed;
|
|
172
|
+
}
|
|
89
173
|
/**
|
|
90
174
|
* Hit POST /v1/client-version/check. Returns the parsed body on 200, or
|
|
91
175
|
* `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
|
|
@@ -208,6 +292,22 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
208
292
|
? deps.performUpdateString(command)
|
|
209
293
|
: performUpdate(command, runner);
|
|
210
294
|
})();
|
|
295
|
+
// A partial/corrupt global install leaves npm unable to atomically rename its
|
|
296
|
+
// freshly-unpacked package over a leftover directory, so the install above
|
|
297
|
+
// fails with ENOTEMPTY (e.g. a prior interrupted `npm install -g` left a
|
|
298
|
+
// half-written `hq-cli` package dir or a `.hq-cli-<rand>` staging dir under
|
|
299
|
+
// the prefix's node_modules). npm cannot self-heal this. Remove the stale
|
|
300
|
+
// artifacts and retry the install ONCE. Guarded on `cleaned.length > 0` so a
|
|
301
|
+
// plain EACCES on an otherwise-healthy prefix falls straight through to the
|
|
302
|
+
// sudo retry below without a redundant reinstall attempt.
|
|
303
|
+
if (!result.ok && prefix) {
|
|
304
|
+
const cleaner = deps.cleanStale ?? cleanStalePartialInstall;
|
|
305
|
+
const cleaned = cleaner(prefix);
|
|
306
|
+
if (cleaned.length > 0) {
|
|
307
|
+
console.error(chalk.dim(` Removing stale partial install artifacts and retrying: ${cleaned.join(", ")}`));
|
|
308
|
+
result = performUpdateCommand("npm", primaryArgs, runner);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
211
311
|
// A root-owned global install (e.g. a system `/usr` install where the CLI runs
|
|
212
312
|
// unprivileged — the outpost agent boxes) can't rewrite the prefix's bin dir,
|
|
213
313
|
// so the install above fails with EACCES (`rename /usr/bin/hq`). Retry ONCE
|
|
@@ -269,6 +369,7 @@ export const __test__ = {
|
|
|
269
369
|
ENDPOINT_PATH,
|
|
270
370
|
FETCH_TIMEOUT_MS,
|
|
271
371
|
buildPrefixedInstallArgv,
|
|
372
|
+
cleanStalePartialInstall,
|
|
272
373
|
enforceUpdateRequired,
|
|
273
374
|
npmPrefixFromPackageDir,
|
|
274
375
|
performUpdate,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.77.
|
|
3
|
+
"version": "5.77.7",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
25
|
-
"@indigoai-us/hq-cloud": "^6.14.
|
|
25
|
+
"@indigoai-us/hq-cloud": "^6.14.19",
|
|
26
26
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
27
27
|
"@sentry/node": "^10.49.0",
|
|
28
28
|
"better-sqlite3": "^12.11.1",
|
package/pnpm-workspace.yaml
CHANGED
|
@@ -513,3 +513,121 @@ describe("integration gateway 401 → AuthError (HQ-CLI-9)", () => {
|
|
|
513
513
|
expect((err as Error).message).toMatch(/monday rejected the board id/);
|
|
514
514
|
});
|
|
515
515
|
});
|
|
516
|
+
|
|
517
|
+
// HQ-CLI-B: `hq integrations tools|call … --provider …` against a connection the
|
|
518
|
+
// caller has NOT been granted returned HTTP 200 with a JSON-RPC error
|
|
519
|
+
// { code: -32003, message: "You do not have access to this integration. Ask its
|
|
520
|
+
// owner to share it with you.", data: { code: "IntegrationAccessDenied" } }.
|
|
521
|
+
// `callGateway` threw an IntegrationsCliError WITHOUT `expected`, so it defaulted
|
|
522
|
+
// to `expected === false` and the top-level handler shipped a GOVERNED, correct
|
|
523
|
+
// access denial to Sentry as an error-level fatal — 40 identical, unfixable
|
|
524
|
+
// events for one user. A caller-side JSON-RPC code (UNAUTHORIZED / INVALID_PARAMS)
|
|
525
|
+
// is the analog of a client 4xx and must be printed to the user, not captured;
|
|
526
|
+
// genuine server/provider/protocol faults must still report.
|
|
527
|
+
describe("gateway JSON-RPC error classification (HQ-CLI-B)", () => {
|
|
528
|
+
function gatewayJsonRpcError(code: number | undefined, message: string, data?: unknown) {
|
|
529
|
+
return jsonResponse({
|
|
530
|
+
jsonrpc: "2.0",
|
|
531
|
+
id: "x",
|
|
532
|
+
error: { ...(code == null ? {} : { code }), message, ...(data ? { data } : {}) },
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function runGatewayCall(): Promise<unknown> {
|
|
537
|
+
return runCli([
|
|
538
|
+
"integrations",
|
|
539
|
+
"call",
|
|
540
|
+
"get_board_info",
|
|
541
|
+
"--provider",
|
|
542
|
+
"linear",
|
|
543
|
+
"--args",
|
|
544
|
+
"{}",
|
|
545
|
+
]).then(
|
|
546
|
+
() => {
|
|
547
|
+
throw new Error("expected runCli to throw");
|
|
548
|
+
},
|
|
549
|
+
(e: unknown) => e,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
it("marks the access-denied gateway error (-32003) as expected and preserves its actionable message", async () => {
|
|
554
|
+
vaultApiFetchMock
|
|
555
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
556
|
+
.mockResolvedValueOnce(
|
|
557
|
+
gatewayJsonRpcError(
|
|
558
|
+
-32003,
|
|
559
|
+
"You do not have access to this integration. Ask its owner to share it with you.",
|
|
560
|
+
{ code: "IntegrationAccessDenied" },
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const err = await runGatewayCall();
|
|
565
|
+
|
|
566
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
567
|
+
// The regression: this was `false` and flooded Sentry with a governed denial.
|
|
568
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
569
|
+
// A connection-level denial is NOT an expired session — never an AuthError.
|
|
570
|
+
expect(isAuthError(err)).toBe(false);
|
|
571
|
+
expect((err as Error).message).toContain("do not have access to this integration");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("also marks access-denied as expected on the `tools` path (same callGateway seam)", async () => {
|
|
575
|
+
vaultApiFetchMock
|
|
576
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
577
|
+
.mockResolvedValueOnce(
|
|
578
|
+
gatewayJsonRpcError(
|
|
579
|
+
-32003,
|
|
580
|
+
"You do not have access to this integration. Ask its owner to share it with you.",
|
|
581
|
+
),
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
const err = await runCli(["integrations", "tools", "--provider", "linear"]).then(
|
|
585
|
+
() => {
|
|
586
|
+
throw new Error("expected runCli to throw");
|
|
587
|
+
},
|
|
588
|
+
(e: unknown) => e,
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
592
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("marks an invalid-params gateway error (-32602) as expected", async () => {
|
|
596
|
+
vaultApiFetchMock
|
|
597
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
598
|
+
.mockResolvedValueOnce(gatewayJsonRpcError(-32602, "Unknown integration tool: get_board_info"));
|
|
599
|
+
|
|
600
|
+
const err = await runGatewayCall();
|
|
601
|
+
|
|
602
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
603
|
+
expect((err as IntegrationsCliError).expected).toBe(true);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("still reports genuine provider/internal/conflict gateway faults (expected === false)", async () => {
|
|
607
|
+
// -32050 PROVIDER_ERROR, -32603 INTERNAL_ERROR, and -32009 CONFLICT (which the
|
|
608
|
+
// gateway raises for a confirm queue being unavailable or an owner
|
|
609
|
+
// notification failing) are real faults that must keep reaching Sentry.
|
|
610
|
+
for (const code of [-32050, -32603, -32009]) {
|
|
611
|
+
vaultApiFetchMock
|
|
612
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
613
|
+
.mockResolvedValueOnce(gatewayJsonRpcError(code, "gateway fault"));
|
|
614
|
+
|
|
615
|
+
const err = await runGatewayCall();
|
|
616
|
+
|
|
617
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
618
|
+
expect((err as IntegrationsCliError).expected).toBe(false);
|
|
619
|
+
vaultApiFetchMock.mockClear();
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("reports a gateway error with no JSON-RPC code (unclassified → expected === false)", async () => {
|
|
624
|
+
vaultApiFetchMock
|
|
625
|
+
.mockResolvedValueOnce(connectionsResponse())
|
|
626
|
+
.mockResolvedValueOnce(gatewayJsonRpcError(undefined, "codeless failure"));
|
|
627
|
+
|
|
628
|
+
const err = await runGatewayCall();
|
|
629
|
+
|
|
630
|
+
expect(err).toBeInstanceOf(IntegrationsCliError);
|
|
631
|
+
expect((err as IntegrationsCliError).expected).toBe(false);
|
|
632
|
+
});
|
|
633
|
+
});
|