@indigoai-us/hq-cli 5.103.31 → 5.103.33

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,10 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.103.33] — 2026-08-30
6
+
7
+ ## [5.103.32] — 2026-08-29
8
+
5
9
  ## [5.103.31] — 2026-08-29
6
10
 
7
11
  ## [5.103.30] — 2026-08-29
@@ -17,6 +17,7 @@ import chalk from "chalk";
17
17
  import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
18
18
  import { DEFAULT_COGNITO, refreshCachedSession, } from "../utils/cognito-session.js";
19
19
  import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
20
+ import { callbackPortBusyGuidance, DEFAULT_CALLBACK_PORT, isCallbackPortBusy, } from "../utils/callback-port-busy.js";
20
21
  /**
21
22
  * Decode the (unverified) ID token payload for display purposes only.
22
23
  * The token was just returned by Cognito's token endpoint, so its contents
@@ -46,17 +47,6 @@ function machineIdentityLabel() {
46
47
  const creds = loadMachineCreds();
47
48
  return creds ? `machine identity ${creds.username}` : "machine identity";
48
49
  }
49
- function isCallbackPortCollision(error) {
50
- if (!error || typeof error !== "object")
51
- return false;
52
- const { code, message } = error;
53
- return code === "EADDRINUSE" || message?.includes("EADDRINUSE") === true;
54
- }
55
- function callbackPortCollisionGuidance(port) {
56
- return (` The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
57
- "Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
58
- "or stop its terminal/process, then retry.");
59
- }
60
50
  export function registerAuthCommands(program) {
61
51
  const authCmd = program
62
52
  .command("auth")
@@ -83,7 +73,7 @@ export function registerAuthCommands(program) {
83
73
  console.log(chalk.dim(` Token cached at ~/.hq/cognito-tokens.json (expires ${tokens.expiresAt})`));
84
74
  }
85
75
  catch (err) {
86
- const callbackPortCollision = isCallbackPortCollision(err);
76
+ const callbackPortCollision = isCallbackPortBusy(err);
87
77
  const msg = callbackPortCollision
88
78
  ? "Browser-login callback port is already in use."
89
79
  : err instanceof CognitoAuthError
@@ -93,7 +83,7 @@ export function registerAuthCommands(program) {
93
83
  : String(err);
94
84
  console.error(chalk.red(`Login failed: ${msg}`));
95
85
  if (callbackPortCollision) {
96
- console.error(chalk.dim(callbackPortCollisionGuidance(DEFAULT_COGNITO.port ?? 8765)));
86
+ console.error(chalk.dim(` ${callbackPortBusyGuidance(DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}`));
97
87
  }
98
88
  else {
99
89
  console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.hq.computer"));
@@ -3,6 +3,7 @@ import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcil
3
3
  import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
4
4
  import { findHqRoot } from '../utils/manifest.js';
5
5
  import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
6
+ import { isQmdStoreMissingError, qmdStoreMissingMessage, } from '../utils/qmd-store-missing-error.js';
6
7
  const defaults = {
7
8
  reconcileCollections,
8
9
  deriveCollections,
@@ -119,10 +120,23 @@ export function registerIndexCommand(program, dependencies = defaults) {
119
120
  qmdStatus = dependencies.runQmd(['status'], { cwd: hqRoot });
120
121
  }
121
122
  catch (error) {
122
- if (!isQmdNativeBindingError(error))
123
+ if (isQmdNativeBindingError(error)) {
124
+ process.stderr.write(`qmd: unusable — native bindings unbuilt. ${QMD_NATIVE_BINDING_REMEDY}\n`);
125
+ process.exitCode = 1;
126
+ }
127
+ else if (isQmdStoreMissingError(error)) {
128
+ // The local qmd store directory does not exist (HQ-CLI-16). Like the
129
+ // native-binding case, this diagnostic command should DESCRIBE the
130
+ // broken local store, not crash on it: print the classified reason +
131
+ // remedy and exit 1 without rethrowing, so the boundary never files it
132
+ // as a Sentry crash. Every OTHER qmd failure keeps propagating.
133
+ const remedy = qmdStoreMissingMessage(error) ?? 'its local search store directory does not exist';
134
+ process.stderr.write(`qmd: unusable — ${remedy}\n`);
135
+ process.exitCode = 1;
136
+ }
137
+ else {
123
138
  throw error;
124
- process.stderr.write(`qmd: unusable — native bindings unbuilt. ${QMD_NATIVE_BINDING_REMEDY}\n`);
125
- process.exitCode = 1;
139
+ }
126
140
  }
127
141
  if (registered) {
128
142
  console.log(collectionSummary(expected, registered));
@@ -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) {
@@ -152,6 +152,29 @@ export declare class QmdLlmDisabledError extends QmdExitError {
152
152
  export declare class QmdModuleMissingError extends QmdExitError {
153
153
  name: string;
154
154
  }
155
+ /**
156
+ * qmd could not open its SQLite store because the store's DIRECTORY does not
157
+ * exist. @tobilu/qmd resolves the store as $INDEX_PATH (returned verbatim,
158
+ * before any mkdir) else ${XDG_CACHE_HOME||~/.cache}/qmd (behind a mkdir whose
159
+ * failure it swallows), then `new Database(path)` throws better-sqlite3's
160
+ * `TypeError: Cannot open database because the directory does not exist`. That
161
+ * missing directory is the caller's ENVIRONMENT — a pruned cache, a fresh
162
+ * machine, an INDEX_PATH into a deleted tree, or a directory hq could not create
163
+ * (permissions / a read-only mount) — never a bug HQ can fix in code.
164
+ *
165
+ * `storeDir` carries the directory hq resolved qmd would open its store in, and
166
+ * `ensureReason` the bounded errno phrase for why hq's own best-effort
167
+ * pre-create of that directory failed (undefined when the ensure succeeded or
168
+ * did not run). Both are hq-DERIVED — the resolved path plus a finite errno
169
+ * reason — so the boundary can name them in an input-free remedy without ever
170
+ * echoing caller argv or upstream text. HQ-CLI-16 (Sentry 7698235964).
171
+ */
172
+ export declare class QmdStoreMissingError extends QmdExitError {
173
+ readonly storeDir?: string | undefined;
174
+ readonly ensureReason?: string | undefined;
175
+ name: string;
176
+ constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, ensureReason?: string | undefined);
177
+ }
155
178
  export type ResolveQmdBinOptions = {
156
179
  env?: Record<string, string | undefined>;
157
180
  isExecutable?: (candidate: string) => boolean;
@@ -203,6 +226,48 @@ export declare function packageLocalNodeEntry(): string | undefined;
203
226
  * disappeared from `hq index status`. Same root cause as packageLocalBin.
204
227
  */
205
228
  export declare function resolveQmdVersion(): string | undefined;
229
+ /**
230
+ * The outcome of the best-effort store-directory ensure, keyed by resolved
231
+ * directory so the mkdir happens at most once per distinct directory per process
232
+ * (idempotent, bounded — a process opens a tiny, fixed set of store dirs). Read
233
+ * by finishRunQmd to stamp the directory and the errno reason onto a
234
+ * QmdStoreMissingError, turning the condition that carried no cause in the
235
+ * reported event into a self-describing one.
236
+ */
237
+ type QmdStoreEnsureOutcome = {
238
+ dir: string;
239
+ created: boolean;
240
+ reason?: string;
241
+ };
242
+ /** Test-only: read the most recent store-directory ensure outcome. */
243
+ export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | undefined;
244
+ /**
245
+ * Resolve the DIRECTORY @tobilu/qmd will open its SQLite store in, mirroring the
246
+ * pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
247
+ * qmdHomedir) against the SAME env the qmd child receives:
248
+ * - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
249
+ * so the store file's directory is the one that must exist);
250
+ * - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
251
+ * $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
252
+ * An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
253
+ * changes it turns CI red instead of silently restoring the noise.
254
+ */
255
+ export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv): string;
256
+ export type EnsureQmdStoreDirOptions = {
257
+ /** mkdir seam (default `fs.mkdirSync(dir, { recursive: true })`); injected in tests. */
258
+ mkdir?: (dir: string) => void;
259
+ };
260
+ /**
261
+ * Best-effort, once-per-directory self-provisioning of qmd's store directory
262
+ * BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
263
+ * machine — simply works instead of erroring. It creates ONLY that one directory
264
+ * (recursive), records the outcome (including a bounded errno reason on
265
+ * failure), and NEVER throws, retries, waits, or spawns. When the mkdir fails
266
+ * (permissions / read-only), qmd then still can't open its store, and the
267
+ * recorded reason is what makes the classified remedy self-describing. Modelled
268
+ * on the repairQmdNativeBindings best-effort contract in this file.
269
+ */
270
+ export declare function ensureQmdStoreDir(env?: NodeJS.ProcessEnv, options?: EnsureQmdStoreDirOptions): void;
206
271
  /** Reset per-process probe/repair memoisation. Test-only. */
207
272
  export declare function __resetQmdProbeStateForTests(): void;
208
273
  /** Probe a resolved invocation, memoised per invocation; records the failure
@@ -6,6 +6,7 @@ import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
8
8
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
9
+ import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
9
10
  import { redactErrorText } from '../../utils/redact-error-text.js';
10
11
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
11
12
  const require = createRequire(import.meta.url);
@@ -131,6 +132,33 @@ export class QmdLlmDisabledError extends QmdExitError {
131
132
  export class QmdModuleMissingError extends QmdExitError {
132
133
  name = 'QmdModuleMissingError';
133
134
  }
135
+ /**
136
+ * qmd could not open its SQLite store because the store's DIRECTORY does not
137
+ * exist. @tobilu/qmd resolves the store as $INDEX_PATH (returned verbatim,
138
+ * before any mkdir) else ${XDG_CACHE_HOME||~/.cache}/qmd (behind a mkdir whose
139
+ * failure it swallows), then `new Database(path)` throws better-sqlite3's
140
+ * `TypeError: Cannot open database because the directory does not exist`. That
141
+ * missing directory is the caller's ENVIRONMENT — a pruned cache, a fresh
142
+ * machine, an INDEX_PATH into a deleted tree, or a directory hq could not create
143
+ * (permissions / a read-only mount) — never a bug HQ can fix in code.
144
+ *
145
+ * `storeDir` carries the directory hq resolved qmd would open its store in, and
146
+ * `ensureReason` the bounded errno phrase for why hq's own best-effort
147
+ * pre-create of that directory failed (undefined when the ensure succeeded or
148
+ * did not run). Both are hq-DERIVED — the resolved path plus a finite errno
149
+ * reason — so the boundary can name them in an input-free remedy without ever
150
+ * echoing caller argv or upstream text. HQ-CLI-16 (Sentry 7698235964).
151
+ */
152
+ export class QmdStoreMissingError extends QmdExitError {
153
+ storeDir;
154
+ ensureReason;
155
+ name = 'QmdStoreMissingError';
156
+ constructor(message, args, status, stdout, stderr, storeDir, ensureReason) {
157
+ super(message, args, status, stdout, stderr);
158
+ this.storeDir = storeDir;
159
+ this.ensureReason = ensureReason;
160
+ }
161
+ }
134
162
  function isExecutable(candidate) {
135
163
  try {
136
164
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -265,6 +293,84 @@ const usableQmdCache = new Map();
265
293
  const lastProbeFailure = new Map();
266
294
  /** At most one native-binding repair attempt per process (bounded, no retries). */
267
295
  let nativeBindingRepairAttempted = false;
296
+ const storeEnsureByDir = new Map();
297
+ /** The most recent ensure outcome (the directory the next qmd spawn will use). */
298
+ let lastQmdStoreEnsure;
299
+ /** Test-only: read the most recent store-directory ensure outcome. */
300
+ export function lastQmdStoreEnsureOutcome() {
301
+ return lastQmdStoreEnsure;
302
+ }
303
+ /**
304
+ * Resolve the DIRECTORY @tobilu/qmd will open its SQLite store in, mirroring the
305
+ * pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
306
+ * qmdHomedir) against the SAME env the qmd child receives:
307
+ * - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
308
+ * so the store file's directory is the one that must exist);
309
+ * - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
310
+ * $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
311
+ * An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
312
+ * changes it turns CI red instead of silently restoring the noise.
313
+ */
314
+ export function resolveQmdStoreDir(env = process.env) {
315
+ const indexPath = env.INDEX_PATH;
316
+ if (indexPath)
317
+ return path.dirname(indexPath);
318
+ const home = env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
319
+ const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
320
+ return path.join(cacheDir, 'qmd');
321
+ }
322
+ /** Map a mkdir ErrnoException to a bounded, human errno reason (never free text). */
323
+ function ensureFailureReason(error) {
324
+ const code = error?.code;
325
+ switch (code) {
326
+ case 'EACCES':
327
+ case 'EPERM':
328
+ return 'permission denied';
329
+ case 'EROFS':
330
+ return 'the filesystem is read-only';
331
+ case 'ENOTDIR':
332
+ return 'a path component is not a directory';
333
+ case 'ENOENT':
334
+ return 'a parent path does not exist';
335
+ case 'ENOSPC':
336
+ return 'no space left on device';
337
+ default:
338
+ return typeof code === 'string' && code.length > 0 ? code : 'an unknown error';
339
+ }
340
+ }
341
+ /**
342
+ * Best-effort, once-per-directory self-provisioning of qmd's store directory
343
+ * BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
344
+ * machine — simply works instead of erroring. It creates ONLY that one directory
345
+ * (recursive), records the outcome (including a bounded errno reason on
346
+ * failure), and NEVER throws, retries, waits, or spawns. When the mkdir fails
347
+ * (permissions / read-only), qmd then still can't open its store, and the
348
+ * recorded reason is what makes the classified remedy self-describing. Modelled
349
+ * on the repairQmdNativeBindings best-effort contract in this file.
350
+ */
351
+ export function ensureQmdStoreDir(env = process.env, options = {}) {
352
+ let dir;
353
+ try {
354
+ dir = resolveQmdStoreDir(env);
355
+ }
356
+ catch {
357
+ // Resolution itself must never break a qmd run.
358
+ return;
359
+ }
360
+ let outcome = storeEnsureByDir.get(dir);
361
+ if (!outcome) {
362
+ const mkdir = options.mkdir ?? ((target) => { fs.mkdirSync(target, { recursive: true }); });
363
+ try {
364
+ mkdir(dir);
365
+ outcome = { dir, created: true };
366
+ }
367
+ catch (error) {
368
+ outcome = { dir, created: false, reason: ensureFailureReason(error) };
369
+ }
370
+ storeEnsureByDir.set(dir, outcome);
371
+ }
372
+ lastQmdStoreEnsure = outcome;
373
+ }
268
374
  /** How long a single usability probe or repair sub-step may run. */
269
375
  const PROBE_TIMEOUT_MS = 10_000;
270
376
  const REPAIR_STEP_TIMEOUT_MS = 180_000;
@@ -275,6 +381,8 @@ export function __resetQmdProbeStateForTests() {
275
381
  usableQmdCache.clear();
276
382
  lastProbeFailure.clear();
277
383
  nativeBindingRepairAttempted = false;
384
+ storeEnsureByDir.clear();
385
+ lastQmdStoreEnsure = undefined;
278
386
  }
279
387
  /** The qmd file identity of an invocation: the launcher for a node entry, else
280
388
  * the command. Used to key probe failures and confine native-binding repair. */
@@ -823,6 +931,26 @@ function finishRunQmd(result, bin, args) {
823
931
  if (isQmdModuleMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
824
932
  throw new QmdModuleMissingError(`qmd ${subcommand} could not run: a module it needs was not found (the hq install tree looks incomplete)`, args, normalized.status, normalized.stdout, normalized.stderr);
825
933
  }
934
+ // qmd could not open its SQLite store because the store DIRECTORY does not
935
+ // exist (better-sqlite3: `TypeError: Cannot open database because the
936
+ // directory does not exist`). That missing directory is the caller's
937
+ // environment — a pruned cache, a fresh machine, an INDEX_PATH into a deleted
938
+ // tree, or a directory hq could not create (permissions / a read-only mount) —
939
+ // not an hq-cli defect. Type it so the boundary prints a self-describing
940
+ // remedy (naming the resolved store dir and the recorded errno reason) and
941
+ // skips capture, and so index-cmd's diagnostic command degrades rather than
942
+ // crashing. Read qmd's OWN captured streams only, never the synthesized
943
+ // message, so a user query containing that phrase can never trip it. Placed
944
+ // AFTER the LLM-disabled and module-missing checks (narrower typed signatures)
945
+ // and BEFORE the collection-missing regex: the wordings are disjoint (that
946
+ // regex needs `collection`/`qmd://` adjacent to a not-found token, which this
947
+ // stderr lacks), so no existing branch changes behaviour (HQ-CLI-16, Sentry
948
+ // 7698235964). The stamped storeDir/ensureReason come from the pre-spawn
949
+ // ensure that ran for this exact store directory.
950
+ if (isQmdStoreMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
951
+ const ensure = lastQmdStoreEnsure;
952
+ throw new QmdStoreMissingError(`qmd ${subcommand} could not run: its local search store could not be opened (its directory does not exist)`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.created ? ensure.reason : undefined);
953
+ }
826
954
  if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
827
955
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
828
956
  }
@@ -861,6 +989,13 @@ export function runQmd(args, options = {}) {
861
989
  execPath: options.execPath,
862
990
  spawn: options.spawn,
863
991
  });
992
+ // Self-provision qmd's store directory before the spawn so the benign
993
+ // pruned-cache / fresh-machine case simply works, and record the outcome so a
994
+ // residual store-open failure (a directory hq could not create) can be
995
+ // classified with its cause instead of captured (HQ-CLI-16). Best-effort and
996
+ // idempotent; only the real spawn path needs it (the injected-runner path
997
+ // above never opens a store).
998
+ ensureQmdStoreDir(options.env ?? process.env);
864
999
  const result = spawnQmd(invocation, args, {
865
1000
  cwd: options.cwd,
866
1001
  env: options.env ?? process.env,
package/dist/main.js CHANGED
@@ -64,17 +64,21 @@ 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";
70
71
  import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
71
72
  import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
72
73
  import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
74
+ import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
73
75
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
74
76
  import { isEpipe } from "./utils/epipe.js";
75
77
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
76
78
  import { isAuthError } from "./utils/auth-error.js";
77
79
  import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js";
80
+ import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
81
+ import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
78
82
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
79
83
  import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
80
84
  import { upgradeToTeam } from "./utils/team-upgrade.js";
@@ -412,6 +416,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
412
416
  deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
413
417
  deps.setExitCode(1);
414
418
  }
419
+ else if (callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)) {
420
+ // HQ-CLI-15: the caller's command fell back to the IMPLICIT browser
421
+ // sign-in, but the loopback OAuth callback port (127.0.0.1:<port>) was
422
+ // already held by another process, so @indigoai-us/hq-cloud's browserLogin
423
+ // rejected with a raw `listen EADDRINUSE` on that fixed port. That is the
424
+ // caller's local machine state — a second `hq login` still waiting for its
425
+ // browser sign-in, or a stray process on the port — the user fixes by
426
+ // finishing/stopping the other login and retrying, not an hq-cli defect.
427
+ // Print the fixed actionable remedy and skip Sentry so one busy port
428
+ // doesn't file a permanent, unfixable "crash". Placed with the
429
+ // abandoned-login carve-out (both mean "your HQ session couldn't be
430
+ // established interactively"). The match is STRUCTURAL (errno code + the
431
+ // configured callback port), so an EADDRINUSE on any OTHER port stays a
432
+ // genuine fault and still captures below.
433
+ deps.stderr.write(`hq: ${callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}\n`);
434
+ deps.setExitCode(1);
435
+ }
415
436
  else if (isPlanGateError(err)) {
416
437
  // hq-pro's plan denials are expected product limits, never a CLI crash.
417
438
  // The shared vault client has already decoded and typed the small safe
@@ -520,9 +541,41 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
520
541
  const moduleMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
521
542
  ? null
522
543
  : qmdModuleMissingMessage(err);
523
- const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
544
+ // A qmd store-directory-missing failure (better-sqlite3 could not open its
545
+ // SQLite store because the directory does not exist) is the caller's local
546
+ // filesystem/environment — a pruned cache, a fresh machine, an INDEX_PATH
547
+ // into a deleted tree, or a directory hq could not create — not an hq-cli
548
+ // defect. finishRunQmd types it QmdStoreMissingError, carrying the resolved
549
+ // store directory and the errno reason hq's pre-spawn ensure recorded;
550
+ // print that self-describing remedy and skip capture (HQ-CLI-16, Sentry
551
+ // 7698235964). Evaluated AFTER the native-binding / collection-missing /
552
+ // terminated / llm-disabled / module-missing checks (all narrower or
553
+ // sibling typed signatures) and BEFORE the environmental / transport /
554
+ // generic branches; the signatures are disjoint, so ordering changes no
555
+ // existing branch.
556
+ const storeMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
557
+ ? null
558
+ : qmdStoreMissingMessage(err);
559
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
524
560
  ? null
525
561
  : environmentalFsErrorMessage(err);
562
+ // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
563
+ // `StateStoreLockError`: another HQ process on this machine holds the
564
+ // sync-state journal lock) is the caller's concurrency state, not an
565
+ // hq-cli defect — hq-cloud's own watcher/sync-runner treat lock contention
566
+ // as transient. Before HQ-CLI-14 the `hq skill create` sync step flattened
567
+ // it into a generic, path-bearing Error that reached the capture below and
568
+ // minted a new fingerprint per home directory. Print the input-free remedy,
569
+ // exit 1, and skip Sentry. The signatures do not overlap the neighbouring
570
+ // branches: a StateStoreLockError carries no `.code` (so
571
+ // environmentalFsErrorMessage returned null) and is not a fetch TypeError
572
+ // (so it is not a transport failure), and this ordering — after the
573
+ // environmental-fs check, before network-transport — is pinned by tests.
574
+ // The `in-process-async-holder` reason is deliberately NOT suppressed here
575
+ // (see sync-state-lock-error.ts); it stays captured.
576
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || envMsg
577
+ ? null
578
+ : syncStateLockMessage(err);
526
579
  // A raw network transport failure (undici's `TypeError: fetch failed`
527
580
  // with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
528
581
  // caller's connectivity, not an hq-cli defect. Before this branch it fell
@@ -533,7 +586,14 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
533
586
  // message that names the unreachable host, exit 1, and skip Sentry.
534
587
  // Ordered after the environmental check so a full disk keeps its exact
535
588
  // existing message.
536
- const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
589
+ const transportMsg = qmdMsg ||
590
+ collectionMsg ||
591
+ terminatedMsg ||
592
+ llmDisabledMsg ||
593
+ moduleMissingMsg ||
594
+ storeMissingMsg ||
595
+ envMsg ||
596
+ lockMsg
537
597
  ? null
538
598
  : networkTransportErrorMessage(err);
539
599
  if (qmdMsg) {
@@ -551,9 +611,15 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
551
611
  else if (moduleMissingMsg) {
552
612
  deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
553
613
  }
614
+ else if (storeMissingMsg) {
615
+ deps.stderr.write(`hq: ${storeMissingMsg}\n`);
616
+ }
554
617
  else if (envMsg) {
555
618
  deps.stderr.write(`hq: ${envMsg}\n`);
556
619
  }
620
+ else if (lockMsg) {
621
+ deps.stderr.write(`hq: ${lockMsg}\n`);
622
+ }
557
623
  else if (transportMsg) {
558
624
  deps.stderr.write(`hq: ${transportMsg}\n`);
559
625
  }
@@ -0,0 +1,29 @@
1
+ /** Fixed callback port for the loopback OAuth server when unset (mirrors DEFAULT_COGNITO). */
2
+ export declare const DEFAULT_CALLBACK_PORT = 8765;
3
+ /**
4
+ * The fixed, actionable remedy. Interpolates ONLY the numeric callback port (a
5
+ * bounded value drawn from config, never caller input or upstream error text),
6
+ * so this carve-out can never widen disclosure and needs no redaction pass.
7
+ */
8
+ export declare function callbackPortBusyGuidance(port: number): string;
9
+ /**
10
+ * The LOOSE predicate for a caller that just invoked browserLogin and is
11
+ * catching its own error (`hq auth login`/`hq login`): an EADDRINUSE by errno
12
+ * code OR message is the callback-port collision, because the callback server is
13
+ * the only listener that call could have opened. NOT for the shared boundary —
14
+ * use {@link callbackPortBusyMessage} there.
15
+ */
16
+ export declare function isCallbackPortBusy(err: unknown): boolean;
17
+ /**
18
+ * The STRUCTURAL classifier for the shared top-level boundary. Returns the fixed
19
+ * remedy ONLY when `err` is an EADDRINUSE whose `port` equals the configured
20
+ * callback `port`; otherwise returns `null`.
21
+ *
22
+ * A non-null result means the caller should print the message and SKIP Sentry
23
+ * capture. A null result means "handle as usual (capture to Sentry)" — including
24
+ * an EADDRINUSE on ANY other port, which is a different listener and a genuine
25
+ * fault that must stay reportable. Matched on the errno SHAPE only (code + port),
26
+ * never on free text, so no upstream/caller string can reach the printed line.
27
+ */
28
+ export declare function callbackPortBusyMessage(err: unknown, port: number): string | null;
29
+ //# sourceMappingURL=callback-port-busy.d.ts.map
@@ -0,0 +1,87 @@
1
+ // src/utils/callback-port-busy.ts
2
+ //
3
+ // Classify a browser-login CALLBACK-PORT COLLISION — the loopback OAuth server
4
+ // @indigoai-us/hq-cloud's browserLogin binds (the fixed DEFAULT_COGNITO.port,
5
+ // 8765 unless HQ_COGNITO_CALLBACK_PORT overrides it) could not be bound because
6
+ // another process already holds it, so Node rejects with a raw ErrnoException
7
+ // (`listen EADDRINUSE …`, code EADDRINUSE, port 8765, syscall listen). That is
8
+ // the caller's LOCAL machine state — a second `hq login` still waiting for its
9
+ // browser sign-in, or a stray process on the port — NOT an hq-cli defect: the
10
+ // user fixes it by finishing/stopping the other login and retrying. So the
11
+ // top-level handler prints an actionable line and exits non-zero but SKIPS
12
+ // Sentry capture, mirroring the abandoned-browser-login (HQ-CLI-V), auth
13
+ // (HQ-CLI-8), company-selection (HQ-CLI-7), expected-user-error (HQ-CLI-6), and
14
+ // environmental-FS (HQ-CLI-2) carve-outs.
15
+ //
16
+ // HQ-CLI-15 (Sentry indigo-d0/hq-cli 7698014705): `hq integrations list
17
+ // --company <slug> --json` refreshed an expiring session, the refresh failed,
18
+ // and it fell back to the IMPLICIT browser sign-in on one of the ~100
19
+ // ensureCognitoToken/ensureCognitoIdToken call sites. The callback port was
20
+ // already held, so browserLogin rejected with a bare `listen EADDRINUSE:
21
+ // address already in use 127.0.0.1:8765`. That Node error carries no `expected`
22
+ // flag and is not an AuthError/CognitoAuthError/…, so every predicate in
23
+ // handleTopLevelError returned false and the collision was captured as a
24
+ // high-priority, unfixable "crash". `hq auth login` already classified the
25
+ // identical condition in a file-private helper; this module lifts that
26
+ // classification to the shared boundary so every implicit call site is covered
27
+ // from one source of truth.
28
+ //
29
+ // Two predicates, differing ONLY by how much context the caller already has:
30
+ // - isCallbackPortBusy(err): the LOOSE form for a call site that KNOWS it just
31
+ // invoked browserLogin (`hq auth login`/`hq login`). There the only listener
32
+ // in play is the callback server, so an EADDRINUSE — by code or message — is
33
+ // unambiguously the callback port.
34
+ // - callbackPortBusyMessage(err, port): the STRUCTURAL form for the shared
35
+ // boundary, which sees arbitrary errors from every call site. It matches
36
+ // ONLY when err.code === 'EADDRINUSE' AND err.port equals the configured
37
+ // callback port, so an EADDRINUSE raised by any OTHER listener stays
38
+ // reportable (the boundary must never swallow a genuine listener defect).
39
+ /** Fixed callback port for the loopback OAuth server when unset (mirrors DEFAULT_COGNITO). */
40
+ export const DEFAULT_CALLBACK_PORT = 8765;
41
+ /** Node's errno code for a `listen()` against an address already in use. */
42
+ const EADDRINUSE = "EADDRINUSE";
43
+ /**
44
+ * The fixed, actionable remedy. Interpolates ONLY the numeric callback port (a
45
+ * bounded value drawn from config, never caller input or upstream error text),
46
+ * so this carve-out can never widen disclosure and needs no redaction pass.
47
+ */
48
+ export function callbackPortBusyGuidance(port) {
49
+ return (`The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
50
+ "Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
51
+ "or stop its terminal/process, then retry.");
52
+ }
53
+ /**
54
+ * The LOOSE predicate for a caller that just invoked browserLogin and is
55
+ * catching its own error (`hq auth login`/`hq login`): an EADDRINUSE by errno
56
+ * code OR message is the callback-port collision, because the callback server is
57
+ * the only listener that call could have opened. NOT for the shared boundary —
58
+ * use {@link callbackPortBusyMessage} there.
59
+ */
60
+ export function isCallbackPortBusy(err) {
61
+ if (!err || typeof err !== "object")
62
+ return false;
63
+ const { code, message } = err;
64
+ return code === EADDRINUSE || message?.includes(EADDRINUSE) === true;
65
+ }
66
+ /**
67
+ * The STRUCTURAL classifier for the shared top-level boundary. Returns the fixed
68
+ * remedy ONLY when `err` is an EADDRINUSE whose `port` equals the configured
69
+ * callback `port`; otherwise returns `null`.
70
+ *
71
+ * A non-null result means the caller should print the message and SKIP Sentry
72
+ * capture. A null result means "handle as usual (capture to Sentry)" — including
73
+ * an EADDRINUSE on ANY other port, which is a different listener and a genuine
74
+ * fault that must stay reportable. Matched on the errno SHAPE only (code + port),
75
+ * never on free text, so no upstream/caller string can reach the printed line.
76
+ */
77
+ export function callbackPortBusyMessage(err, port) {
78
+ if (!err || typeof err !== "object")
79
+ return null;
80
+ const errno = err;
81
+ if (errno.code !== EADDRINUSE)
82
+ return null;
83
+ if (typeof errno.port !== "number" || errno.port !== port)
84
+ return null;
85
+ return callbackPortBusyGuidance(port);
86
+ }
87
+ //# sourceMappingURL=callback-port-busy.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * True when `err` is a qmd store-directory-missing failure: better-sqlite3's
3
+ * fixed sentence appears in qmd's captured stderr/stdout. Accepts either the
4
+ * thrown error (reads its `stderr`/`stdout`) or a bare `{ stderr, stdout }`
5
+ * probe object. A true result means the caller should print the classified
6
+ * remedy, exit non-zero, and SKIP Sentry capture.
7
+ */
8
+ export declare function isQmdStoreMissingError(err: unknown): boolean;
9
+ /**
10
+ * If `err` is a qmd store-directory-missing failure, return the actionable
11
+ * remedy; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
12
+ * qmdMissingCollectionMessage so the top-level handler can branch on it the same
13
+ * way: a non-null result means print-and-skip-Sentry, null means "handle as
14
+ * usual (capture to Sentry)". The store directory and errno reason are read
15
+ * STRUCTURALLY from the error's own hq-populated `storeDir`/`ensureReason`
16
+ * fields (absent on a bare probe object — then the message names neither), never
17
+ * from qmd's output.
18
+ */
19
+ export declare function qmdStoreMissingMessage(err: unknown): string | null;
20
+ //# sourceMappingURL=qmd-store-missing-error.d.ts.map
@@ -0,0 +1,96 @@
1
+ // src/utils/qmd-store-missing-error.ts
2
+ //
3
+ // Classify a qmd failure caused by a MISSING STORE DIRECTORY — the directory
4
+ // qmd opens its SQLite index in does not exist — rather than an hq-cli code
5
+ // defect. This is the caller's LOCAL filesystem/environment (a pruned cache dir,
6
+ // a fresh machine, an INDEX_PATH pointing into a deleted tree, or a directory hq
7
+ // could not create because of permissions / a read-only mount), not a bug HQ can
8
+ // fix in code, so the CLI surfaces an actionable remedy and SKIPS Sentry
9
+ // capture. Sibling of qmd-native-binding-error.ts (HQ-CLI-J, unbuilt bindings),
10
+ // environmental-error.ts (HQ-CLI-2, full disk), and network-transport-error.ts
11
+ // (HQ-CLI-G, connectivity): a failure that is NOT an hq-cli defect is printed
12
+ // with an actionable message and never filed as a crash.
13
+ //
14
+ // HQ-CLI-16 (Sentry indigo-d0/hq-cli 7698235964): `hq index status` ran `qmd
15
+ // collection list`, and @tobilu/qmd's better-sqlite3 threw `TypeError: Cannot
16
+ // open database because the directory does not exist` the moment it opened its
17
+ // store — qmd's getDefaultDbPath returns $INDEX_PATH verbatim before any mkdir,
18
+ // else resolves ${XDG_CACHE_HOME||~/.cache}/qmd behind a mkdir whose failure it
19
+ // swallows. runQmd wrapped the exit-1 in a plain QmdExitError, index-cmd's catch
20
+ // re-threw it (narrowed to the sibling native-binding case only), and it reached
21
+ // the boundary's final else, which captured it — an unfixable, per-user "crash"
22
+ // whose title carries the caller's home path.
23
+ //
24
+ // Matching is deliberately narrow so it can neither be tripped by user input nor
25
+ // silence a real bug: better-sqlite3's FIXED sentence is required in qmd's OWN
26
+ // captured streams (stderr/stdout), NEVER the synthesized `message` — which
27
+ // echoes the caller's argv — so a search query that merely contains the phrase
28
+ // can never classify. Every other qmd store failure (a locked db, a corrupt
29
+ // store, an unknown exit) matches none of this and stays a reportable
30
+ // QmdExitError.
31
+ /** better-sqlite3's exact "directory does not exist" sentence — sufficient on its own. */
32
+ const STORE_DIR_MISSING = /Cannot open database because the directory does not exist/i;
33
+ /**
34
+ * qmd's OWN captured streams (stderr then stdout), joined. The synthesized
35
+ * `message` is deliberately NOT consulted: it embeds the caller's qmd arguments,
36
+ * so reading it would let a user query for this phrase trip the classifier.
37
+ */
38
+ function capturedStreams(err) {
39
+ if (err === null || typeof err !== "object")
40
+ return "";
41
+ const record = err;
42
+ const stderr = typeof record.stderr === "string" ? record.stderr : "";
43
+ const stdout = typeof record.stdout === "string" ? record.stdout : "";
44
+ return `${stderr}\n${stdout}`;
45
+ }
46
+ /**
47
+ * True when `err` is a qmd store-directory-missing failure: better-sqlite3's
48
+ * fixed sentence appears in qmd's captured stderr/stdout. Accepts either the
49
+ * thrown error (reads its `stderr`/`stdout`) or a bare `{ stderr, stdout }`
50
+ * probe object. A true result means the caller should print the classified
51
+ * remedy, exit non-zero, and SKIP Sentry capture.
52
+ */
53
+ export function isQmdStoreMissingError(err) {
54
+ return STORE_DIR_MISSING.test(capturedStreams(err));
55
+ }
56
+ /**
57
+ * The actionable remedy, naming the store directory that could not be opened and
58
+ * (when the store-directory ensure step recorded one) the errno reason it could
59
+ * not be created. Both are hq-DERIVED values — the resolved store path and a
60
+ * bounded errno phrase — never caller argv/query or upstream free text, so the
61
+ * line stays input-free. Deliberately does NOT point the user at `hq index sync`:
62
+ * reconciliation begins with the same `qmd collection list` and would hit the
63
+ * identical failure, a dead-end loop.
64
+ */
65
+ function remedyMessage(storeDir, ensureReason) {
66
+ const where = storeDir
67
+ ? `hq's local search store directory (${storeDir}) could not be opened`
68
+ : "hq's local search store directory could not be opened";
69
+ const because = ensureReason
70
+ ? ` — hq could not create it: ${ensureReason}.`
71
+ : " — it does not exist.";
72
+ return (`${where}${because} This is your machine's filesystem, not an hq bug: ` +
73
+ "check that the directory (and its parent) exist and are writable, or point " +
74
+ "INDEX_PATH at a writable location, then run your command again.");
75
+ }
76
+ /**
77
+ * If `err` is a qmd store-directory-missing failure, return the actionable
78
+ * remedy; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
79
+ * qmdMissingCollectionMessage so the top-level handler can branch on it the same
80
+ * way: a non-null result means print-and-skip-Sentry, null means "handle as
81
+ * usual (capture to Sentry)". The store directory and errno reason are read
82
+ * STRUCTURALLY from the error's own hq-populated `storeDir`/`ensureReason`
83
+ * fields (absent on a bare probe object — then the message names neither), never
84
+ * from qmd's output.
85
+ */
86
+ export function qmdStoreMissingMessage(err) {
87
+ if (!isQmdStoreMissingError(err))
88
+ return null;
89
+ const record = err;
90
+ const storeDir = typeof record.storeDir === "string" && record.storeDir.length > 0 ? record.storeDir : null;
91
+ const ensureReason = typeof record.ensureReason === "string" && record.ensureReason.length > 0
92
+ ? record.ensureReason
93
+ : null;
94
+ return remedyMessage(storeDir, ensureReason);
95
+ }
96
+ //# sourceMappingURL=qmd-store-missing-error.js.map
@@ -82,6 +82,7 @@ const KNOWN_ERROR_NAMES = new Set([
82
82
  "QmdTerminatedError",
83
83
  "QmdLlmDisabledError",
84
84
  "QmdModuleMissingError",
85
+ "QmdStoreMissingError",
85
86
  ]);
86
87
  /** Fixed bucket for any error name outside the closed allowlist. */
87
88
  const FALLBACK_ERROR_NAME = "other";
@@ -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.33",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {