@indigoai-us/hq-cli 5.103.32 → 5.103.34

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.34] — 2026-08-30
6
+
7
+ ## [5.103.33] — 2026-08-30
8
+
5
9
  ## [5.103.32] — 2026-08-29
6
10
 
7
11
  ## [5.103.31] — 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));
@@ -136,11 +136,17 @@ export function resolveSkillUid(target, cwd) {
136
136
  return uid;
137
137
  }
138
138
  export function canonicalCompanySkillPath(hqRoot, companySlug, skillSlug) {
139
+ // HQ-CLI-17 (Sentry 7699881899): both validators reject CALLER-supplied input
140
+ // — the `--company` slug and the `create <slug>` argument. A malformed value is
141
+ // the caller's request, not an hq-cli defect, so throw `localSkillError`
142
+ // (expected: true) and let the top-level boundary print the remedy and skip
143
+ // Sentry, exactly as the sibling caller-input throws in this module already do.
144
+ // The message text is unchanged; only its CLASS changes.
139
145
  if (!COMPANY_SLUG_PATTERN.test(companySlug)) {
140
- throw new Error("Company slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
146
+ throw localSkillError("Company slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
141
147
  }
142
148
  if (!SKILL_SLUG_PATTERN.test(skillSlug)) {
143
- throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
149
+ throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
144
150
  }
145
151
  return path.join(hqRoot, "companies", companySlug, "skills", skillSlug, "SKILL.md");
146
152
  }
@@ -277,7 +283,10 @@ export function registerSkillCommand(program, deps = {}) {
277
283
  .option("--surface-only", "Refresh local skill discovery without registration or sync")
278
284
  .action(async (slug, opts) => {
279
285
  if (!SKILL_SLUG_PATTERN.test(slug)) {
280
- throw new Error("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
286
+ // HQ-CLI-17: the `create <slug>` argument is caller input; a malformed
287
+ // slug is a correctly-enforced refusal, not a crash. Mark it expected
288
+ // (via localSkillError) so it is printed and skipped for Sentry.
289
+ throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
281
290
  }
282
291
  const parentOpts = skill.opts();
283
292
  const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
@@ -285,7 +294,13 @@ export function registerSkillCommand(program, deps = {}) {
285
294
  const filePath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
286
295
  const legacyPrefix = readCompanyPrefix(resolvedRoot, companySlug);
287
296
  if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
288
- throw new Error(`Expected a SKILL.md file at '${filePath}'.`);
297
+ // HQ-CLI-17: the target path is derived from caller input and its
298
+ // on-disk shape is the caller's own filesystem, not an hq-cli defect.
299
+ // The bare Error also embedded this absolute (home-relative) path, so it
300
+ // minted a NEW Sentry fingerprint per machine. Mark it expected so it is
301
+ // printed (with the path the caller needs) and never captured. Redaction
302
+ // in localSkillError scrubs credentials but leaves plain paths intact.
303
+ throw localSkillError(`Expected a SKILL.md file at '${filePath}'.`);
289
304
  }
290
305
  if (opts.surfaceOnly === true) {
291
306
  // Discovery wrappers are execution surfaces. Never create one for an
@@ -396,8 +411,10 @@ export function registerSkillCommand(program, deps = {}) {
396
411
  .requiredOption("-m, --message <text>", "The improvement to discuss")
397
412
  .action(async (target, opts) => {
398
413
  const message = opts.message.trim();
414
+ // HQ-CLI-17: a blank `-m/--message` is caller input, not an hq-cli defect.
415
+ // Mark it expected so it is printed and skipped for Sentry.
399
416
  if (!message)
400
- throw new Error("An improvement message is required.");
417
+ throw localSkillError("An improvement message is required.");
401
418
  const parentOpts = skill.opts();
402
419
  const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
403
420
  const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
@@ -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
@@ -71,11 +71,14 @@ import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-erro
71
71
  import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
72
72
  import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
73
73
  import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
74
+ import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
74
75
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
75
76
  import { isEpipe } from "./utils/epipe.js";
76
77
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
77
78
  import { isAuthError } from "./utils/auth-error.js";
78
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";
79
82
  import { isCompanySelectionError } from "./utils/company-selection-error.js";
80
83
  import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
81
84
  import { upgradeToTeam } from "./utils/team-upgrade.js";
@@ -413,6 +416,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
413
416
  deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
414
417
  deps.setExitCode(1);
415
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
+ }
416
436
  else if (isPlanGateError(err)) {
417
437
  // hq-pro's plan denials are expected product limits, never a CLI crash.
418
438
  // The shared vault client has already decoded and typed the small safe
@@ -521,7 +541,22 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
521
541
  const moduleMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
522
542
  ? null
523
543
  : qmdModuleMissingMessage(err);
524
- 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
525
560
  ? null
526
561
  : environmentalFsErrorMessage(err);
527
562
  // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
@@ -538,7 +573,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
538
573
  // environmental-fs check, before network-transport — is pinned by tests.
539
574
  // The `in-process-async-holder` reason is deliberately NOT suppressed here
540
575
  // (see sync-state-lock-error.ts); it stays captured.
541
- const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
576
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || envMsg
542
577
  ? null
543
578
  : syncStateLockMessage(err);
544
579
  // A raw network transport failure (undici's `TypeError: fetch failed`
@@ -551,7 +586,14 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
551
586
  // message that names the unreachable host, exit 1, and skip Sentry.
552
587
  // Ordered after the environmental check so a full disk keeps its exact
553
588
  // existing message.
554
- const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg || lockMsg
589
+ const transportMsg = qmdMsg ||
590
+ collectionMsg ||
591
+ terminatedMsg ||
592
+ llmDisabledMsg ||
593
+ moduleMissingMsg ||
594
+ storeMissingMsg ||
595
+ envMsg ||
596
+ lockMsg
555
597
  ? null
556
598
  : networkTransportErrorMessage(err);
557
599
  if (qmdMsg) {
@@ -569,6 +611,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
569
611
  else if (moduleMissingMsg) {
570
612
  deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
571
613
  }
614
+ else if (storeMissingMsg) {
615
+ deps.stderr.write(`hq: ${storeMissingMsg}\n`);
616
+ }
572
617
  else if (envMsg) {
573
618
  deps.stderr.write(`hq: ${envMsg}\n`);
574
619
  }
@@ -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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.103.32",
3
+ "version": "5.103.34",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {