@indigoai-us/hq-cli 5.106.3 → 5.107.1
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/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +28 -2
- 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 +7 -1
- package/dist/run/hq-plugin.js +1 -1
- 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/secrets-cache.d.ts +8 -2
- package/dist/utils/secrets-cache.js +150 -34
- package/dist/utils/vault-api.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.107.1] — 2026-09-03
|
|
6
|
+
|
|
7
|
+
## [5.107.0] — 2026-09-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- The CLI now reports client health to HQ Cloud, so support can see which
|
|
12
|
+
versions a person is running and when their sync last actually succeeded —
|
|
13
|
+
without asking them for a screenshot. Each authenticated invocation reports
|
|
14
|
+
the installed CLI, HQ Core, and desktop versions, and sync commands report
|
|
15
|
+
both the attempt and its real outcome. The contribution is bounded, is never
|
|
16
|
+
allowed to delay or change a command's result or exit code, and is skipped
|
|
17
|
+
entirely on machines that have no HQ installation.
|
|
18
|
+
- `hq doctor` gained a `sync` check family covering installed versions,
|
|
19
|
+
available updates, and per-company sync journal staleness. An unreadable
|
|
20
|
+
journal store is now reported as `UNKNOWN` rather than being mistaken for
|
|
21
|
+
"cloud sync not in use".
|
|
22
|
+
- Reindexing now trusts the HQ folder for Claude Code, not only Codex and Grok.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Sync commands that exit early on failure now record that failure instead of
|
|
27
|
+
leaving the run marked as still in progress.
|
|
28
|
+
|
|
5
29
|
## [5.106.3] — 2026-09-02
|
|
6
30
|
|
|
7
31
|
## [5.106.2] — 2026-09-02
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -15,6 +15,34 @@
|
|
|
15
15
|
import { Command } from "commander";
|
|
16
16
|
import { type ConflictStrategy, type MembershipSyncConfig, type SyncMode, type PullScope, type ExplicitGrant } from "@indigoai-us/hq-cloud";
|
|
17
17
|
import { type BannerLevel } from "../lib/narrow-hint-banner.js";
|
|
18
|
+
/**
|
|
19
|
+
* Terminal-failure signal for the sync fanout runners. The runners
|
|
20
|
+
* (`runPushAll` / `runPullAll` / `runPullPersonal` / `runNowSingle` /
|
|
21
|
+
* `runNowAll`) NEVER call `process.exit` themselves — they print their own
|
|
22
|
+
* diagnostics and throw this instead, so control always unwinds back into
|
|
23
|
+
* {@link withSyncHealthReport}, which records the terminal client-health
|
|
24
|
+
* outcome BEFORE the process exits. A direct `process.exit` inside a runner
|
|
25
|
+
* would skip the wrapper's catch, leaving a dangling `sync_attempt`
|
|
26
|
+
* ("syncing") heartbeat with no terminal success/failure and a wrong local
|
|
27
|
+
* `consecutiveFailures` counter (US-003 review fix).
|
|
28
|
+
*/
|
|
29
|
+
export declare class SyncExitError extends Error {
|
|
30
|
+
readonly code: number;
|
|
31
|
+
constructor(code: number);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* US-003: run one sync fanout under a client-health attempt/outcome report.
|
|
35
|
+
* The report is best-effort, bounded, and swallowed — it can only append a
|
|
36
|
+
* short bounded wait, never change the run's result, thrown error, or exit
|
|
37
|
+
* code. Direct reporting from the sync command itself (never reconstructed
|
|
38
|
+
* from cli_session_started analytics).
|
|
39
|
+
*
|
|
40
|
+
* Outcome reporting cannot be skipped: runners signal failure exits via
|
|
41
|
+
* {@link SyncExitError} (never a direct `process.exit`), so EVERY terminal
|
|
42
|
+
* path — return, throw, or exit — flows through `succeeded()`/`failed()`
|
|
43
|
+
* before this wrapper exits the process on the runner's behalf.
|
|
44
|
+
*/
|
|
45
|
+
export declare function withSyncHealthReport<T>(run: () => Promise<T>): Promise<T>;
|
|
18
46
|
/**
|
|
19
47
|
* Build a loud, human-readable warning when a push dropped files because
|
|
20
48
|
* they fell outside the caller's granted write scope. Returns null when
|
|
@@ -302,4 +330,6 @@ export interface PerCompanyPullResolveResult {
|
|
|
302
330
|
}
|
|
303
331
|
export declare function resolvePerCompanyPullPlan(client: PerCompanyPullResolveClient, targetCompany: string | undefined): Promise<PerCompanyPullResolveResult>;
|
|
304
332
|
export declare function registerCloudCommands(program: Command): void;
|
|
333
|
+
/** Exported for the US-003 sync-health exit-path regression test. */
|
|
334
|
+
export declare function runPullPersonal(hqRoot: string, onConflict?: ConflictStrategy): Promise<void>;
|
|
305
335
|
//# sourceMappingURL=cloud.d.ts.map
|
package/dist/commands/cloud.js
CHANGED
|
@@ -18,6 +18,52 @@ import * as path from "path";
|
|
|
18
18
|
import { share, sync, getStateDir, listJournals, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, resolvePullScope, } from "@indigoai-us/hq-cloud";
|
|
19
19
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
20
20
|
import { companyFolderExceedsThreshold, emitNarrowHint, isStrictRefusal, resolveBannerLevel, resolveNarrowHintMinBytes, } from "../lib/narrow-hint-banner.js";
|
|
21
|
+
import { beginSyncHealthReport, } from "../utils/client-health.js";
|
|
22
|
+
/**
|
|
23
|
+
* Terminal-failure signal for the sync fanout runners. The runners
|
|
24
|
+
* (`runPushAll` / `runPullAll` / `runPullPersonal` / `runNowSingle` /
|
|
25
|
+
* `runNowAll`) NEVER call `process.exit` themselves — they print their own
|
|
26
|
+
* diagnostics and throw this instead, so control always unwinds back into
|
|
27
|
+
* {@link withSyncHealthReport}, which records the terminal client-health
|
|
28
|
+
* outcome BEFORE the process exits. A direct `process.exit` inside a runner
|
|
29
|
+
* would skip the wrapper's catch, leaving a dangling `sync_attempt`
|
|
30
|
+
* ("syncing") heartbeat with no terminal success/failure and a wrong local
|
|
31
|
+
* `consecutiveFailures` counter (US-003 review fix).
|
|
32
|
+
*/
|
|
33
|
+
export class SyncExitError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
constructor(code) {
|
|
36
|
+
super(`sync command exit ${code}`);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.name = "SyncExitError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* US-003: run one sync fanout under a client-health attempt/outcome report.
|
|
43
|
+
* The report is best-effort, bounded, and swallowed — it can only append a
|
|
44
|
+
* short bounded wait, never change the run's result, thrown error, or exit
|
|
45
|
+
* code. Direct reporting from the sync command itself (never reconstructed
|
|
46
|
+
* from cli_session_started analytics).
|
|
47
|
+
*
|
|
48
|
+
* Outcome reporting cannot be skipped: runners signal failure exits via
|
|
49
|
+
* {@link SyncExitError} (never a direct `process.exit`), so EVERY terminal
|
|
50
|
+
* path — return, throw, or exit — flows through `succeeded()`/`failed()`
|
|
51
|
+
* before this wrapper exits the process on the runner's behalf.
|
|
52
|
+
*/
|
|
53
|
+
export async function withSyncHealthReport(run) {
|
|
54
|
+
const health = beginSyncHealthReport();
|
|
55
|
+
try {
|
|
56
|
+
const result = await run();
|
|
57
|
+
await health.succeeded();
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
await health.failed();
|
|
62
|
+
if (err instanceof SyncExitError)
|
|
63
|
+
process.exit(err.code);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
21
67
|
/**
|
|
22
68
|
* Build a loud, human-readable warning when a push dropped files because
|
|
23
69
|
* they fell outside the caller's granted write scope. Returns null when
|
|
@@ -601,7 +647,7 @@ export function registerCloudCommands(program) {
|
|
|
601
647
|
// the fanout" — wired through to `pushAll.skipPersonal`. Outside
|
|
602
648
|
// `--all` the flag has no effect (logged above as part of the
|
|
603
649
|
// option's help text).
|
|
604
|
-
await runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false);
|
|
650
|
+
await withSyncHealthReport(() => runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false));
|
|
605
651
|
return;
|
|
606
652
|
}
|
|
607
653
|
const jsonMode = options.json === true;
|
|
@@ -614,6 +660,9 @@ export function registerCloudCommands(program) {
|
|
|
614
660
|
const emitJson = (event) => {
|
|
615
661
|
process.stderr.write(JSON.stringify(event) + "\n");
|
|
616
662
|
};
|
|
663
|
+
// Assigned right before the sync engine runs, so option-validation
|
|
664
|
+
// failures never count as a sync attempt (US-003).
|
|
665
|
+
let syncHealth;
|
|
617
666
|
try {
|
|
618
667
|
if (options.personal && options.credsFromStdin) {
|
|
619
668
|
throw new Error("`--personal` cannot be combined with --creds-from-stdin: " +
|
|
@@ -708,6 +757,7 @@ export function registerCloudCommands(program) {
|
|
|
708
757
|
const scope = await resolveCliPullScope(scopeClient, targetCompany, options.hqRoot);
|
|
709
758
|
pushPrefixSet = scope?.prefixSet;
|
|
710
759
|
}
|
|
760
|
+
syncHealth = beginSyncHealthReport();
|
|
711
761
|
const result = await share({
|
|
712
762
|
paths: targetPaths,
|
|
713
763
|
company: targetCompany,
|
|
@@ -738,6 +788,7 @@ export function registerCloudCommands(program) {
|
|
|
738
788
|
}
|
|
739
789
|
if (result.aborted) {
|
|
740
790
|
log(chalk.yellow(`\n⚠ Push aborted (${result.filesUploaded} uploaded, ${result.filesSkipped} skipped)`));
|
|
791
|
+
await syncHealth.failed();
|
|
741
792
|
process.exit(1);
|
|
742
793
|
}
|
|
743
794
|
if (result.filesExcludedByScope > 0) {
|
|
@@ -751,8 +802,11 @@ export function registerCloudCommands(program) {
|
|
|
751
802
|
else {
|
|
752
803
|
log(chalk.green(`\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`));
|
|
753
804
|
}
|
|
805
|
+
await syncHealth.succeeded();
|
|
754
806
|
}
|
|
755
807
|
catch (err) {
|
|
808
|
+
if (syncHealth)
|
|
809
|
+
await syncHealth.failed();
|
|
756
810
|
const message = err instanceof Error ? err.message : String(err);
|
|
757
811
|
if (jsonMode) {
|
|
758
812
|
// In JSON mode, the parent process is parsing stderr for ndjson —
|
|
@@ -806,13 +860,14 @@ export function registerCloudCommands(program) {
|
|
|
806
860
|
// `options.personal === false` is Commander's auto-negation of
|
|
807
861
|
// `--personal`; in `--all` mode that means "drop the personal
|
|
808
862
|
// leg from the fanout" (see `--no-personal` option above).
|
|
809
|
-
await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false, options.forceScopeShrink === true);
|
|
863
|
+
await withSyncHealthReport(() => runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false, options.forceScopeShrink === true));
|
|
810
864
|
return;
|
|
811
865
|
}
|
|
812
866
|
if (options.personal) {
|
|
813
|
-
await runPullPersonal(options.hqRoot, options.onConflict);
|
|
867
|
+
await withSyncHealthReport(() => runPullPersonal(options.hqRoot, options.onConflict));
|
|
814
868
|
return;
|
|
815
869
|
}
|
|
870
|
+
let syncHealth;
|
|
816
871
|
try {
|
|
817
872
|
console.log(chalk.bold("\nHQ Sync — Pull"));
|
|
818
873
|
console.log(` HQ root: ${options.hqRoot}`);
|
|
@@ -856,6 +911,7 @@ export function registerCloudCommands(program) {
|
|
|
856
911
|
process.exit(1);
|
|
857
912
|
}
|
|
858
913
|
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
914
|
+
syncHealth = beginSyncHealthReport();
|
|
859
915
|
const result = await sync({
|
|
860
916
|
company: options.company,
|
|
861
917
|
onConflict: options.onConflict,
|
|
@@ -874,6 +930,7 @@ export function registerCloudCommands(program) {
|
|
|
874
930
|
});
|
|
875
931
|
if (result.aborted) {
|
|
876
932
|
console.log(chalk.yellow(`\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
933
|
+
await syncHealth.failed();
|
|
877
934
|
process.exit(1);
|
|
878
935
|
}
|
|
879
936
|
console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
@@ -887,8 +944,11 @@ export function registerCloudCommands(program) {
|
|
|
887
944
|
level: narrowHintLevel,
|
|
888
945
|
});
|
|
889
946
|
}
|
|
947
|
+
await syncHealth.succeeded();
|
|
890
948
|
}
|
|
891
949
|
catch (err) {
|
|
950
|
+
if (syncHealth)
|
|
951
|
+
await syncHealth.failed();
|
|
892
952
|
console.error(chalk.red("\n✗ Pull failed:"), err instanceof Error ? err.message : String(err));
|
|
893
953
|
process.exit(1);
|
|
894
954
|
}
|
|
@@ -999,10 +1059,10 @@ export function registerCloudCommands(program) {
|
|
|
999
1059
|
// of `--personal`; in `--all` mode that means "drop the
|
|
1000
1060
|
// personal leg from both legs of the bidirectional fanout"
|
|
1001
1061
|
// (see `--no-personal` option above).
|
|
1002
|
-
await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false, options.forceScopeShrink === true);
|
|
1062
|
+
await withSyncHealthReport(() => runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false, options.forceScopeShrink === true));
|
|
1003
1063
|
return;
|
|
1004
1064
|
}
|
|
1005
|
-
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true, options.forceScopeShrink === true);
|
|
1065
|
+
await withSyncHealthReport(() => runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true, options.forceScopeShrink === true));
|
|
1006
1066
|
}
|
|
1007
1067
|
catch (err) {
|
|
1008
1068
|
console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
|
|
@@ -1074,7 +1134,9 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1074
1134
|
}
|
|
1075
1135
|
catch (err) {
|
|
1076
1136
|
console.error(chalk.red("\n✗ Pull-all failed:"), err instanceof Error ? err.message : String(err));
|
|
1077
|
-
process.exit
|
|
1137
|
+
// Unwind (never process.exit) so withSyncHealthReport records the
|
|
1138
|
+
// terminal sync_failure before exiting on our behalf.
|
|
1139
|
+
throw new SyncExitError(1);
|
|
1078
1140
|
}
|
|
1079
1141
|
for (const row of result.perCompany) {
|
|
1080
1142
|
if (row.error) {
|
|
@@ -1095,9 +1157,10 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1095
1157
|
`target(s); ${result.conflicts} conflict(s); ${errored} error(s)`;
|
|
1096
1158
|
console.log(errored > 0 ? chalk.yellow(summary) : chalk.green(summary));
|
|
1097
1159
|
if (errored > 0)
|
|
1098
|
-
|
|
1160
|
+
throw new SyncExitError(1);
|
|
1099
1161
|
}
|
|
1100
|
-
|
|
1162
|
+
/** Exported for the US-003 sync-health exit-path regression test. */
|
|
1163
|
+
export async function runPullPersonal(hqRoot, onConflict) {
|
|
1101
1164
|
console.log(chalk.bold("\nHQ Sync — Pull (personal)"));
|
|
1102
1165
|
console.log(` HQ root: ${hqRoot}`);
|
|
1103
1166
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
|
|
@@ -1120,13 +1183,15 @@ async function runPullPersonal(hqRoot, onConflict) {
|
|
|
1120
1183
|
});
|
|
1121
1184
|
if (result.aborted) {
|
|
1122
1185
|
console.log(chalk.yellow(`\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
1123
|
-
|
|
1186
|
+
throw new SyncExitError(1);
|
|
1124
1187
|
}
|
|
1125
1188
|
console.log(chalk.green(`\n✓ Pulled ${result.filesDownloaded} file(s) (${formatBytes(result.bytesDownloaded)}, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
1126
1189
|
}
|
|
1127
1190
|
catch (err) {
|
|
1191
|
+
if (err instanceof SyncExitError)
|
|
1192
|
+
throw err;
|
|
1128
1193
|
console.error(chalk.red("\n✗ Pull (personal) failed:"), err instanceof Error ? err.message : String(err));
|
|
1129
|
-
|
|
1194
|
+
throw new SyncExitError(1);
|
|
1130
1195
|
}
|
|
1131
1196
|
}
|
|
1132
1197
|
async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
@@ -1192,7 +1257,9 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1192
1257
|
}
|
|
1193
1258
|
catch (err) {
|
|
1194
1259
|
console.error(chalk.red("\n✗ Push-all failed:"), err instanceof Error ? err.message : String(err));
|
|
1195
|
-
process.exit
|
|
1260
|
+
// Unwind (never process.exit) so withSyncHealthReport records the
|
|
1261
|
+
// terminal sync_failure before exiting on our behalf.
|
|
1262
|
+
throw new SyncExitError(1);
|
|
1196
1263
|
}
|
|
1197
1264
|
for (const row of result.perCompany) {
|
|
1198
1265
|
if (row.error) {
|
|
@@ -1222,7 +1289,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1222
1289
|
console.log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)));
|
|
1223
1290
|
}
|
|
1224
1291
|
if (errored > 0)
|
|
1225
|
-
|
|
1292
|
+
throw new SyncExitError(1);
|
|
1226
1293
|
}
|
|
1227
1294
|
async function runNowSingle(hqRoot, company, personal, message, onConflict, modeAllOverride, forceScopeShrink) {
|
|
1228
1295
|
console.log(chalk.bold("\nHQ Sync — Now"));
|
|
@@ -1304,7 +1371,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1304
1371
|
}
|
|
1305
1372
|
if (pushResult.aborted) {
|
|
1306
1373
|
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
1307
|
-
|
|
1374
|
+
throw new SyncExitError(1);
|
|
1308
1375
|
}
|
|
1309
1376
|
// US-011: resolve membership sync-config so we can either nudge an
|
|
1310
1377
|
// all-mode owner or refuse the pull when strict-mode is on. Skipped
|
|
@@ -1352,7 +1419,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1352
1419
|
console.error(chalk.red("\n✗ Sync now refused: strict narrow-hint mode is on and this " +
|
|
1353
1420
|
"company's local folder has grown large. Run `hq sync narrow --apply` " +
|
|
1354
1421
|
"to migrate, or re-run with --mode-all."));
|
|
1355
|
-
|
|
1422
|
+
throw new SyncExitError(1);
|
|
1356
1423
|
}
|
|
1357
1424
|
// DEV-1768: resolve the membership's real scope and thread it into the
|
|
1358
1425
|
// pull leg, so `hq sync now` stops seeding all-mode PullRecords. Personal
|
|
@@ -1392,7 +1459,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1392
1459
|
(pullResult.aborted ? " — aborted" : ""));
|
|
1393
1460
|
if (pullResult.aborted) {
|
|
1394
1461
|
console.log(chalk.yellow("\n⚠ Sync now finished with pull leg aborted."));
|
|
1395
|
-
|
|
1462
|
+
throw new SyncExitError(1);
|
|
1396
1463
|
}
|
|
1397
1464
|
// Emit the hint banner after a successful pull so it appears at the
|
|
1398
1465
|
// bottom of the summary rather than mid-stream. Size-gated: only a large
|
|
@@ -1407,8 +1474,10 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1407
1474
|
console.log(chalk.green("\n✓ Sync now complete"));
|
|
1408
1475
|
}
|
|
1409
1476
|
catch (err) {
|
|
1477
|
+
if (err instanceof SyncExitError)
|
|
1478
|
+
throw err;
|
|
1410
1479
|
console.error(chalk.red("\n✗ Sync now failed:"), err instanceof Error ? err.message : String(err));
|
|
1411
|
-
|
|
1480
|
+
throw new SyncExitError(1);
|
|
1412
1481
|
}
|
|
1413
1482
|
}
|
|
1414
1483
|
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal, forceScopeShrink) {
|
package/dist/commands/reindex.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* hq reindex — surface namespaced skills, mirror the personal overlay into
|
|
3
3
|
* core/, regenerate the workers registry, and converge HQ-owned hook trust for
|
|
4
|
-
* Codex and
|
|
4
|
+
* Codex, Grok, and Claude Code.
|
|
5
5
|
*
|
|
6
6
|
* Thin wrapper over @indigoai-us/hq-cloud's reindex(), which execs the bundled
|
|
7
7
|
* scripts/reindex.sh against the HQ root. This command is what the hq-core
|
|
@@ -493,7 +493,7 @@ export function registerReindexCommand(program) {
|
|
|
493
493
|
program
|
|
494
494
|
.command('reindex')
|
|
495
495
|
.alias('master-sync')
|
|
496
|
-
.description('Surface namespaced skills, materialize legacy knowledge repos, regenerate the workers registry, and trust HQ hooks for Codex and
|
|
496
|
+
.description('Surface namespaced skills, materialize legacy knowledge repos, regenerate the workers registry, and trust HQ hooks for Codex, Grok, and Claude Code')
|
|
497
497
|
.option('--repo-root <path>', 'HQ root to operate on (defaults to the current directory)')
|
|
498
498
|
.option('--from-hook', 'Invoked from a Claude/Codex lifecycle hook: never wait on the per-root operation lock — if a sync/rescue holds it, skip this reindex instead of blocking the session (equivalent to --lock-timeout 0)')
|
|
499
499
|
.option('--lock-timeout <seconds>', 'Bound the wait for the per-root operation lock (seconds). 0 = refuse immediately; omitted = wait indefinitely (the interactive default). --from-hook implies 0; an explicit --lock-timeout wins.')
|
|
@@ -518,13 +518,23 @@ export function registerReindexCommand(program) {
|
|
|
518
518
|
if (lockTimeoutSec !== undefined && process.env.HQ_OP_LOCK_TIMEOUT === undefined) {
|
|
519
519
|
process.env.HQ_OP_LOCK_TIMEOUT = String(lockTimeoutSec);
|
|
520
520
|
}
|
|
521
|
-
|
|
521
|
+
// Keep the pre-realpath spelling: `resolveHqRoot` canonicalizes, and
|
|
522
|
+
// Claude keys its trusted projects by session cwd, so a user who lives
|
|
523
|
+
// in a symlinked HQ root would otherwise never get that spelling trusted.
|
|
524
|
+
const literalHqRoot = path.resolve(opts.repoRoot ?? findHqRoot());
|
|
525
|
+
const hqRoot = resolveHqRoot(literalHqRoot);
|
|
522
526
|
const sweepStartedAt = Date.now();
|
|
523
527
|
removeStaleHqWorktrees(hqRoot, sweepStartedAt, opts.fromHook ? sweepStartedAt + WORKTREE_HOOK_SWEEP_BUDGET_MS : Number.POSITIVE_INFINITY);
|
|
524
528
|
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
525
529
|
repairExtremeHookDrift(hqRoot, status === 0);
|
|
526
|
-
if (status === 0)
|
|
527
|
-
await trustHqRuntimeHooks(hqRoot
|
|
530
|
+
if (status === 0) {
|
|
531
|
+
await trustHqRuntimeHooks(hqRoot, undefined, {
|
|
532
|
+
rootAliases: [literalHqRoot],
|
|
533
|
+
// A lifecycle hook must never block the agent waiting on another HQ
|
|
534
|
+
// process's config write; trust converges on the next reindex.
|
|
535
|
+
lockWaitMs: opts.fromHook ? 0 : undefined,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
528
538
|
// Runs even when reindex failed: a failed reindex does not make an
|
|
529
539
|
// oversized blob any less likely to wedge the next push.
|
|
530
540
|
reportLargeFileGuard(hqRoot);
|
package/dist/commands/secrets.js
CHANGED
|
@@ -4,7 +4,7 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { writeSync } from "node:fs";
|
|
5
5
|
import * as nodePath from "node:path";
|
|
6
6
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
7
|
-
import { DEFAULT_SECRETS_CACHE_TTL_MS, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
7
|
+
import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
8
8
|
import { computeSha256 } from "../utils/integrity.js";
|
|
9
9
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
10
10
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
@@ -545,7 +545,12 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
545
545
|
}
|
|
546
546
|
const resolved = new Map();
|
|
547
547
|
const requested = [...new Set(keys)];
|
|
548
|
+
const cachedCandidates = new Set(requested.filter((name) => hasCacheEntry(companyUid, name)));
|
|
548
549
|
try {
|
|
550
|
+
// The batch endpoint is the only non-capturing authorization path that also
|
|
551
|
+
// evaluates the supplied usage metadata. It covers warm and cold names in
|
|
552
|
+
// bounded requests, then a warm name may use its locally authenticated
|
|
553
|
+
// plaintext only when the returned version and current policy TTL agree.
|
|
549
554
|
for (let i = 0; i < requested.length; i += MAX_BATCH_NAMES) {
|
|
550
555
|
const chunk = requested.slice(i, i + MAX_BATCH_NAMES);
|
|
551
556
|
const chunkNames = new Set(chunk);
|
|
@@ -572,6 +577,9 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
572
577
|
typeof body.code === "string") {
|
|
573
578
|
throw new Error(message);
|
|
574
579
|
}
|
|
580
|
+
if (res.status === 403) {
|
|
581
|
+
throw new Error(`Failed to fetch secret '${chunk[0]}': No read permission`);
|
|
582
|
+
}
|
|
575
583
|
throw new Error(`Failed to batch-load secrets: ${message}`);
|
|
576
584
|
}
|
|
577
585
|
const data = (await res.json());
|
|
@@ -601,8 +609,26 @@ export async function loadRevealedSecrets(token, companyUid, keys, usage) {
|
|
|
601
609
|
continue;
|
|
602
610
|
}
|
|
603
611
|
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
612
|
+
const cached = cachedCandidates.has(s.name) &&
|
|
613
|
+
cacheTtlMs > 0 &&
|
|
614
|
+
typeof s.version === "number" &&
|
|
615
|
+
Number.isSafeInteger(s.version) &&
|
|
616
|
+
s.version > 0
|
|
617
|
+
? readCache(companyUid, s.name, s.version, cacheTtlMs)
|
|
618
|
+
: null;
|
|
619
|
+
if (cached !== null) {
|
|
620
|
+
resolved.set(s.name, cached);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
604
623
|
if (cacheTtlMs > 0) {
|
|
605
|
-
|
|
624
|
+
if (typeof s.version === "number" &&
|
|
625
|
+
Number.isSafeInteger(s.version) &&
|
|
626
|
+
s.version > 0) {
|
|
627
|
+
writeCache(companyUid, s.name, s.value, cacheTtlMs, s.version);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
writeCache(companyUid, s.name, s.value, cacheTtlMs);
|
|
631
|
+
}
|
|
606
632
|
}
|
|
607
633
|
else {
|
|
608
634
|
removeCacheEntry(companyUid, s.name);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versions & sync health — the doctor family the registry docblock always
|
|
3
|
+
* anticipated (client-sync-health-control-plane US-015).
|
|
4
|
+
*
|
|
5
|
+
* Reports, per install:
|
|
6
|
+
* - the CLI / Core / desktop (hq-sync menubar) component versions, from the
|
|
7
|
+
* same collectors the feedback and client-health paths use (US-003), so
|
|
8
|
+
* `hq doctor --json` shows exactly what a heartbeat would report;
|
|
9
|
+
* - whether a newer hq-core release is known to be available — read OFFLINE
|
|
10
|
+
* from the cache stamped by the `check-hq-update` SessionStart hook
|
|
11
|
+
* (`workspace/.hq-update-check/last-check.json`), never from the network,
|
|
12
|
+
* preserving the doctor's offline contract (no cache → UNTESTED, not PASS);
|
|
13
|
+
* - per-company sync journal staleness via the engine's `listJournals()` —
|
|
14
|
+
* the ONLY correct enumeration of per-scope journal shards
|
|
15
|
+
* (single-path reconstruction regressed before: feedback_9fbf1f82 /
|
|
16
|
+
* feedback_46288b7b).
|
|
17
|
+
*
|
|
18
|
+
* Caution paid for in blood (bridge-health false positives): a machine with no
|
|
19
|
+
* journals at all is NA, not WARN — CLI-only installs never sync locally and
|
|
20
|
+
* must not read as degraded. Staleness warns only on a corroborated signal: a
|
|
21
|
+
* journal that EXISTS and carries a parseable, old `lastSync`.
|
|
22
|
+
*
|
|
23
|
+
* Every dependency is injectable so the family is unit-testable without an HQ
|
|
24
|
+
* tree, a state dir, or the wall clock.
|
|
25
|
+
*/
|
|
26
|
+
import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
|
|
27
|
+
/** The id of the versions/sync family. */
|
|
28
|
+
export declare const SYNC_FAMILY_ID = "sync";
|
|
29
|
+
/** Human title for grouped output. */
|
|
30
|
+
export declare const SYNC_FAMILY_TITLE = "Versions & sync";
|
|
31
|
+
/**
|
|
32
|
+
* A journal shard older than this is reported stale. Seven days: long enough
|
|
33
|
+
* that a laptop shut over a weekend never warns, short enough that a silently
|
|
34
|
+
* dead sync runner surfaces well before data divergence becomes painful.
|
|
35
|
+
*/
|
|
36
|
+
export declare const STALE_JOURNAL_THRESHOLD_MS: number;
|
|
37
|
+
/** The offline update cache written by the check-hq-update SessionStart hook. */
|
|
38
|
+
export declare const UPDATE_CACHE_RELPATH: string;
|
|
39
|
+
/** The minimal journal shape this family reads. Matches `JournalSummary`. */
|
|
40
|
+
export interface SyncJournalSummary {
|
|
41
|
+
slug: string;
|
|
42
|
+
path: string;
|
|
43
|
+
journal: {
|
|
44
|
+
lastSync?: string | null;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The component versions this family reports. */
|
|
48
|
+
export interface SyncVersionInfo {
|
|
49
|
+
cli: string;
|
|
50
|
+
core: string | null;
|
|
51
|
+
desktop: string | null;
|
|
52
|
+
}
|
|
53
|
+
/** Injectable dependencies — defaults are the real collectors. */
|
|
54
|
+
export interface SyncHealthDeps {
|
|
55
|
+
versions: (hqRoot: string) => SyncVersionInfo;
|
|
56
|
+
journals: () => readonly SyncJournalSummary[];
|
|
57
|
+
now: () => Date;
|
|
58
|
+
}
|
|
59
|
+
/** The versions/sync check family. Registered in `createDefaultRegistry`. */
|
|
60
|
+
export declare const syncHealthFamily: CheckFamily;
|
|
61
|
+
/** Run every versions/sync check. A thrown check degrades to UNKNOWN. */
|
|
62
|
+
export declare function checkSyncHealth(context: CheckContext, deps?: SyncHealthDeps): CheckResult[];
|
|
63
|
+
/** True when `a` > `b` for plain X.Y.Z versions. Non-numeric parts compare 0. */
|
|
64
|
+
export declare function semverGt(a: string, b: string): boolean;
|
|
65
|
+
//# sourceMappingURL=sync-health.d.ts.map
|