@indigoai-us/hq-cli 5.103.31 → 5.103.32

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,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.32] — 2026-08-29
6
+
5
7
  ## [5.103.31] — 2026-08-29
6
8
 
7
9
  ## [5.103.30] — 2026-08-29
@@ -68,7 +68,13 @@ export function readActiveCompanySlug(hqRoot) {
68
68
  export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
69
69
  const slug = flag ?? readActiveCompanySlug(hqRoot);
70
70
  if (!slug) {
71
- throw new Error("No company specified. Pass --company <slug> (or set an active company in .hq/config.json).");
71
+ // HQ-CLI-13 (Sentry 7695157352): neither `--company` nor an active company
72
+ // in `.hq/config.json` — ordinary caller state with a self-describing
73
+ // remedy, not an hq-cli defect. Mark it `expected` (via localSkillError) so
74
+ // the top-level boundary prints this line and skips Sentry, exactly as the
75
+ // resolveSkillUid local conditions do (HQ-CLI-10/11). The text is unchanged
76
+ // and already tells the caller what to do.
77
+ throw localSkillError("No company specified. Pass --company <slug> (or set an active company in .hq/config.json).");
72
78
  }
73
79
  return slug;
74
80
  }
@@ -354,10 +360,26 @@ export function registerSkillCommand(program, deps = {}) {
354
360
  });
355
361
  }
356
362
  catch (err) {
357
- throw new Error(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed: ${err instanceof Error ? err.message : String(err)}`);
363
+ // HQ-CLI-14 (Sentry 7695715459): registration and the local stamp
364
+ // already SUCCEEDED; only the upload failed. Do NOT flatten the
365
+ // inner error into a new generic Error — that discarded its class,
366
+ // `name`, `cause` and any `expected` marker, and spliced the
367
+ // caller's absolute SKILL.md path into a message Sentry then
368
+ // fingerprinted per home directory. Keep the partial-success context
369
+ // on stderr, then rethrow the ORIGINAL error so every classifier the
370
+ // boundary already has (expected, AuthError, plan-gate, EPIPE,
371
+ // environmental-fs, sync-state-lock, network-transport) applies to a
372
+ // sync failure exactly as it does everywhere else, and a genuine
373
+ // fault is captured under its own type and stack.
374
+ console.warn(chalk.yellow(`⚠ Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed.`));
375
+ throw err;
358
376
  }
359
377
  if (syncResult.aborted) {
360
- throw new Error(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
378
+ // A remote-file conflict under hq-cli's own `onConflict: "abort"`
379
+ // choice is unambiguous caller state with a user-side remedy, not an
380
+ // hq-cli defect — mark it `expected` so it is printed and skipped for
381
+ // Sentry rather than filing a crash on its first occurrence.
382
+ throw localSkillError(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
361
383
  }
362
384
  }
363
385
  console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
@@ -128,6 +128,21 @@ function resultForGroup(entries, company) {
128
128
  remediation: serverRemediation ?? `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
129
129
  };
130
130
  }
131
+ if (first.kind === "re-add-with-auth") {
132
+ // Deliberately NOT the `re-add` branch. That one's default remediation is
133
+ // the API-key flow (`--token-stdin`), and this fault is the opposite case:
134
+ // an install that was created with no credential at all and needs a
135
+ // browser sign-in. Falling through to the reconnect default would be worse
136
+ // still, since there is no stored credential to refresh.
137
+ return {
138
+ status: "FAIL",
139
+ checkId: `${INTEGRATIONS_PREFIX}.re-add-with-auth.${first.provider}`,
140
+ target: namedConnections,
141
+ message: `${first.provider}: ${count} ${plural} ${first.message}.`,
142
+ remediation: serverRemediation
143
+ ?? `Remove the integration and add it again, signing in this time: hq integrations connect ${first.provider}${company ? ` --company ${company}` : ""}`,
144
+ };
145
+ }
131
146
  if (first.kind === "contact-admin") {
132
147
  return {
133
148
  status: "FAIL",
@@ -39,8 +39,18 @@ export interface IntegrationConnection extends AdminConnection {
39
39
  *
40
40
  * A server that DID say `fix_kind: "reconnect"` still classifies as
41
41
  * `reconnect`: that is a diagnosis, not an absence of one.
42
+ *
43
+ * `re-add-with-auth` is NOT a synonym for `re-add`. `re-add` means an API key
44
+ * has to be typed in again; this one means the install was created with no
45
+ * credential at all and needs a browser sign-in. Collapsing the two would print
46
+ * the API-key command against a connection that never had a key.
47
+ *
48
+ * This union is kept identical to the console's twin (`_connection-health.ts`
49
+ * `FixKind`) on purpose: a person who reads one surface and then the other must
50
+ * not find the same failure sorted into two different buckets. Any member added
51
+ * here has to be added there in the same change.
42
52
  */
43
- export type FindingKind = "reconnect" | "re-add" | "contact-admin" | "wait" | "provider-blocked" | "retryable" | "hq-configuration" | "undiagnosed";
53
+ export type FindingKind = "reconnect" | "re-add" | "re-add-with-auth" | "contact-admin" | "wait" | "provider-blocked" | "retryable" | "hq-configuration" | "undiagnosed";
44
54
  export interface Finding {
45
55
  provider: string;
46
56
  connectionId: string;
@@ -19,6 +19,12 @@ import { bareProvider } from "./provider-slug.js";
19
19
  const RECONNECT_REASON_CODES = new Set([
20
20
  "oauth_refresh_invalid_grant",
21
21
  "oauth_refresh_unavailable",
22
+ // The stored grant carries no refresh token, so nothing can be renewed
23
+ // without the person signing in again. The legacy text heuristic below
24
+ // happens to match this string through its `refresh[\s_-]*token` branch, but
25
+ // that is coincidence rather than contract: it is listed here so the
26
+ // classification survives a rename of the heuristic.
27
+ "oauth_refresh_missing_refresh_token",
22
28
  "token_refresh_failed",
23
29
  "credentials_rejected",
24
30
  ]);
@@ -26,7 +32,27 @@ const RETRYABLE_REASON_CODES = new Set([
26
32
  "oauth_refresh_transient",
27
33
  "oauth_refresh_write_conflict",
28
34
  ]);
29
- const HQ_CONFIGURATION_REASON_CODE = "oauth_client_secret_unavailable";
35
+ /**
36
+ * Faults that sit on HQ's side of the boundary.
37
+ *
38
+ * Neither is repairable by anyone at the customer: `oauth_client_secret_unavailable`
39
+ * means HQ could not read its own client secret, and `oauth_refresh_missing_client`
40
+ * means HQ has no OAuth client configured for the provider at all. Sending a
41
+ * person round a reconnect for either costs them a working credential and
42
+ * cannot succeed, which is why hq-pro answers both with `contact-support`
43
+ * ahead of its own role gate — a member sees the same instruction an owner does.
44
+ */
45
+ const HQ_CONFIGURATION_REASON_CODES = new Set([
46
+ "oauth_client_secret_unavailable",
47
+ "oauth_refresh_missing_client",
48
+ ]);
49
+ /**
50
+ * The install exists but was created without credentials, and the provider
51
+ * refused it. The repair is to add the integration again and sign in — NOT the
52
+ * `re-add` API-key flow, and not a reconnect of a credential that was never
53
+ * stored.
54
+ */
55
+ const RE_ADD_WITH_AUTH_REASON_CODE = "unauthenticated_install_rejected";
30
56
  const UNSPECIFIED_REASON_CODE = "unspecified";
31
57
  /** Classify without echoing untrusted provider text, which may contain secrets. */
32
58
  export function classifyConnection(connection) {
@@ -45,10 +71,11 @@ export function classifyConnection(connection) {
45
71
  if (knownReasonCode && knownReasonCode !== UNSPECIFIED_REASON_CODE) {
46
72
  const finding = findingForKnownReasonCode(connection, provider, knownReasonCode);
47
73
  // Retryable and HQ-configuration reason codes diagnose conditions that a
48
- // caller-specific remediation cannot change. Reconnect-class codes defer
49
- // to the server's credential/role-aware remediation classification, so an
50
- // install in progress is never interrupted by a reconnect.
51
- return [withServerRemediation(connection, RECONNECT_REASON_CODES.has(knownReasonCode)
74
+ // caller-specific remediation cannot change. Role-dependent codes defer to
75
+ // the server's credential/role-aware remediation classification, so an
76
+ // install in progress is never interrupted by a reconnect and a member is
77
+ // never told to perform a repair only an owner or admin can carry out.
78
+ return [withServerRemediation(connection, defersToServerFixKind(knownReasonCode)
52
79
  ? findingForServerFixKind(finding, connection.fix_kind)
53
80
  : finding)];
54
81
  }
@@ -161,6 +188,32 @@ function findingForServerFixKind(fallback, fixKind) {
161
188
  kind: "wait",
162
189
  message: "is waiting for the Factory installation to complete",
163
190
  };
191
+ case "re-add-with-auth":
192
+ return {
193
+ ...fallback,
194
+ kind: "re-add-with-auth",
195
+ message: "was added without a sign-in, and the provider refused it",
196
+ };
197
+ case "contact-support":
198
+ // hq-pro answers `contact-support` only for a gap in HQ's own OAuth
199
+ // configuration, which is the same class `hq-configuration` already
200
+ // names. It gets that kind rather than a member of its own so both paths
201
+ // into the class carry the same VERDICT — an HQ administrator has to fix
202
+ // it and reconnecting will not — instead of one cause reaching a person
203
+ // under two different remediations.
204
+ //
205
+ // The message stays distinct, and `hq doctor` groups on the message, so a
206
+ // provider with both faults prints two lines under the one class. That is
207
+ // deliberate and matches every other class here (two `reconnect` rows with
208
+ // different causes already print separately). Collapsing them would mean
209
+ // stamping a row that arrived with NO reason code with the specific
210
+ // wording of one that did — a confident wrong answer, which is the exact
211
+ // failure this vocabulary exists to stop.
212
+ return {
213
+ ...fallback,
214
+ kind: "hq-configuration",
215
+ message: "is blocked by an HQ-side configuration gap",
216
+ };
164
217
  case "reconnect":
165
218
  default:
166
219
  return fallback;
@@ -181,13 +234,30 @@ function withServerRemediation(connection, finding) {
181
234
  * the legacy text heuristics, which remain below for old and unknown rows.
182
235
  */
183
236
  function knownReasonCodeFor(connection) {
184
- const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason];
185
- if (values.includes(HQ_CONFIGURATION_REASON_CODE))
186
- return HQ_CONFIGURATION_REASON_CODE;
187
- return values.find((value) => (typeof value === "string" &&
188
- (RECONNECT_REASON_CODES.has(value) ||
189
- RETRYABLE_REASON_CODES.has(value) ||
190
- value === UNSPECIFIED_REASON_CODE)));
237
+ const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
238
+ .filter((value) => typeof value === "string");
239
+ // An HQ-side configuration fault outranks anything else recorded on the row:
240
+ // no customer-side repair can clear it, so a co-recorded reconnect code must
241
+ // not send someone to re-enter a credential that was never the problem.
242
+ const hqFault = values.find((value) => HQ_CONFIGURATION_REASON_CODES.has(value));
243
+ if (hqFault)
244
+ return hqFault;
245
+ return values.find((value) => (RECONNECT_REASON_CODES.has(value) ||
246
+ RETRYABLE_REASON_CODES.has(value) ||
247
+ value === RE_ADD_WITH_AUTH_REASON_CODE ||
248
+ value === UNSPECIFIED_REASON_CODE));
249
+ }
250
+ /**
251
+ * Whether the server's role- and credential-aware `fix_kind` should override
252
+ * the reason code's own classification.
253
+ *
254
+ * True for faults the customer CAN repair, because who repairs them depends on
255
+ * the caller's role — hq-pro answers a member with `contact-admin` where it
256
+ * answers an owner with the repair itself. False for HQ-side and retryable
257
+ * faults, whose remediation is the same for everyone.
258
+ */
259
+ function defersToServerFixKind(code) {
260
+ return RECONNECT_REASON_CODES.has(code) || code === RE_ADD_WITH_AUTH_REASON_CODE;
191
261
  }
192
262
  function findingForKnownReasonCode(connection, provider, code) {
193
263
  if (RECONNECT_REASON_CODES.has(code)) {
@@ -208,11 +278,21 @@ function findingForKnownReasonCode(connection, provider, code) {
208
278
  : "token refresh is temporarily unavailable; the stored credential remains intact",
209
279
  };
210
280
  }
281
+ if (code === RE_ADD_WITH_AUTH_REASON_CODE) {
282
+ return {
283
+ provider,
284
+ connectionId: connection.id,
285
+ kind: "re-add-with-auth",
286
+ message: "was added without a sign-in, and the provider refused it",
287
+ };
288
+ }
211
289
  return {
212
290
  provider,
213
291
  connectionId: connection.id,
214
292
  kind: "hq-configuration",
215
- message: "the HQ OAuth client secret is unavailable",
293
+ message: code === "oauth_refresh_missing_client"
294
+ ? "cannot be refreshed because HQ has no OAuth client configured for this provider"
295
+ : "the HQ OAuth client secret is unavailable",
216
296
  };
217
297
  }
218
298
  function recordedReason(connection) {
package/dist/main.js CHANGED
@@ -64,6 +64,7 @@ import { registerDoctorCommand } from "./commands/doctor.js";
64
64
  import { registerMeshCommand } from "./commands/mesh.js";
65
65
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
66
66
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
67
+ import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
67
68
  import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
68
69
  import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
69
70
  import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
@@ -523,6 +524,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
523
524
  const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
524
525
  ? null
525
526
  : environmentalFsErrorMessage(err);
527
+ // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
528
+ // `StateStoreLockError`: another HQ process on this machine holds the
529
+ // sync-state journal lock) is the caller's concurrency state, not an
530
+ // hq-cli defect — hq-cloud's own watcher/sync-runner treat lock contention
531
+ // as transient. Before HQ-CLI-14 the `hq skill create` sync step flattened
532
+ // it into a generic, path-bearing Error that reached the capture below and
533
+ // minted a new fingerprint per home directory. Print the input-free remedy,
534
+ // exit 1, and skip Sentry. The signatures do not overlap the neighbouring
535
+ // branches: a StateStoreLockError carries no `.code` (so
536
+ // environmentalFsErrorMessage returned null) and is not a fetch TypeError
537
+ // (so it is not a transport failure), and this ordering — after the
538
+ // environmental-fs check, before network-transport — is pinned by tests.
539
+ // The `in-process-async-holder` reason is deliberately NOT suppressed here
540
+ // (see sync-state-lock-error.ts); it stays captured.
541
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
542
+ ? null
543
+ : syncStateLockMessage(err);
526
544
  // A raw network transport failure (undici's `TypeError: fetch failed`
527
545
  // with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
528
546
  // caller's connectivity, not an hq-cli defect. Before this branch it fell
@@ -533,7 +551,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
533
551
  // message that names the unreachable host, exit 1, and skip Sentry.
534
552
  // Ordered after the environmental check so a full disk keeps its exact
535
553
  // existing message.
536
- const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
554
+ const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg || lockMsg
537
555
  ? null
538
556
  : networkTransportErrorMessage(err);
539
557
  if (qmdMsg) {
@@ -554,6 +572,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
554
572
  else if (envMsg) {
555
573
  deps.stderr.write(`hq: ${envMsg}\n`);
556
574
  }
575
+ else if (lockMsg) {
576
+ deps.stderr.write(`hq: ${lockMsg}\n`);
577
+ }
557
578
  else if (transportMsg) {
558
579
  deps.stderr.write(`hq: ${transportMsg}\n`);
559
580
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The `name` @indigoai-us/hq-cloud stamps on a state-store lock failure, and the
3
+ * prefix it puts on every such message (`super(\`state-store lock: ${message}\`)`).
4
+ * EITHER signal identifies the class. A unit test pins BOTH against the installed
5
+ * dependency so a future rename fails loudly in CI here rather than silently
6
+ * resuming Sentry noise.
7
+ */
8
+ export declare const SYNC_STATE_LOCK_ERROR_NAME = "StateStoreLockError";
9
+ export declare const SYNC_STATE_LOCK_MESSAGE_PREFIX = "state-store lock: ";
10
+ /**
11
+ * If `err` is a LOCAL sync-state lock failure, return a short, input-free,
12
+ * actionable user-facing message; otherwise return `null`.
13
+ *
14
+ * A non-null result means the caller should PRINT the message, exit non-zero,
15
+ * and SKIP Sentry capture — the condition is another HQ process holding this
16
+ * machine's sync-state lock, not a bug HQ can fix. A null result means "handle
17
+ * this as usual (capture to Sentry)". The `in-process-async-holder` reason is
18
+ * deliberately excluded and still captures.
19
+ *
20
+ * The returned message never echoes the lock path or any other caller input, so
21
+ * the whole class groups as ONE Sentry-free condition instead of minting a new
22
+ * fingerprint per home directory. It is a fixed, bounded string, so no redaction
23
+ * or length cap is required.
24
+ */
25
+ export declare function syncStateLockMessage(err: unknown): string | null;
26
+ //# sourceMappingURL=sync-state-lock-error.d.ts.map
@@ -0,0 +1,78 @@
1
+ // src/utils/sync-state-lock-error.ts
2
+ //
3
+ // Classify a LOCAL sync-state lock failure — another HQ process on THIS machine
4
+ // already holds the sync-state journal lock — as the caller's machine/
5
+ // concurrency state rather than an hq-cli code defect. Sibling of
6
+ // `environmental-error.ts` (HQ-CLI-2, full disk / read-only fs) and
7
+ // `network-transport-error.ts` (HQ-CLI-G, connectivity): a failure that is not
8
+ // an hq-cli defect is surfaced to the user with an actionable message and
9
+ // skipped for Sentry capture.
10
+ //
11
+ // HQ-CLI-14 (Sentry 7695715459): `hq skill create` registered and stamped the
12
+ // skill, then its sync step called @indigoai-us/hq-cloud's `share()`, which
13
+ // surfaced a `StateStoreLockError: state-store lock: cannot acquire
14
+ // <HOME>/.hq/sync-state-v3/<digest>/append.lock`. hq-cloud's OWN consumers treat
15
+ // lock contention as transient — its watcher reschedules on it and its
16
+ // sync-runner defers only the `in-process-async-holder` reason — so a local
17
+ // lock failure is concurrency state the user resolves by waiting for (or
18
+ // quitting) the other HQ sync, not a reportable hq-cli crash. Before this the
19
+ // caller flattened every sync failure into a generic, path-bearing Error, so the
20
+ // lock class fell through the top-level boundary's closed allowlist and filed a
21
+ // crash whose message minted a new Sentry fingerprint per home directory.
22
+ //
23
+ // Matching is STRUCTURAL, never `instanceof`: `StateStoreLockError` is not
24
+ // re-exported from `@indigoai-us/hq-cloud`'s package entry (only `.`,
25
+ // `./outposts*` and `./package.json` are exported), so a consumer cannot name
26
+ // the class. Both signals below are computed from the value itself, mirroring
27
+ // the style `environmental-error.ts` and `network-transport-error.ts` already
28
+ // use.
29
+ /**
30
+ * The `name` @indigoai-us/hq-cloud stamps on a state-store lock failure, and the
31
+ * prefix it puts on every such message (`super(\`state-store lock: ${message}\`)`).
32
+ * EITHER signal identifies the class. A unit test pins BOTH against the installed
33
+ * dependency so a future rename fails loudly in CI here rather than silently
34
+ * resuming Sentry noise.
35
+ */
36
+ export const SYNC_STATE_LOCK_ERROR_NAME = "StateStoreLockError";
37
+ export const SYNC_STATE_LOCK_MESSAGE_PREFIX = "state-store lock: ";
38
+ /**
39
+ * The one `reason` hq-cloud treats as a DISTINCT internal condition rather than
40
+ * ordinary foreign contention: this process already holds the async scope lock,
41
+ * so a synchronous contender cannot spin without deadlocking the holder
42
+ * (state-store.ts's `in-process async holder for …`). That is same-process
43
+ * reentrancy — a condition HQ could actually fix — so it is NOT suppressed and
44
+ * stays on the captured path, exactly as hq-cloud's own sync-runner special-cases
45
+ * it.
46
+ */
47
+ const IN_PROCESS_ASYNC_HOLDER_REASON = "in-process-async-holder";
48
+ function isStateStoreLockError(err) {
49
+ if (!(err instanceof Error))
50
+ return false;
51
+ return (err.name === SYNC_STATE_LOCK_ERROR_NAME ||
52
+ err.message.startsWith(SYNC_STATE_LOCK_MESSAGE_PREFIX));
53
+ }
54
+ /**
55
+ * If `err` is a LOCAL sync-state lock failure, return a short, input-free,
56
+ * actionable user-facing message; otherwise return `null`.
57
+ *
58
+ * A non-null result means the caller should PRINT the message, exit non-zero,
59
+ * and SKIP Sentry capture — the condition is another HQ process holding this
60
+ * machine's sync-state lock, not a bug HQ can fix. A null result means "handle
61
+ * this as usual (capture to Sentry)". The `in-process-async-holder` reason is
62
+ * deliberately excluded and still captures.
63
+ *
64
+ * The returned message never echoes the lock path or any other caller input, so
65
+ * the whole class groups as ONE Sentry-free condition instead of minting a new
66
+ * fingerprint per home directory. It is a fixed, bounded string, so no redaction
67
+ * or length cap is required.
68
+ */
69
+ export function syncStateLockMessage(err) {
70
+ if (!isStateStoreLockError(err))
71
+ return null;
72
+ if (err.reason === IN_PROCESS_ASYNC_HOLDER_REASON)
73
+ return null;
74
+ return ("Another HQ process on this machine is holding the local sync state, so HQ " +
75
+ "could not update it. Wait for the other HQ sync to finish (or quit it), " +
76
+ "then run `hq sync` to finish syncing.");
77
+ }
78
+ //# sourceMappingURL=sync-state-lock-error.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.31",
3
+ "version": "5.103.32",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {