@indigoai-us/hq-cli 5.212.0 → 5.214.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 CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.214.0] — 2026-09-26
6
+
7
+ ### Added
8
+
9
+ - `hq doctor` now checks the last hour of the sync runner log for stuck
10
+ realtime lag, cloud permission refusals, runner restart loops, device-limit
11
+ backoff, and repeated conflicts. Client health reports send these to Sentry
12
+ as reason codes and counts only.
13
+
14
+ ### Fixed
15
+
16
+ - The desktop app's first sync push now uploads files inside private folders.
17
+ The credentials the app hands to `hq sync push --creds-from-stdin` cannot
18
+ write there directly, so the push also passes the vault config to hq-cloud
19
+ 6.18.5, which sends those files through presigned uploads.
20
+
21
+ ## [5.213.0] — 2026-09-26
22
+
23
+ ### Fixed
24
+
25
+ - `hq lanes accounts set-home` and `login` return
26
+ `company_selection_failed` for company-selection errors without a stack
27
+ trace. Other resolver failures remain `internal` and include a stack trace.
28
+
5
29
  ## [5.212.0] — 2026-09-26
6
30
 
7
31
  ### Changed
@@ -13,7 +13,7 @@
13
13
  * hq sync status — show local journal summary
14
14
  */
15
15
  import { Command } from "commander";
16
- import { type ConflictStrategy, type MembershipSyncConfig, type SyncMode, type PullScope, type ExplicitGrant } from "@indigoai-us/hq-cloud";
16
+ import { type ConflictStrategy, type EntityContext, type MembershipSyncConfig, type SyncMode, type PullScope, type ExplicitGrant, type ShareOptions, type VaultServiceConfig } from "@indigoai-us/hq-cloud";
17
17
  import { type BannerLevel } from "../lib/narrow-hint-banner.js";
18
18
  /**
19
19
  * Terminal-failure signal for the sync fanout runners. The runners
@@ -264,6 +264,18 @@ export declare function pullAll(options: PullAllOptions, deps: PullAllDeps): Pro
264
264
  * deps so tests can drive it without network or filesystem.
265
265
  */
266
266
  export declare function pushAll(options: PushAllOptions, deps: PushAllDeps): Promise<PushAllResult>;
267
+ /**
268
+ * Credentials `hq sync push` hands to share(). A pre-vended EntityContext
269
+ * (AppBar's `--creds-from-stdin` first push) cannot write under private
270
+ * folders directly, so it travels with the caller's vault config as
271
+ * `privateFolderVaultConfig`. share() takes exactly one of `vaultConfig` /
272
+ * `entityContext`.
273
+ */
274
+ export declare function pushShareCredentials(input: {
275
+ entityContext?: EntityContext;
276
+ vaultConfig?: VaultServiceConfig;
277
+ scopeVaultConfig?: VaultServiceConfig;
278
+ }): Pick<ShareOptions, "entityContext" | "vaultConfig" | "privateFolderVaultConfig">;
267
279
  /**
268
280
  * Resolve the canonical person entity UID for the logged-in user. Used by
269
281
  * `hq sync {push,pull,now} --personal` to target the personal vault without
@@ -455,6 +455,24 @@ export async function pushAll(options, deps) {
455
455
  }
456
456
  return result;
457
457
  }
458
+ /**
459
+ * Credentials `hq sync push` hands to share(). A pre-vended EntityContext
460
+ * (AppBar's `--creds-from-stdin` first push) cannot write under private
461
+ * folders directly, so it travels with the caller's vault config as
462
+ * `privateFolderVaultConfig`. share() takes exactly one of `vaultConfig` /
463
+ * `entityContext`.
464
+ */
465
+ export function pushShareCredentials(input) {
466
+ if (input.entityContext) {
467
+ return {
468
+ entityContext: input.entityContext,
469
+ ...(input.scopeVaultConfig
470
+ ? { privateFolderVaultConfig: input.scopeVaultConfig }
471
+ : {}),
472
+ };
473
+ }
474
+ return input.vaultConfig ? { vaultConfig: input.vaultConfig } : {};
475
+ }
458
476
  /**
459
477
  * Resolve the canonical person entity UID for the logged-in user. Used by
460
478
  * `hq sync {push,pull,now} --personal` to target the personal vault without
@@ -715,7 +733,7 @@ export function registerCloudCommands(program) {
715
733
  // 2. default: vend via cached Cognito session (the human CLI path).
716
734
  let entityContext;
717
735
  let vaultConfig;
718
- /** Vault API config for membership/grant scope lookups. Set even on the pre-vended `--creds-from-stdin` path — share() accepts entityContext OR vaultConfig, not both. */
736
+ /** Vault API config for membership/grant scope lookups and, on the pre-vended `--creds-from-stdin` path, private-folder uploads. */
719
737
  let scopeVaultConfig;
720
738
  if (options.credsFromStdin) {
721
739
  if (process.stdin.isTTY) {
@@ -798,8 +816,11 @@ export function registerCloudCommands(program) {
798
816
  company: targetCompany,
799
817
  message: options.message,
800
818
  onConflict: options.onConflict,
801
- vaultConfig,
802
- entityContext,
819
+ ...pushShareCredentials({
820
+ entityContext,
821
+ vaultConfig,
822
+ scopeVaultConfig,
823
+ }),
803
824
  hqRoot: options.hqRoot,
804
825
  onEvent,
805
826
  ...(personalMode ? { personalMode: true } : {}),
@@ -121,15 +121,18 @@ function printJson(value) {
121
121
  console.log(JSON.stringify(withoutProviderHome(value), null, 2));
122
122
  }
123
123
  function failJson(err) {
124
- if (isLanesError(err)) {
125
- if (err.error === "hire_grant_exhausted") {
126
- process.stderr.write(`hq lanes: ${err.message}\n`);
127
- }
128
- printJson({ ...err.toJSON(), message: err.message });
124
+ const commandError = err instanceof CompanySelectionError
125
+ ? new LanesError("company_selection_failed", err.message)
126
+ : err;
127
+ if (isLanesError(commandError)) {
128
+ if (commandError.error === "hire_grant_exhausted") {
129
+ process.stderr.write(`hq lanes: ${commandError.message}\n`);
130
+ }
131
+ printJson({ ...commandError.toJSON(), message: commandError.message });
129
132
  process.exit(1);
130
133
  }
131
- const message = err instanceof Error ? err.message : String(err);
132
- process.stderr.write(`hq lanes: ${message}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
134
+ const message = commandError instanceof Error ? commandError.message : String(commandError);
135
+ process.stderr.write(`hq lanes: ${message}${commandError instanceof Error && commandError.stack ? `\n${commandError.stack}` : ""}\n`);
133
136
  printJson({ error: "internal", message });
134
137
  process.exit(1);
135
138
  }
@@ -64,6 +64,8 @@ export interface SyncManifestUploadStatus {
64
64
  }
65
65
  /** Injectable dependencies — defaults are the real collectors. */
66
66
  export interface SyncHealthDeps {
67
+ /** Runner log and conflict signals; omitted in unit tests of other checks. */
68
+ runtime?: (now: Date) => CheckResult[];
67
69
  versions: (hqRoot: string) => SyncVersionInfo;
68
70
  journals: () => readonly SyncJournalSummary[];
69
71
  now: () => Date;
@@ -23,6 +23,7 @@
23
23
  * Every dependency is injectable so the family is unit-testable without an HQ
24
24
  * tree, a state dir, or the wall clock.
25
25
  */
26
+ import { defaultSyncRuntimeResults } from "./sync-runtime.js";
26
27
  import * as fs from "node:fs";
27
28
  import * as path from "node:path";
28
29
  // Namespace import, deliberately: hq-cloud is an ESM package, so a static
@@ -50,6 +51,7 @@ export const UPDATE_CACHE_RELPATH = path.join("workspace", ".hq-update-check", "
50
51
  function withDefaults(deps, hqRoot) {
51
52
  return {
52
53
  ...deps,
54
+ runtime: deps.runtime ?? (() => []),
53
55
  manifestStatuses: deps.manifestStatuses ?? defaultManifestStatuses,
54
56
  manifestAvailable: deps.manifestAvailable ?? (() => loadManifestExports() !== null),
55
57
  unresolvedScopes: deps.unresolvedScopes ?? defaultUnresolvedScopes,
@@ -118,6 +120,7 @@ const DEFAULT_DEPS = {
118
120
  journals: () => hqCloud.listJournals(),
119
121
  now: () => new Date(),
120
122
  manifestStatuses: defaultManifestStatuses,
123
+ runtime: defaultSyncRuntimeResults,
121
124
  };
122
125
  /**
123
126
  * Map the machine's journal shards onto the manifest scopes hq-cloud keys its
@@ -190,6 +193,7 @@ export function checkSyncHealth(context, rawDeps = DEFAULT_DEPS) {
190
193
  ...versionResults(context, deps),
191
194
  ...updateAvailabilityResult(context, deps),
192
195
  ...journalAndManifestResults(deps),
196
+ ...deps.runtime(deps.now()),
193
197
  ];
194
198
  }
195
199
  catch (error) {
@@ -0,0 +1,11 @@
1
+ import type { CheckResult } from "../types.js";
2
+ export declare const SYNC_RUNTIME_WINDOW_MS: number;
3
+ export interface SyncRuntimeOptions {
4
+ now: number;
5
+ conflictCount?: number;
6
+ }
7
+ /** Classify the last hour of sync log lines into privacy-safe results. */
8
+ export declare function analyzeSyncRuntimeLog(lines: readonly string[], options: SyncRuntimeOptions): CheckResult[];
9
+ /** Read the machine's sync logs and client health file. */
10
+ export declare function defaultSyncRuntimeResults(now: Date, hqHome?: string): CheckResult[];
11
+ //# sourceMappingURL=sync-runtime.d.ts.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Sync runtime checks: problems visible only in the running sync runner's log
3
+ * (realtime lag, receiver permission errors, restart loops, device-limit
4
+ * backoff) plus the conflict counter the desktop app keeps in client health.
5
+ *
6
+ * Results carry only a closed reason code and counts. Log content (paths,
7
+ * tenant ids, AWS error prose) never leaves this module.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ export const SYNC_RUNTIME_WINDOW_MS = 60 * 60_000;
13
+ const LAG_SECONDS = 15 * 60;
14
+ const LAG_MIN_RECEIPTS = 3;
15
+ const RESTART_MIN_STARTS = 4;
16
+ const CONFLICT_MIN_COUNT = 5;
17
+ const LOG_TAIL_BYTES = 8 * 1024 * 1024;
18
+ const TIMESTAMP = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s/;
19
+ const PERMISSION = /AccessDenied|not authorized to perform|\b403\b/;
20
+ const DEVICE_LIMIT = /sync device limit exceeded|"sync_device_limit"/;
21
+ function parseRecord(rest) {
22
+ const start = rest.indexOf("{");
23
+ if (start < 0)
24
+ return null;
25
+ try {
26
+ const value = JSON.parse(rest.slice(start));
27
+ return value !== null && typeof value === "object" && !Array.isArray(value)
28
+ ? value
29
+ : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** Classify the last hour of sync log lines into privacy-safe results. */
36
+ export function analyzeSyncRuntimeLog(lines, options) {
37
+ const since = options.now - SYNC_RUNTIME_WINDOW_MS;
38
+ let slowReceipts = 0;
39
+ let denied = 0;
40
+ let starts = 0;
41
+ let deviceLimited = 0;
42
+ for (const line of lines) {
43
+ const stamp = TIMESTAMP.exec(line);
44
+ if (!stamp)
45
+ continue;
46
+ const time = Date.parse(stamp[1]);
47
+ if (!Number.isFinite(time) || time < since || time > options.now)
48
+ continue;
49
+ const rest = line.slice(stamp[0].length);
50
+ if (DEVICE_LIMIT.test(rest))
51
+ deviceLimited += 1;
52
+ const record = parseRecord(rest);
53
+ if (!record)
54
+ continue;
55
+ const event = record.event;
56
+ if (event === "fanout.receive") {
57
+ const latency = record.latencySeconds;
58
+ if (typeof latency === "number" && latency > LAG_SECONDS)
59
+ slowReceipts += 1;
60
+ }
61
+ else if (event === "receiver.start") {
62
+ starts += 1;
63
+ }
64
+ if ((record.type === "receiver" || record.type === "error") &&
65
+ PERMISSION.test(JSON.stringify(record.err ?? record.message ?? ""))) {
66
+ denied += 1;
67
+ }
68
+ }
69
+ const conflicts = options.conflictCount ?? 0;
70
+ const checks = [
71
+ { id: "realtime-lag", reasonCode: "realtime-lag-stuck", failing: slowReceipts >= LAG_MIN_RECEIPTS, count: slowReceipts, label: "incoming changes delayed over 15 minutes" },
72
+ { id: "receiver-permission", reasonCode: "receiver-permission-denied", failing: denied > 0, count: denied, label: "cloud permission refusals" },
73
+ { id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "sync runner starts" },
74
+ { id: "device-limit", reasonCode: "device-limit-backoff", failing: deviceLimited > 0, count: deviceLimited, label: "device-limit backoffs" },
75
+ { id: "conflicts", reasonCode: "repeated-conflicts", failing: conflicts >= CONFLICT_MIN_COUNT, count: conflicts, label: "unresolved conflicts" },
76
+ ];
77
+ return checks.map((check) => check.failing
78
+ ? {
79
+ status: "WARN",
80
+ checkId: `sync.runtime.${check.id}`,
81
+ reasonCode: check.reasonCode,
82
+ message: `${check.count} ${check.label} in the last hour.`,
83
+ remediation: "Run `hq sync status` and check ~/.hq/logs/hq-sync.log.",
84
+ }
85
+ : {
86
+ status: "PASS",
87
+ checkId: `sync.runtime.${check.id}`,
88
+ message: `${check.count} ${check.label} in the last hour.`,
89
+ });
90
+ }
91
+ const LOG_GENERATIONS = 4;
92
+ function readLogTail(file) {
93
+ const fd = fs.openSync(file, "r");
94
+ try {
95
+ const size = fs.fstatSync(fd).size;
96
+ const length = Math.min(size, LOG_TAIL_BYTES);
97
+ const buffer = Buffer.alloc(length);
98
+ fs.readSync(fd, buffer, 0, length, size - length);
99
+ return buffer.toString("utf8").split("\n");
100
+ }
101
+ finally {
102
+ fs.closeSync(fd);
103
+ }
104
+ }
105
+ function firstTimestamp(lines) {
106
+ for (const line of lines) {
107
+ const stamp = TIMESTAMP.exec(line);
108
+ if (stamp)
109
+ return Date.parse(stamp[1]);
110
+ }
111
+ return null;
112
+ }
113
+ /**
114
+ * Lines from the active log and as many rotated generations as needed to
115
+ * cover the window. Throws when a present log cannot be read.
116
+ */
117
+ function readWindowLines(logFile, since) {
118
+ const generations = [];
119
+ for (let n = 0; n < LOG_GENERATIONS; n += 1) {
120
+ const file = n === 0 ? logFile : `${logFile}.${n}`;
121
+ if (!fs.existsSync(file))
122
+ break;
123
+ const lines = readLogTail(file);
124
+ generations.unshift(lines);
125
+ const first = firstTimestamp(lines);
126
+ if (first !== null && first <= since)
127
+ break;
128
+ }
129
+ return generations.length > 0 ? generations.flat() : null;
130
+ }
131
+ function readConflictCount(file) {
132
+ try {
133
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
134
+ const count = value?.conflictCount;
135
+ return typeof count === "number" && Number.isFinite(count) ? count : 0;
136
+ }
137
+ catch {
138
+ return 0;
139
+ }
140
+ }
141
+ /** Read the machine's sync logs and client health file. */
142
+ export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(), ".hq")) {
143
+ const options = {
144
+ now: now.getTime(),
145
+ conflictCount: readConflictCount(path.join(hqHome, "client-health.json")),
146
+ };
147
+ const logFile = path.join(hqHome, "logs", "hq-sync.log");
148
+ let lines;
149
+ try {
150
+ lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS);
151
+ }
152
+ catch {
153
+ return [
154
+ { status: "UNKNOWN", checkId: "sync.runtime.log", message: "The sync runner log could not be read." },
155
+ ...analyzeSyncRuntimeLog([], options).filter((r) => r.checkId === "sync.runtime.conflicts"),
156
+ ];
157
+ }
158
+ if (lines === null) {
159
+ return [
160
+ { status: "NA", checkId: "sync.runtime.log", message: "No sync runner log on this machine." },
161
+ ...analyzeSyncRuntimeLog([], options).filter((r) => r.checkId === "sync.runtime.conflicts"),
162
+ ];
163
+ }
164
+ return analyzeSyncRuntimeLog(lines, options);
165
+ }
166
+ //# sourceMappingURL=sync-runtime.js.map
@@ -5,7 +5,7 @@ export declare const CLIENT_HEALTH_INSTALL_ID_FILENAME = "install-id";
5
5
  export declare const CLIENT_HEALTH_LAST_RESULT_FILENAME = "last-result.json";
6
6
  export type ClientHealthFixOutcome = "not_auto_fixable" | "fix_failed" | "fix_timed_out";
7
7
  export type ClientHealthStatus = "FAIL" | "WARN";
8
- export type ClientHealthCheckClass = "sync.update.core" | "sync.journal.personal" | "sync.journal.personal_vault" | "sync.journal.company" | "sync.manifest.personal" | "sync.manifest.personal_vault" | "sync.manifest.company" | "other";
8
+ export type ClientHealthCheckClass = "sync.update.core" | "sync.journal.personal" | "sync.journal.personal_vault" | "sync.journal.company" | "sync.manifest.personal" | "sync.manifest.personal_vault" | "sync.manifest.company" | "sync.runtime" | "other";
9
9
  export interface ClientHealthSentryEvent {
10
10
  message: string;
11
11
  level: "warning";
@@ -24,6 +24,11 @@ const REASON_CODES = new Set([
24
24
  "never-uploaded",
25
25
  "invalid-upload-time",
26
26
  "unresolved-company-uid",
27
+ "realtime-lag-stuck",
28
+ "receiver-permission-denied",
29
+ "runner-restart-loop",
30
+ "device-limit-backoff",
31
+ "repeated-conflicts",
27
32
  ]);
28
33
  const FIX_OUTCOMES = new Set(["not_auto_fixable", "fix_failed", "fix_timed_out"]);
29
34
  const INSTALL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -52,6 +57,8 @@ function cleanArchitecture(value) {
52
57
  export function normalizeClientHealthCheckId(checkId) {
53
58
  if (checkId === "sync.update.core")
54
59
  return checkId;
60
+ if (checkId === "sync.runtime" || checkId.startsWith("sync.runtime."))
61
+ return "sync.runtime";
55
62
  if (checkId === "sync.journal.__hq_personal_vault__")
56
63
  return "sync.journal.personal_vault";
57
64
  if (checkId === "sync.manifest.__hq_personal_vault__")
@@ -19,7 +19,7 @@ export declare const DOCTOR_STATUSES: readonly ["PASS", "FAIL", "WARN", "UNTESTE
19
19
  */
20
20
  export type DoctorStatus = (typeof DOCTOR_STATUSES)[number];
21
21
  /** Stable, privacy-safe diagnostics emitted by the sync health family. */
22
- export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "never-synced", "invalid-last-sync", "stale-threshold", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid"];
22
+ export declare const SYNC_REASON_CODES: readonly ["core-version-unavailable", "core-update-available", "no-local-tree", "never-synced", "invalid-last-sync", "stale-threshold", "manifest-unavailable", "manifest-state-unavailable", "no-manifest-scopes", "never-uploaded", "invalid-upload-time", "unresolved-company-uid", "realtime-lag-stuck", "receiver-permission-denied", "runner-restart-loop", "device-limit-backoff", "repeated-conflicts"];
23
23
  export type SyncReasonCode = (typeof SYNC_REASON_CODES)[number];
24
24
  /** Runtime type guard: true only for a member of the fixed vocabulary. */
25
25
  export declare function isDoctorStatus(value: unknown): value is DoctorStatus;
@@ -35,6 +35,11 @@ export const SYNC_REASON_CODES = [
35
35
  "never-uploaded",
36
36
  "invalid-upload-time",
37
37
  "unresolved-company-uid",
38
+ "realtime-lag-stuck",
39
+ "receiver-permission-denied",
40
+ "runner-restart-loop",
41
+ "device-limit-backoff",
42
+ "repeated-conflicts",
38
43
  ];
39
44
  /** Runtime type guard: true only for a member of the fixed vocabulary. */
40
45
  export function isDoctorStatus(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.212.0",
3
+ "version": "5.214.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  "dependencies": {
42
42
  "@aws-sdk/client-iot-data-plane": "^3.1096.0",
43
43
  "@aws-sdk/client-s3": "^3.1049.0",
44
- "@indigoai-us/hq-cloud": "~6.16.74",
44
+ "@indigoai-us/hq-cloud": "~6.18.5",
45
45
  "@indigoai-us/hq-flags-client": "^0.1.2",
46
46
  "@sentry/node": "^10.49.0",
47
47
  "@tobilu/qmd": "2.5.3",