@indigoai-us/hq-cli 5.106.2 → 5.107.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/commands/cloud.d.ts +30 -0
- package/dist/commands/cloud.js +85 -16
- package/dist/commands/reindex.js +15 -5
- package/dist/commands/run.js +25 -2
- package/dist/index.js +12 -1
- package/dist/lib/doctor/checks/sync-health.d.ts +65 -0
- package/dist/lib/doctor/checks/sync-health.js +279 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/main.js +19 -1
- 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/client-health-contract.d.ts +151 -0
- package/dist/utils/client-health-contract.js +306 -0
- package/dist/utils/client-health.d.ts +134 -0
- package/dist/utils/client-health.js +376 -0
- package/dist/utils/hook-trust.d.ts +62 -2
- package/dist/utils/hook-trust.js +242 -3
- package/dist/utils/vault-api.js +6 -0
- package/package.json +2 -2
package/dist/utils/hook-trust.js
CHANGED
|
@@ -3,6 +3,26 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as readline from 'node:readline';
|
|
6
|
+
// `~/.claude.json` is Claude Code's live user-global state file, so the write
|
|
7
|
+
// reuses the hardened MCP-registration primitives (advisory lock held across
|
|
8
|
+
// read->merge->write, backup before the first byte, temp+fsync+rename commit)
|
|
9
|
+
// rather than a bare readFile/writeFile pair.
|
|
10
|
+
import { acquireLock, atomicReplace, backupConfig, realpathOrSelf, releaseLock, restoreFromBackup, } from '../commands/mcp-registration.js';
|
|
11
|
+
/**
|
|
12
|
+
* The home directory every runtime's config is resolved under.
|
|
13
|
+
*
|
|
14
|
+
* `HOME=''` is not the same as `HOME` unset: with `??` an empty-but-set `HOME`
|
|
15
|
+
* survives the fallback, and every `path.join(home, ...)` below it silently
|
|
16
|
+
* becomes a RELATIVE path — pointing `~/.claude.json` at a same-named file in
|
|
17
|
+
* the cwd, reading it, writing it, and dropping a backup beside it, while the
|
|
18
|
+
* user's real config goes untouched. Empty means "no home", exactly as the
|
|
19
|
+
* safe-write substrate's `resolveEnv` treats it.
|
|
20
|
+
*
|
|
21
|
+
* Exported for tests; production callers get it through `DEFAULT_DEPS`.
|
|
22
|
+
*/
|
|
23
|
+
export function defaultHomeDir() {
|
|
24
|
+
return process.env.HOME?.trim() ? process.env.HOME : os.homedir();
|
|
25
|
+
}
|
|
6
26
|
const CODEX_REQUEST_TIMEOUT_MS = 10_000;
|
|
7
27
|
function errorMessage(value) {
|
|
8
28
|
return value instanceof Error ? value.message : String(value);
|
|
@@ -154,7 +174,7 @@ export async function createCodexAppServerClient(cwd, executable = 'codex', args
|
|
|
154
174
|
}
|
|
155
175
|
const DEFAULT_DEPS = {
|
|
156
176
|
createCodexClient: createCodexAppServerClient,
|
|
157
|
-
homeDir:
|
|
177
|
+
homeDir: defaultHomeDir,
|
|
158
178
|
};
|
|
159
179
|
/** Trust only hooks declared by this HQ root's project `.codex/` layer. */
|
|
160
180
|
export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
@@ -232,6 +252,221 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
|
232
252
|
}
|
|
233
253
|
}
|
|
234
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* How many times to re-read and re-merge `~/.claude.json` when Claude rewrites
|
|
257
|
+
* it mid-flight. Three is enough to ride out an exiting session's write without
|
|
258
|
+
* spinning against a runtime that is actively churning the file.
|
|
259
|
+
*/
|
|
260
|
+
const CLAUDE_MERGE_ATTEMPTS = 3;
|
|
261
|
+
/** `~/.claude.json` — Claude Code's user-global state file. */
|
|
262
|
+
function claudeConfigPath(home) {
|
|
263
|
+
return path.join(home, '.claude.json');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The home directory to resolve runtime config under, or `''` if there is none.
|
|
267
|
+
*
|
|
268
|
+
* An injected `homeDir` is authoritative: if a caller says the home is empty we
|
|
269
|
+
* report that rather than quietly reaching for the real one behind its back.
|
|
270
|
+
* The empty-`HOME` fallback lives in {@link defaultHomeDir}.
|
|
271
|
+
*/
|
|
272
|
+
function resolveTrustHome(deps) {
|
|
273
|
+
const home = (deps.homeDir ?? DEFAULT_DEPS.homeDir)();
|
|
274
|
+
return home?.trim() ? home : '';
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Every spelling of the HQ root that Claude might have keyed `projects` under.
|
|
278
|
+
*
|
|
279
|
+
* Claude keys a project by the cwd of the session, so a symlinked HQ root can
|
|
280
|
+
* appear under either the link or its target depending on how the user got
|
|
281
|
+
* there. Trusting both is harmless — an entry for a path is inert until a
|
|
282
|
+
* session actually opens it — and trusting only one is a silent no-op.
|
|
283
|
+
*
|
|
284
|
+
* `aliases` carries the spellings the caller had before it canonicalized;
|
|
285
|
+
* without them a command that realpaths its root up front can only ever
|
|
286
|
+
* produce one key here, and the symlink branch is unreachable in real use.
|
|
287
|
+
*/
|
|
288
|
+
function claudeProjectKeys(hqRoot, aliases = []) {
|
|
289
|
+
const keys = [];
|
|
290
|
+
const add = (candidate) => {
|
|
291
|
+
if (candidate && !keys.includes(candidate))
|
|
292
|
+
keys.push(candidate);
|
|
293
|
+
};
|
|
294
|
+
for (const raw of [hqRoot, ...aliases]) {
|
|
295
|
+
if (!raw)
|
|
296
|
+
continue;
|
|
297
|
+
const resolved = path.resolve(raw);
|
|
298
|
+
add(resolved);
|
|
299
|
+
try {
|
|
300
|
+
add(fs.realpathSync(resolved));
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Root may be unreadable; the literal path is still worth trusting.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return keys;
|
|
307
|
+
}
|
|
308
|
+
/** Re-read from disk and report which keys are still not trusted. */
|
|
309
|
+
function pendingAfterWrite(target, keys) {
|
|
310
|
+
let projects;
|
|
311
|
+
try {
|
|
312
|
+
projects = asObject(asObject(JSON.parse(fs.readFileSync(target, 'utf8')))?.projects);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return [...keys];
|
|
316
|
+
}
|
|
317
|
+
return keys.filter((key) => asObject(projects?.[key])?.hasTrustDialogAccepted !== true);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Mark this HQ root as a trusted Claude Code workspace.
|
|
321
|
+
*
|
|
322
|
+
* WHY reindex owns this: Claude's trust is per FOLDER, not per hook — the same
|
|
323
|
+
* flag gates project `.claude/settings.json` hooks AND the `.claude/skills/`
|
|
324
|
+
* plugin scan. An untrusted HQ root loads neither, and Claude reports it as
|
|
325
|
+
* "skipped because this workspace was not trusted when plugins were scanned".
|
|
326
|
+
* So the Claude leg looks like the Grok leg (write folder trust into the
|
|
327
|
+
* runtime's own config) rather than the Codex leg (trust individual hooks).
|
|
328
|
+
*
|
|
329
|
+
* Setting the key is the runtime's own documented alternative to the dialog;
|
|
330
|
+
* Claude Code's error text names it directly: "Run Claude Code in that folder
|
|
331
|
+
* once and accept the trust dialog, or set
|
|
332
|
+
* projects[<path>].hasTrustDialogAccepted: true".
|
|
333
|
+
*
|
|
334
|
+
* `~/.claude.json` is a live, high-traffic file — Claude rewrites it after
|
|
335
|
+
* every session — so this reuses the MCP registration machinery rather than a
|
|
336
|
+
* bare read/write: the advisory lock is held across read->merge->write, a
|
|
337
|
+
* backup is taken before the first written byte, and the commit is a
|
|
338
|
+
* temp+fsync+rename. `JSON.stringify(doc, null, 2)` reproduces Claude's own
|
|
339
|
+
* formatting byte-for-byte, so an unrelated key is never reformatted.
|
|
340
|
+
*/
|
|
341
|
+
export function trustClaudeProjectFolder(hqRoot, deps = DEFAULT_DEPS, options = {}) {
|
|
342
|
+
const home = resolveTrustHome(deps);
|
|
343
|
+
if (!home) {
|
|
344
|
+
return {
|
|
345
|
+
runtime: 'claude',
|
|
346
|
+
status: 'failed',
|
|
347
|
+
trusted: 0,
|
|
348
|
+
reason: 'cannot resolve a home directory for ~/.claude.json (HOME is empty and os.homedir() gave nothing)',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
const target = claudeConfigPath(home);
|
|
352
|
+
if (!fs.existsSync(target)) {
|
|
353
|
+
return {
|
|
354
|
+
runtime: 'claude',
|
|
355
|
+
status: 'skipped',
|
|
356
|
+
trusted: 0,
|
|
357
|
+
reason: 'Claude Code has not run on this machine (~/.claude.json absent)',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const keys = claudeProjectKeys(hqRoot, options.rootAliases);
|
|
361
|
+
const fail = (reason) => ({
|
|
362
|
+
runtime: 'claude',
|
|
363
|
+
status: 'failed',
|
|
364
|
+
trusted: 0,
|
|
365
|
+
reason,
|
|
366
|
+
});
|
|
367
|
+
let lock;
|
|
368
|
+
try {
|
|
369
|
+
try {
|
|
370
|
+
lock = acquireLock(target, options.lockWaitMs === undefined ? {} : { waitMs: options.lockWaitMs });
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
// Contention is not corruption. Another HQ writer holds the file, the
|
|
374
|
+
// next reindex converges, and a lifecycle hook must not stall behind it.
|
|
375
|
+
return {
|
|
376
|
+
runtime: 'claude',
|
|
377
|
+
status: 'skipped',
|
|
378
|
+
trusted: 0,
|
|
379
|
+
reason: `another HQ process is writing ~/.claude.json (${errorMessage(error)})`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// Read and write the symlink TARGET, never the link. `atomicReplace` takes
|
|
383
|
+
// a *realTarget* — handed a link it renames a regular file over the link,
|
|
384
|
+
// silently detaching it and leaving the real config unchanged (and the
|
|
385
|
+
// read-back verification would still pass, against the wrong file).
|
|
386
|
+
const real = realpathOrSelf(target);
|
|
387
|
+
const readText = deps.readConfigText ?? ((file) => fs.readFileSync(file, 'utf8'));
|
|
388
|
+
for (let attempt = 1; attempt <= CLAUDE_MERGE_ATTEMPTS; attempt += 1) {
|
|
389
|
+
const raw = readText(real);
|
|
390
|
+
let doc;
|
|
391
|
+
try {
|
|
392
|
+
doc = JSON.parse(raw);
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
// Never rewrite a file we could not parse — that would replace the
|
|
396
|
+
// user's Claude state with our own idea of it.
|
|
397
|
+
return fail(`~/.claude.json is not valid JSON (${errorMessage(error)})`);
|
|
398
|
+
}
|
|
399
|
+
const config = asObject(doc);
|
|
400
|
+
if (!config)
|
|
401
|
+
return fail('~/.claude.json is not a JSON object');
|
|
402
|
+
const projectsValue = config.projects === undefined ? {} : config.projects;
|
|
403
|
+
const projects = asObject(projectsValue);
|
|
404
|
+
if (!projects)
|
|
405
|
+
return fail('~/.claude.json "projects" is not an object');
|
|
406
|
+
// A PRESENT entry that is not an object (null, an array, a scalar) is
|
|
407
|
+
// either corruption or a schema we do not understand. Spreading it away
|
|
408
|
+
// would irreversibly drop live per-project state, so refuse instead —
|
|
409
|
+
// the same conservatism applied to the document as a whole.
|
|
410
|
+
const malformed = keys.filter((key) => projects[key] !== undefined && !asObject(projects[key]));
|
|
411
|
+
if (malformed.length > 0) {
|
|
412
|
+
return fail(`~/.claude.json has a non-object "projects" entry for ${malformed.join(', ')}`);
|
|
413
|
+
}
|
|
414
|
+
const pending = keys.filter((key) => asObject(projects[key])?.hasTrustDialogAccepted !== true);
|
|
415
|
+
if (pending.length === 0) {
|
|
416
|
+
return { runtime: 'claude', status: 'unchanged', trusted: 0 };
|
|
417
|
+
}
|
|
418
|
+
for (const key of pending) {
|
|
419
|
+
// Preserve every other field Claude keeps per project (lastCost,
|
|
420
|
+
// lastSessionId, exampleFiles, …) — only the trust flag is ours.
|
|
421
|
+
projects[key] = { ...(asObject(projects[key]) ?? {}), hasTrustDialogAccepted: true };
|
|
422
|
+
}
|
|
423
|
+
config.projects = projects;
|
|
424
|
+
const next = JSON.stringify(config, null, 2);
|
|
425
|
+
// Compare-and-swap against CLAUDE ITSELF. `.hqlock` only serializes HQ
|
|
426
|
+
// code that calls `acquireLock`; Claude has never heard of it and
|
|
427
|
+
// rewrites this file after every session. If the bytes moved under us
|
|
428
|
+
// since the read, our merge is built on a stale document and committing
|
|
429
|
+
// it would silently discard whatever Claude just wrote — so re-read and
|
|
430
|
+
// re-merge instead. This narrows the exposure to the gap between this
|
|
431
|
+
// check and the rename; it cannot close it, and losing the race is
|
|
432
|
+
// reported rather than papered over.
|
|
433
|
+
if (readText(real) !== raw) {
|
|
434
|
+
if (attempt < CLAUDE_MERGE_ATTEMPTS)
|
|
435
|
+
continue;
|
|
436
|
+
return fail('Claude rewrote ~/.claude.json during each merge attempt; left it alone rather than discarding its concurrent write');
|
|
437
|
+
}
|
|
438
|
+
// Backup before the first written byte: this is the user's live Claude
|
|
439
|
+
// state, and the backup is the only remediation if the write goes wrong.
|
|
440
|
+
const backupDir = backupConfig({ home }, target, 'hq-hook-trust');
|
|
441
|
+
atomicReplace(real, next);
|
|
442
|
+
// Verify from disk rather than trusting the in-memory merge, and roll the
|
|
443
|
+
// file back if the flag did not land — a half-written Claude state file is
|
|
444
|
+
// worse than an untrusted folder.
|
|
445
|
+
const stillPending = pendingAfterWrite(real, keys);
|
|
446
|
+
if (stillPending.length > 0) {
|
|
447
|
+
let tail = `restored from backup ${backupDir}`;
|
|
448
|
+
try {
|
|
449
|
+
restoreFromBackup(real, backupDir);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
tail = `RESTORE FAILED — the original is recoverable at ${backupDir}`;
|
|
453
|
+
}
|
|
454
|
+
return fail(`trust flag did not persist for ${stillPending.join(', ')}; ${tail}`);
|
|
455
|
+
}
|
|
456
|
+
return { runtime: 'claude', status: 'trusted', trusted: pending.length };
|
|
457
|
+
}
|
|
458
|
+
/* c8 ignore next -- the loop returns or continues; continue past the last
|
|
459
|
+
attempt is guarded above. */
|
|
460
|
+
return fail('exhausted ~/.claude.json merge attempts');
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
return fail(errorMessage(error));
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
if (lock)
|
|
467
|
+
releaseLock(lock);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
235
470
|
function regexEscape(value) {
|
|
236
471
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
237
472
|
}
|
|
@@ -412,19 +647,23 @@ export function trustGrokProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
|
412
647
|
}
|
|
413
648
|
}
|
|
414
649
|
/** Converge hook trust without turning an absent runtime into a reindex failure. */
|
|
415
|
-
export async function trustHqRuntimeHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
650
|
+
export async function trustHqRuntimeHooks(hqRoot, deps = DEFAULT_DEPS, options = {}) {
|
|
416
651
|
const results = [
|
|
417
652
|
await trustCodexProjectHooks(hqRoot, deps),
|
|
418
653
|
trustGrokProjectHooks(hqRoot, deps),
|
|
654
|
+
trustClaudeProjectFolder(hqRoot, deps, options),
|
|
419
655
|
];
|
|
420
656
|
for (const result of results) {
|
|
421
657
|
if (result.status === 'trusted') {
|
|
422
658
|
if (result.runtime === 'codex') {
|
|
423
659
|
console.log(`reindex: trusted ${result.trusted} Codex HQ hook${result.trusted === 1 ? '' : 's'}`);
|
|
424
660
|
}
|
|
425
|
-
else {
|
|
661
|
+
else if (result.runtime === 'grok') {
|
|
426
662
|
console.log('reindex: refreshed Grok HQ hook trust');
|
|
427
663
|
}
|
|
664
|
+
else {
|
|
665
|
+
console.log('reindex: trusted this HQ folder for Claude Code hooks and skills');
|
|
666
|
+
}
|
|
428
667
|
}
|
|
429
668
|
else if (result.status === 'failed') {
|
|
430
669
|
console.warn(`reindex: could not trust ${result.runtime} HQ hooks: ${result.reason ?? 'unknown error'}`);
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
|
|
2
|
+
import { CLI_VERSION } from '../cli-version.js';
|
|
2
3
|
import { Sentry } from '../sentry.js';
|
|
3
4
|
import { AuthError } from './auth-error.js';
|
|
4
5
|
import { CompanySelectionError } from './company-selection-error.js';
|
|
@@ -169,6 +170,11 @@ export async function vaultApiFetch(opts) {
|
|
|
169
170
|
Authorization: `Bearer ${opts.token}`,
|
|
170
171
|
'Content-Type': 'application/json',
|
|
171
172
|
'x-hq-client-name': HQ_CLIENT_NAME,
|
|
173
|
+
// US-003: version alongside the client family, mirroring the desktop
|
|
174
|
+
// app's client_info.rs headers, so the server can see client-version
|
|
175
|
+
// skew on EVERY authenticated request, not only health heartbeats.
|
|
176
|
+
// Fixed build constant, never caller-supplied; attribution only.
|
|
177
|
+
'x-hq-client-version': CLI_VERSION,
|
|
172
178
|
},
|
|
173
179
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
174
180
|
signal: opts.signal,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.107.0",
|
|
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",
|