@indigoai-us/hq-cli 5.96.0 → 5.97.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.97.1]
6
+
7
+ ### Fixed
8
+
9
+ - Search commands no longer crash — or file a Sentry report — when the bundled
10
+ qmd's native module was never built. pnpm 10 skips dependency build scripts by
11
+ default, so `pnpm add @indigoai-us/hq-cli` lands with better-sqlite3
12
+ uncompiled; the bundled `@tobilu/qmd` then died with "Could not locate the
13
+ bindings file" the moment it opened its SQLite store, and `hq index status`
14
+ reported that as an hq-cli crash (HQ-CLI-J, Sentry 7656662146). Three things
15
+ changed. The qmd usability probe now actually opens a store (a throwaway one,
16
+ so the real index is never touched) instead of running `qmd --version`, which
17
+ exited 0 even on a bindings-broken install — the false positive that let this
18
+ ship in 5.94.2. That probe now runs on every resolution, including when there
19
+ is no qmd on PATH to fall back to, so a broken bundled binary is caught rather
20
+ than blindly preferred. And a broken bundled qmd now self-heals: hq runs
21
+ better-sqlite3's own install step (`prebuild-install`, then
22
+ `node-gyp rebuild --release`) once, inside its own dependency tree, under a
23
+ hard timeout, before giving up — set `HQ_QMD_NO_REPAIR=1` to opt out. When the
24
+ module still cannot load, hq prints an actionable remedy (approve the build and
25
+ reinstall — `pnpm approve-builds -g better-sqlite3` for a global install, or
26
+ without `-g` for a local project, or reinstall with npm), exits 1, and skips
27
+ Sentry; `hq index status` degrades to a status block that
28
+ names the problem rather than throwing. Any other qmd failure, and any genuine
29
+ hq-cli defect, still reports as before. Note: hq-cli's own pnpm build approval
30
+ only applies when hq-cli is the root project, never on a consumer install, so
31
+ the runtime probe and self-heal are what make a pnpm-installed host recover.
32
+ (#349)
33
+
34
+ ## [5.97.0]
35
+
36
+ ### Added
37
+
38
+ - `hq reindex` now keeps files over GitHub's 100MB limit out of the HQ repo.
39
+ The HQ root is a git repo that `git add -A` sweeps wholesale after every
40
+ sync, and session logs and package stores routinely pass that limit — once
41
+ such a blob is committed, every later push is rejected with GH001
42
+ permanently, because the blob is in history. Oversized files now gain an
43
+ anchored `.gitignore` rule, and any already in the index are dropped with
44
+ `git rm --cached`, which leaves the working-tree file untouched. Every
45
+ affected path is named on stdout, since untracking mutates the user's index
46
+ and must never be silent. The scan runs through git rather than the
47
+ filesystem, so nested checkouts under `repos/` are excluded automatically,
48
+ and it is throttled to once an hour per root so the hook-driven reindex path
49
+ stays cheap. (#346)
50
+
5
51
  ## [5.96.0]
6
52
 
7
53
  ### Changed
@@ -1,7 +1,8 @@
1
1
  import { Option } from 'commander';
2
- import { deriveCollections, listRegisteredCollections, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
2
+ import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcileCollections, resolveQmdBin, resolveQmdVersion, runQmd, } from '../lib/search-index/index.js';
3
3
  import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
4
4
  import { findHqRoot } from '../utils/manifest.js';
5
+ import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
5
6
  const defaults = {
6
7
  reconcileCollections,
7
8
  deriveCollections,
@@ -93,17 +94,42 @@ export function registerIndexCommand(program, dependencies = defaults) {
93
94
  .action((options) => {
94
95
  const hqRoot = resolveRoot(options.hqRoot);
95
96
  const bin = dependencies.resolveQmdBin();
96
- const registered = dependencies.listRegisteredCollections(hqRoot, { bin, cwd: hqRoot });
97
+ // resolveQmdVersion() reads the BUNDLED @tobilu/qmd manifest, so it is
98
+ // only accurate for the bundled binary. When the store-opening probe
99
+ // rejects the bundled qmd and a PATH (or HQ_QMD_BIN) qmd wins, pairing its
100
+ // path with the bundled version would misreport it — omit it instead.
101
+ const qmdVersion = bin === packageLocalBin() ? dependencies.resolveQmdVersion() : undefined;
97
102
  const expected = dependencies.deriveCollections(hqRoot);
98
- const qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
99
- const qmdVersion = dependencies.resolveQmdVersion();
100
103
  const background = (dependencies.backgroundStatus ?? backgroundStatus)(makeBackgroundDependencies(hqRoot, dependencies));
101
104
  console.log(`qmd: ${bin}${qmdVersion ? ` (version ${qmdVersion})` : ''}`);
102
- console.log(collectionSummary(expected, registered));
105
+ // The bundled qmd can be present yet unable to open its SQLite store when
106
+ // pnpm 10 left better-sqlite3 unbuilt (HQ-CLI-J). Degrade rather than
107
+ // throwing QmdExitError out of the command: still report everything
108
+ // derivable without qmd, print the reason + remedy on stderr, and exit 1 —
109
+ // this diagnostic command should describe the broken install, not become a
110
+ // Sentry crash. Any OTHER qmd failure keeps propagating and reporting.
111
+ let registered;
112
+ let qmdStatus;
113
+ try {
114
+ registered = dependencies.listRegisteredCollections(hqRoot, { bin, cwd: hqRoot });
115
+ qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
116
+ }
117
+ catch (error) {
118
+ if (!isQmdNativeBindingError(error))
119
+ throw error;
120
+ process.stderr.write(`qmd: unusable — native bindings unbuilt. ${QMD_NATIVE_BINDING_REMEDY}\n`);
121
+ process.exitCode = 1;
122
+ }
123
+ if (registered) {
124
+ console.log(collectionSummary(expected, registered));
125
+ }
126
+ else {
127
+ console.log(`collections: unavailable (qmd unusable); ${expected.length} expected`);
128
+ }
103
129
  console.log(`background: lock ${background.lock}; last completed ${background.completedAt ?? 'never'}`);
104
- if (qmdStatus.stdout)
130
+ if (qmdStatus?.stdout)
105
131
  process.stdout.write(qmdStatus.stdout);
106
- if (qmdStatus.stderr)
132
+ if (qmdStatus?.stderr)
107
133
  process.stderr.write(qmdStatus.stderr);
108
134
  });
109
135
  }
@@ -30,6 +30,7 @@ import * as yaml from 'js-yaml';
30
30
  import { reindex, rescue } from '@indigoai-us/hq-cloud';
31
31
  import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
32
32
  import { findHqRoot } from '../utils/manifest.js';
33
+ import { guardLargeFiles } from '../utils/large-file-guard.js';
33
34
  const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
34
35
  const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
35
36
  /** Resolve the same root the repair/check commands must operate on. */
@@ -205,6 +206,25 @@ export function repairExtremeHookDrift(hqRoot, allowRepair = true) {
205
206
  printHookHealthWarning(hqRoot, 'hook configuration repair could not run');
206
207
  }
207
208
  }
209
+ /**
210
+ * Keep files above GitHub's 100MB limit out of the HQ repo, and say so.
211
+ *
212
+ * Untracking is a mutation of the user's index, so it is never silent: every
213
+ * affected path is named on stdout. Throttled internally to one scan per hour
214
+ * per root, so the hook-driven reindex path stays cheap.
215
+ */
216
+ function reportLargeFileGuard(hqRoot) {
217
+ const { scanned, ignored, untracked } = guardLargeFiles(hqRoot);
218
+ if (!scanned)
219
+ return;
220
+ const removed = new Set(untracked);
221
+ for (const file of untracked) {
222
+ console.log(`reindex: ${file} exceeds GitHub's 100MB limit — removed from git tracking and ignored (the file itself is untouched)`);
223
+ }
224
+ for (const file of ignored.filter((f) => !removed.has(f))) {
225
+ console.log(`reindex: ${file} exceeds GitHub's 100MB limit — added to .gitignore`);
226
+ }
227
+ }
208
228
  export function registerReindexCommand(program) {
209
229
  program
210
230
  .command('reindex')
@@ -239,6 +259,9 @@ export function registerReindexCommand(program) {
239
259
  repairExtremeHookDrift(hqRoot, status === 0);
240
260
  if (status === 0)
241
261
  await trustHqRuntimeHooks(hqRoot);
262
+ // Runs even when reindex failed: a failed reindex does not make an
263
+ // oversized blob any less likely to wedge the next push.
264
+ reportLargeFileGuard(hqRoot);
242
265
  process.exit(status);
243
266
  });
244
267
  }
@@ -28,7 +28,30 @@ export type ResolveQmdBinOptions = {
28
28
  packageBin?: () => string | undefined;
29
29
  pathBin?: () => string | undefined;
30
30
  isUsable?: (bin: string) => boolean;
31
+ /**
32
+ * Attempt a one-shot native-binding repair of a package-local qmd, returning
33
+ * whether the caller should re-probe. Injected in tests; the default is
34
+ * {@link repairQmdNativeBindings}.
35
+ */
36
+ repair?: (bin: string) => boolean;
31
37
  };
38
+ /**
39
+ * Locate the bundled qmd through the dependency-bin link the package manager
40
+ * installs, walking up from this module.
41
+ *
42
+ * Two earlier approaches were wrong and both failed silently, so every host fell
43
+ * through to PATH and a machine without a global qmd reported "skipped" — which
44
+ * defeats the point of depending on qmd at all:
45
+ *
46
+ * - `<pkg>/qmd` guessed a filename; the package declares `bin/qmd`.
47
+ * - `require.resolve('@tobilu/qmd/package.json')` throws
48
+ * ERR_PACKAGE_PATH_NOT_EXPORTED on qmd 2.x, whose `exports` map does not
49
+ * expose package.json.
50
+ *
51
+ * `node_modules/.bin/qmd` is the contract every package manager honours and is
52
+ * independent of the dependency's own exports map.
53
+ */
54
+ export declare function packageLocalBin(): string | undefined;
32
55
  /**
33
56
  * Return the pinned package version when qmd is supplied by this CLI.
34
57
  *
@@ -37,6 +60,52 @@ export type ResolveQmdBinOptions = {
37
60
  * disappeared from `hq index status`. Same root cause as packageLocalBin.
38
61
  */
39
62
  export declare function resolveQmdVersion(): string | undefined;
63
+ /** Reset per-process probe/repair memoisation. Test-only. */
64
+ export declare function __resetQmdProbeStateForTests(): void;
65
+ export declare function isUsableQmd(bin: string): boolean;
66
+ /**
67
+ * Derive the better-sqlite3 PACKAGE directory qmd tried to load, from a bindings
68
+ * failure. `bindings` lists every path it tried, each ending in
69
+ * `.../better-sqlite3/<build-subdir>/better_sqlite3.node`; we take the package
70
+ * root (up to and including the `/better-sqlite3` segment). Returns undefined
71
+ * when the text carries no such path.
72
+ */
73
+ export declare function betterSqlite3DirFromFailure(detail: string): string | undefined;
74
+ /**
75
+ * A repair may write ONLY inside the same install tree as the resolved
76
+ * package-local qmd — never a PATH/system qmd, never a path outside that tree.
77
+ * Both the qmd bin and the better-sqlite3 dir must live under a shared
78
+ * `node_modules`/`.pnpm` ancestor; anything else is refused.
79
+ */
80
+ export declare function repairConfinedTo(qmdBin: string, betterSqlite3Dir: string): boolean;
81
+ type RepairSpawn = (cmd: string, args: string[], opts: {
82
+ cwd: string;
83
+ env: NodeJS.ProcessEnv;
84
+ timeout: number;
85
+ }) => {
86
+ status: number | null;
87
+ error?: Error;
88
+ };
89
+ export type QmdRepairOptions = {
90
+ env?: Record<string, string | undefined>;
91
+ /** Failure text to derive the better-sqlite3 dir from; defaults to the last probe's. */
92
+ failureDetail?: string;
93
+ /** Injected in tests so the real build tools are never spawned. */
94
+ spawn?: RepairSpawn;
95
+ };
96
+ /**
97
+ * Bounded, one-shot self-repair of a package-local qmd whose better-sqlite3
98
+ * native module was never built (pnpm 10 skips the build script for dependency
99
+ * installs). Runs better-sqlite3's own declared install step —
100
+ * `prebuild-install`, then `node-gyp rebuild --release` — inside the resolved
101
+ * package directory, under a hard timeout, at most once per process, then lets
102
+ * the caller re-probe. Returns whether `better_sqlite3.node` now exists.
103
+ *
104
+ * Every failure mode degrades to `false` (the caller then surfaces the
105
+ * classified native-binding remedy): it never throws, never retries, never
106
+ * waits unbounded, and never touches anything outside hq's own dependency tree.
107
+ */
108
+ export declare function repairQmdNativeBindings(bin: string, options?: QmdRepairOptions): boolean;
40
109
  /** Resolve qmd without relying on a globally installed copy. */
41
110
  export declare function resolveQmdBin(options?: ResolveQmdBinOptions): string;
42
111
  export type RunQmdOptions = {
@@ -62,4 +131,5 @@ export type ReconcileCollectionsOptions = {
62
131
  /** Register expected collections that qmd does not yet know about. */
63
132
  export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
64
133
  export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
134
+ export {};
65
135
  //# sourceMappingURL=index.d.ts.map
@@ -1,8 +1,10 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
+ import * as os from 'node:os';
4
5
  import * as path from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
7
+ import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
6
8
  const require = createRequire(import.meta.url);
7
9
  export class QmdBinaryMissingError extends Error {
8
10
  name = 'QmdBinaryMissingError';
@@ -49,7 +51,7 @@ function isExecutable(candidate) {
49
51
  * `node_modules/.bin/qmd` is the contract every package manager honours and is
50
52
  * independent of the dependency's own exports map.
51
53
  */
52
- function packageLocalBin() {
54
+ export function packageLocalBin() {
53
55
  const names = process.platform === 'win32' ? ['qmd.cmd', 'qmd.exe', 'qmd'] : ['qmd'];
54
56
  let directory = path.dirname(fileURLToPath(import.meta.url));
55
57
  for (let depth = 0; depth < 10; depth++) {
@@ -110,24 +112,239 @@ function pathBin() {
110
112
  }
111
113
  const usableQmdCache = new Map();
112
114
  /**
113
- * Does this qmd binary actually run? `--version` is the cheapest subcommand
114
- * that still loads the native module, which is exactly what fails when the
115
- * bindings are missing. Cached per path so resolution stays a single spawn.
115
+ * Why the last probe of a given bin failed (combined stderr+stdout). Lets the
116
+ * repair step decide whether the failure is a fixable native-binding problem
117
+ * and, from the bindings "Tried:" list, WHERE the unbuilt better-sqlite3 lives.
116
118
  */
117
- function isUsableQmd(bin) {
119
+ const lastProbeFailure = new Map();
120
+ /** At most one native-binding repair attempt per process (bounded, no retries). */
121
+ let nativeBindingRepairAttempted = false;
122
+ /** How long a single usability probe or repair sub-step may run. */
123
+ const PROBE_TIMEOUT_MS = 10_000;
124
+ const REPAIR_STEP_TIMEOUT_MS = 180_000;
125
+ /** A stale repair lock older than this is reclaimed rather than trusted. */
126
+ const REPAIR_LOCK_TTL_MS = 10 * 60_000;
127
+ /** Reset per-process probe/repair memoisation. Test-only. */
128
+ export function __resetQmdProbeStateForTests() {
129
+ usableQmdCache.clear();
130
+ lastProbeFailure.clear();
131
+ nativeBindingRepairAttempted = false;
132
+ }
133
+ /**
134
+ * Does this qmd binary actually OPEN ITS STORE? The 5.94.2 probe spawned
135
+ * `--version`, which prints and exits 0 even when better-sqlite3's native module
136
+ * is missing — the exact false positive that let this cluster ship (HQ-CLI-J):
137
+ * it passed on precisely the bindings-broken install it was added to reject.
138
+ * `collection list` instead opens the SQLite database, which is the operation
139
+ * that fails when `better_sqlite3.node` was never built.
140
+ *
141
+ * The probe is pointed at a throwaway INDEX_PATH / QMD_CONFIG_DIR / HOME so it
142
+ * NEVER creates or mutates the user's real qmd store, and is bounded by a hard
143
+ * timeout. Callers cache the boolean per path so resolution stays one spawn.
144
+ */
145
+ function probeQmd(bin) {
146
+ let scratch;
147
+ try {
148
+ scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-qmd-probe-'));
149
+ const env = {
150
+ ...process.env,
151
+ HOME: scratch,
152
+ QMD_CONFIG_DIR: path.join(scratch, 'config'),
153
+ INDEX_PATH: path.join(scratch, 'probe-index.db'),
154
+ XDG_CONFIG_HOME: path.join(scratch, 'xdg-config'),
155
+ XDG_CACHE_HOME: path.join(scratch, 'xdg-cache'),
156
+ };
157
+ const probe = spawnSync(bin, ['collection', 'list'], {
158
+ cwd: scratch,
159
+ env,
160
+ encoding: 'utf8',
161
+ timeout: PROBE_TIMEOUT_MS,
162
+ });
163
+ if (probe.error)
164
+ return { usable: false, detail: probe.error.message };
165
+ if (probe.status === 0)
166
+ return { usable: true, detail: '' };
167
+ return { usable: false, detail: `${probe.stderr ?? ''}\n${probe.stdout ?? ''}`.trim() };
168
+ }
169
+ catch (error) {
170
+ return { usable: false, detail: error instanceof Error ? error.message : String(error) };
171
+ }
172
+ finally {
173
+ if (scratch) {
174
+ try {
175
+ fs.rmSync(scratch, { recursive: true, force: true });
176
+ }
177
+ catch { /* best-effort cleanup */ }
178
+ }
179
+ }
180
+ }
181
+ export function isUsableQmd(bin) {
118
182
  const cached = usableQmdCache.get(bin);
119
183
  if (cached !== undefined)
120
184
  return cached;
121
- let usable = false;
185
+ const result = probeQmd(bin);
186
+ usableQmdCache.set(bin, result.usable);
187
+ if (result.usable)
188
+ lastProbeFailure.delete(bin);
189
+ else
190
+ lastProbeFailure.set(bin, result.detail);
191
+ return result.usable;
192
+ }
193
+ /** Native-binding self-repair is on unless explicitly disabled. */
194
+ function repairEnabled(env) {
195
+ const flag = env.HQ_QMD_NO_REPAIR;
196
+ return !(flag === '1' || flag === 'true');
197
+ }
198
+ /**
199
+ * Derive the better-sqlite3 PACKAGE directory qmd tried to load, from a bindings
200
+ * failure. `bindings` lists every path it tried, each ending in
201
+ * `.../better-sqlite3/<build-subdir>/better_sqlite3.node`; we take the package
202
+ * root (up to and including the `/better-sqlite3` segment). Returns undefined
203
+ * when the text carries no such path.
204
+ */
205
+ export function betterSqlite3DirFromFailure(detail) {
206
+ const match = /([^\s'"]+[/\\]better-sqlite3)[/\\][^\s'"]*better_sqlite3\.node/.exec(detail);
207
+ return match ? match[1] : undefined;
208
+ }
209
+ /**
210
+ * A repair may write ONLY inside the same install tree as the resolved
211
+ * package-local qmd — never a PATH/system qmd, never a path outside that tree.
212
+ * Both the qmd bin and the better-sqlite3 dir must live under a shared
213
+ * `node_modules`/`.pnpm` ancestor; anything else is refused.
214
+ */
215
+ export function repairConfinedTo(qmdBin, betterSqlite3Dir) {
216
+ const binDir = path.resolve(path.dirname(qmdBin));
217
+ const target = path.resolve(betterSqlite3Dir);
218
+ if (!/(?:^|[/\\])node_modules(?:[/\\]|$)/.test(target))
219
+ return false;
220
+ const binSegments = binDir.split(/[/\\]/);
221
+ const targetSegments = target.split(/[/\\]/);
222
+ let shared = 0;
223
+ while (shared < binSegments.length &&
224
+ shared < targetSegments.length &&
225
+ binSegments[shared] === targetSegments[shared]) {
226
+ shared++;
227
+ }
228
+ const commonAncestor = binSegments.slice(0, shared);
229
+ // The common ancestor must itself pass THROUGH the dependency tree: a shared
230
+ // `node_modules` or pnpm `.pnpm` store proves both paths belong to the same
231
+ // install, which a system path (e.g. /usr/bin) can never satisfy.
232
+ return commonAncestor.includes('node_modules') || commonAncestor.includes('.pnpm');
233
+ }
234
+ const defaultRepairSpawn = (cmd, args, opts) => {
235
+ const result = spawnSync(cmd, args, {
236
+ cwd: opts.cwd,
237
+ env: opts.env,
238
+ timeout: opts.timeout,
239
+ encoding: 'utf8',
240
+ stdio: 'ignore',
241
+ });
242
+ return { status: result.status, error: result.error };
243
+ };
244
+ /**
245
+ * Bounded, one-shot self-repair of a package-local qmd whose better-sqlite3
246
+ * native module was never built (pnpm 10 skips the build script for dependency
247
+ * installs). Runs better-sqlite3's own declared install step —
248
+ * `prebuild-install`, then `node-gyp rebuild --release` — inside the resolved
249
+ * package directory, under a hard timeout, at most once per process, then lets
250
+ * the caller re-probe. Returns whether `better_sqlite3.node` now exists.
251
+ *
252
+ * Every failure mode degrades to `false` (the caller then surfaces the
253
+ * classified native-binding remedy): it never throws, never retries, never
254
+ * waits unbounded, and never touches anything outside hq's own dependency tree.
255
+ */
256
+ export function repairQmdNativeBindings(bin, options = {}) {
122
257
  try {
123
- const probe = spawnSync(bin, ['--version'], { encoding: 'utf8', timeout: 10_000 });
124
- usable = !probe.error && probe.status === 0;
258
+ const detail = options.failureDetail ?? lastProbeFailure.get(bin) ?? '';
259
+ if (!isQmdNativeBindingError(detail))
260
+ return false;
261
+ const betterSqlite3Dir = betterSqlite3DirFromFailure(detail);
262
+ if (!betterSqlite3Dir || !repairConfinedTo(bin, betterSqlite3Dir))
263
+ return false;
264
+ const artifact = path.join(betterSqlite3Dir, 'build', 'Release', 'better_sqlite3.node');
265
+ // Idempotent: a concurrent or earlier process may already have built it.
266
+ if (fs.existsSync(artifact))
267
+ return true;
268
+ if (nativeBindingRepairAttempted)
269
+ return false;
270
+ nativeBindingRepairAttempted = true;
271
+ const lockDir = path.join(betterSqlite3Dir, '.hq-qmd-repair.lock');
272
+ if (!acquireRepairLock(lockDir)) {
273
+ // Another process holds a fresh lock; do not pile a second build on top.
274
+ // Re-check the artifact once (it may have just finished) and defer.
275
+ return fs.existsSync(artifact);
276
+ }
277
+ try {
278
+ const env = { ...process.env, ...(options.env ?? {}) };
279
+ const spawn = options.spawn ?? defaultRepairSpawn;
280
+ runBetterSqlite3Install(betterSqlite3Dir, env, spawn);
281
+ }
282
+ finally {
283
+ try {
284
+ fs.rmSync(lockDir, { recursive: true, force: true });
285
+ }
286
+ catch { /* best-effort */ }
287
+ }
288
+ return fs.existsSync(artifact);
289
+ }
290
+ catch {
291
+ // Repair is strictly best-effort: any unexpected failure degrades to the
292
+ // classified native-binding remedy rather than crashing the resolver.
293
+ return false;
294
+ }
295
+ }
296
+ /** Acquire a bounded, TTL-guarded repair lock. No PID liveness, no wait loop. */
297
+ function acquireRepairLock(lockDir) {
298
+ try {
299
+ fs.mkdirSync(lockDir);
300
+ return true;
125
301
  }
126
302
  catch {
127
- usable = false;
303
+ // Reclaim a stale lock (a crashed prior repair); otherwise defer.
304
+ try {
305
+ const age = Date.now() - fs.statSync(lockDir).mtimeMs;
306
+ if (age > REPAIR_LOCK_TTL_MS) {
307
+ fs.rmSync(lockDir, { recursive: true, force: true });
308
+ fs.mkdirSync(lockDir);
309
+ return true;
310
+ }
311
+ }
312
+ catch { /* lock vanished or unreadable: fall through to defer */ }
313
+ return false;
314
+ }
315
+ }
316
+ /**
317
+ * Resolve a dependency executable better-sqlite3 would run at install time. pnpm
318
+ * links a package's dependency bins into the SIBLING `.bin` of its virtual-store
319
+ * `node_modules` (`.pnpm/better-sqlite3@x/node_modules/.bin/<tool>`), NOT inside
320
+ * `better-sqlite3/node_modules/.bin`; npm's hoisted layout and some pnpm configs
321
+ * use the nested form. Check both so the self-repair works on the pnpm-installed
322
+ * hosts it exists for. Both candidates sit inside the already-confined tree.
323
+ */
324
+ function resolveRepairTool(betterSqlite3Dir, tool) {
325
+ const candidates = [
326
+ path.join(path.dirname(betterSqlite3Dir), '.bin', tool), // pnpm virtual-store sibling
327
+ path.join(betterSqlite3Dir, 'node_modules', '.bin', tool), // nested (npm / some pnpm)
328
+ ];
329
+ for (const candidate of candidates) {
330
+ if (fs.existsSync(candidate))
331
+ return candidate;
332
+ }
333
+ return undefined;
334
+ }
335
+ /** Run better-sqlite3's declared install step: prebuild-install, then node-gyp. */
336
+ function runBetterSqlite3Install(dir, env, spawn) {
337
+ const artifact = path.join(dir, 'build', 'Release', 'better_sqlite3.node');
338
+ const prebuild = resolveRepairTool(dir, 'prebuild-install');
339
+ if (prebuild) {
340
+ spawn(prebuild, [], { cwd: dir, env, timeout: REPAIR_STEP_TIMEOUT_MS });
341
+ if (fs.existsSync(artifact))
342
+ return;
343
+ }
344
+ const nodeGyp = resolveRepairTool(dir, 'node-gyp');
345
+ if (nodeGyp) {
346
+ spawn(nodeGyp, ['rebuild', '--release'], { cwd: dir, env, timeout: REPAIR_STEP_TIMEOUT_MS });
128
347
  }
129
- usableQmdCache.set(bin, usable);
130
- return usable;
131
348
  }
132
349
  /** Resolve qmd without relying on a globally installed copy. */
133
350
  export function resolveQmdBin(options = {}) {
@@ -136,6 +353,8 @@ export function resolveQmdBin(options = {}) {
136
353
  const probes = [];
137
354
  const override = env.HQ_QMD_BIN;
138
355
  if (override) {
356
+ // An explicit user choice is honoured verbatim: never probe or repair a
357
+ // binary the operator pointed us at.
139
358
  if (executable(override))
140
359
  return override;
141
360
  probes.push(`HQ_QMD_BIN (${override})`);
@@ -146,26 +365,42 @@ export function resolveQmdBin(options = {}) {
146
365
  const installed = (options.packageBin ?? packageLocalBin)();
147
366
  const onPath = (options.pathBin ?? pathBin)();
148
367
  const fallback = onPath && executable(onPath) ? onPath : undefined;
368
+ const isUsable = options.isUsable ?? isUsableQmd;
149
369
  if (installed && executable(installed)) {
150
- // Executable is not the same as usable: a global install can ship a qmd
151
- // whose native better-sqlite3 bindings were never compiled, so every
152
- // invocation crashes. Preferring it blindly turned a dormant packaging
153
- // problem into broken search on hosts that had a working qmd on PATH.
154
- //
155
- // The runnability probe costs a ~0.3s spawn, so only pay it when there is
156
- // something to fall back TO. With no alternative the answer is the bundled
157
- // binary either way, and a failure there now surfaces loudly rather than
158
- // silently, so probing would buy nothing but latency on the hot path.
159
- if (!fallback || (options.isUsable ?? isUsableQmd)(installed))
370
+ // Executable is not the same as usable: pnpm 10 skips better-sqlite3's build
371
+ // script for dependency installs, so the bundled qmd can be present yet
372
+ // unable to open its SQLite store. ALWAYS probe the 5.94.2 short-circuit
373
+ // that skipped the probe when no PATH fallback existed is exactly how the
374
+ // outpost in HQ-CLI-J was handed the broken binary — and attempt one bounded
375
+ // native-binding repair before giving up on it.
376
+ if (isUsable(installed))
160
377
  return installed;
161
- probes.push(`package-local @tobilu/qmd (${installed}, present but not runnable)`);
378
+ if (repairEnabled(env)) {
379
+ const repair = options.repair ?? repairQmdNativeBindings;
380
+ if (repair(installed)) {
381
+ usableQmdCache.delete(installed);
382
+ if (isUsable(installed))
383
+ return installed;
384
+ }
385
+ }
386
+ probes.push(`package-local @tobilu/qmd (${installed}, present but native bindings unbuilt)`);
162
387
  }
163
388
  else {
164
389
  probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
165
390
  }
391
+ // A working qmd on PATH is the right answer when the bundled one is broken —
392
+ // the fallback the 5.94.2 fix intended but its short-circuit never reached.
166
393
  if (fallback)
167
394
  return fallback;
168
395
  probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
396
+ // Last resort: no usable qmd anywhere, but the bundled binary IS present. Hand
397
+ // it back rather than throwing an opaque "qmd missing" error — running it
398
+ // yields a QmdExitError whose native-binding signature the top-level handler
399
+ // turns into an actionable remedy (and skips Sentry). This is the "never
400
+ // return an unusable binary SILENTLY" contract: we probed, we tried repair,
401
+ // and the ensuing failure is classified, not swallowed.
402
+ if (installed && executable(installed))
403
+ return installed;
169
404
  throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
170
405
  }
171
406
  function defaultRunner(bin, args, options) {
package/dist/main.js CHANGED
@@ -62,6 +62,7 @@ import { registerIndexCommand } from "./commands/index-cmd.js";
62
62
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
63
63
  import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
64
64
  import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
65
+ import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
65
66
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
66
67
  import { isEpipe } from "./utils/epipe.js";
67
68
  import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
@@ -374,7 +375,15 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
374
375
  // skip Sentry capture so one full disk doesn't flood the tracker with
375
376
  // identical, unfixable crash reports (HQ-CLI-2). Genuine errors still go
376
377
  // to Sentry and still exit 1.
377
- const envMsg = environmentalFsErrorMessage(err);
378
+ // A qmd failure caused by an unbuilt better-sqlite3 native module is the
379
+ // caller's install shape (pnpm 10 skips dependency build scripts), not an
380
+ // hq-cli defect. Before this branch it fell through to the capture below
381
+ // and filed 7 identical, unfixable "crashes" (HQ-CLI-J, Sentry
382
+ // 7656662146). Print the actionable remedy, exit 1, and skip Sentry.
383
+ // Ordered FIRST so the narrow native-binding signature wins over the
384
+ // broader environmental / transport / generic branches.
385
+ const qmdMsg = qmdNativeBindingErrorMessage(err);
386
+ const envMsg = qmdMsg ? null : environmentalFsErrorMessage(err);
378
387
  // A raw network transport failure (undici's `TypeError: fetch failed`
379
388
  // with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
380
389
  // caller's connectivity, not an hq-cli defect. Before this branch it fell
@@ -385,8 +394,11 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
385
394
  // message that names the unreachable host, exit 1, and skip Sentry.
386
395
  // Ordered after the environmental check so a full disk keeps its exact
387
396
  // existing message.
388
- const transportMsg = envMsg ? null : networkTransportErrorMessage(err);
389
- if (envMsg) {
397
+ const transportMsg = qmdMsg || envMsg ? null : networkTransportErrorMessage(err);
398
+ if (qmdMsg) {
399
+ deps.stderr.write(`hq: ${qmdMsg}\n`);
400
+ }
401
+ else if (envMsg) {
390
402
  deps.stderr.write(`hq: ${envMsg}\n`);
391
403
  }
392
404
  else if (transportMsg) {
@@ -0,0 +1,39 @@
1
+ /** GitHub rejects any single file above this size. */
2
+ export declare const DEFAULT_THRESHOLD_BYTES: number;
3
+ /** At most one scan per hour per root. */
4
+ export declare const SCAN_THROTTLE_MS: number;
5
+ export interface LargeFileGuardResult {
6
+ /** False when the root is not a git repo, or the scan was throttled. */
7
+ scanned: boolean;
8
+ /** Repo-relative paths newly added to `.gitignore`. */
9
+ ignored: string[];
10
+ /** Repo-relative paths removed from the index (still on disk). */
11
+ untracked: string[];
12
+ }
13
+ export interface LargeFileGuardOptions {
14
+ /** Defaults to {@link DEFAULT_THRESHOLD_BYTES}. */
15
+ thresholdBytes?: number;
16
+ /** Injectable clock, in epoch milliseconds. */
17
+ now?: number;
18
+ /** Bypass the throttle. */
19
+ force?: boolean;
20
+ }
21
+ /**
22
+ * Render a repo-relative path as a literal `.gitignore` pattern.
23
+ *
24
+ * The leading slash anchors it to the repo root — without it a bare filename
25
+ * would also ignore same-named files in every subdirectory. Glob and
26
+ * character-class metacharacters are escaped so the rule matches the one real
27
+ * path and nothing else, and a trailing space is escaped because git strips
28
+ * unescaped ones.
29
+ */
30
+ export declare function toGitignorePattern(relPath: string): string;
31
+ /**
32
+ * Scan `hqRoot` for files above the size limit, ignore them, and drop any that
33
+ * are already in the index.
34
+ *
35
+ * Never throws: every failure path degrades to "did nothing", because a guard
36
+ * that can take `hq reindex` down is worse than the problem it prevents.
37
+ */
38
+ export declare function guardLargeFiles(hqRoot: string, options?: LargeFileGuardOptions): LargeFileGuardResult;
39
+ //# sourceMappingURL=large-file-guard.d.ts.map
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Large-file guard — keep blobs over GitHub's hard limit out of the HQ repo.
3
+ *
4
+ * HQ's root is a git repo that `git add -A` sweeps wholesale (the desktop app's
5
+ * git-mirror does exactly that after every sync). Session logs and package
6
+ * stores routinely blow past GitHub's 100 MB per-file limit, and once such a
7
+ * blob is committed every subsequent push is rejected with GH001 — permanently,
8
+ * since the blob is in history. Recovering means rewriting history.
9
+ *
10
+ * This guard runs from `hq reindex` and closes the door before that happens:
11
+ * - oversized files gain a `.gitignore` rule, so `git add -A` skips them;
12
+ * - oversized files already in the index are dropped from it with
13
+ * `git rm --cached`, which leaves the working-tree file untouched.
14
+ *
15
+ * Design constraints that shaped this:
16
+ *
17
+ * - **Git-aware, not filesystem-aware.** A naive `find -size +100M` over an HQ
18
+ * root takes ~37s and surfaces files inside nested checkouts under `repos/`,
19
+ * which the outer repo does not own. Driving everything through git costs
20
+ * ~13s and gets nested-repo boundaries right for free.
21
+ * - **Index, not HEAD.** `git rm --cached` mutates the index and leaves HEAD
22
+ * alone until the next commit, so enumerating HEAD would re-report the same
23
+ * file on every run.
24
+ * - **Throttled.** `hq reindex` also fires from a PostToolUse hook shim on
25
+ * skill/worker/policy edits. A stamp file in the git dir bounds the scan to
26
+ * once an hour so a policy-editing session doesn't pay the cost repeatedly.
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ /** GitHub rejects any single file above this size. */
32
+ export const DEFAULT_THRESHOLD_BYTES = 100 * 1024 * 1024;
33
+ /** At most one scan per hour per root. */
34
+ export const SCAN_THROTTLE_MS = 60 * 60 * 1000;
35
+ /** Lives in the git dir, so it is never committed, synced, or seen by status. */
36
+ const STAMP_BASENAME = "hq-large-file-scan.stamp";
37
+ const GITIGNORE_HEADER = "# hq reindex: files over GitHub's 100MB limit (added automatically)";
38
+ /**
39
+ * git output for a fully-populated HQ root runs to tens of megabytes
40
+ * (~450k tracked paths). The Node default of 1 MiB would truncate it.
41
+ */
42
+ const MAX_GIT_BUFFER = 512 * 1024 * 1024;
43
+ const SKIPPED = Object.freeze({
44
+ scanned: false,
45
+ ignored: [],
46
+ untracked: [],
47
+ });
48
+ function git(cwd, args, input) {
49
+ try {
50
+ const out = spawnSync("git", args, {
51
+ cwd,
52
+ input,
53
+ encoding: "utf8",
54
+ maxBuffer: MAX_GIT_BUFFER,
55
+ });
56
+ if (out.error || out.status !== 0)
57
+ return { ok: false, stdout: "" };
58
+ return { ok: true, stdout: out.stdout ?? "" };
59
+ }
60
+ catch {
61
+ // A missing cwd or an absent git binary must degrade to "guard did
62
+ // nothing", never take reindex down with it.
63
+ return { ok: false, stdout: "" };
64
+ }
65
+ }
66
+ /** Absolute path to the repo's git dir, or undefined when cwd is not a repo. */
67
+ function resolveGitDir(hqRoot) {
68
+ if (!fs.existsSync(hqRoot))
69
+ return undefined;
70
+ const out = git(hqRoot, ["rev-parse", "--git-dir"]);
71
+ if (!out.ok)
72
+ return undefined;
73
+ const raw = out.stdout.trim();
74
+ if (!raw)
75
+ return undefined;
76
+ return path.isAbsolute(raw) ? raw : path.resolve(hqRoot, raw);
77
+ }
78
+ function isThrottled(stampPath, now) {
79
+ try {
80
+ const last = Number(fs.readFileSync(stampPath, "utf8").trim());
81
+ if (!Number.isFinite(last))
82
+ return false;
83
+ return now - last < SCAN_THROTTLE_MS;
84
+ }
85
+ catch {
86
+ return false; // no stamp yet, or unreadable — scan.
87
+ }
88
+ }
89
+ function writeStamp(stampPath, now) {
90
+ try {
91
+ fs.writeFileSync(stampPath, `${now}\n`);
92
+ }
93
+ catch {
94
+ // Losing the stamp only costs an extra scan next time.
95
+ }
96
+ }
97
+ /** Split a NUL-delimited git record stream, dropping the trailing empty field. */
98
+ function splitNul(raw) {
99
+ return raw.split("\0").filter((entry) => entry.length > 0);
100
+ }
101
+ /**
102
+ * Oversized blobs in the *index*.
103
+ *
104
+ * `git ls-files -s` yields `<mode> <object> <stage>\t<path>`; the object ids are
105
+ * piped through `cat-file --batch-check` so sizes come from the object database
106
+ * rather than ~450k filesystem stats.
107
+ */
108
+ function oversizedInIndex(hqRoot, threshold) {
109
+ const listed = git(hqRoot, ["ls-files", "-s", "-z"]);
110
+ if (!listed.ok)
111
+ return [];
112
+ const entries = [];
113
+ for (const record of splitNul(listed.stdout)) {
114
+ const tab = record.indexOf("\t");
115
+ if (tab === -1)
116
+ continue;
117
+ const fields = record.slice(0, tab).split(" ");
118
+ if (fields.length < 2)
119
+ continue;
120
+ entries.push({ oid: fields[1], file: record.slice(tab + 1) });
121
+ }
122
+ if (entries.length === 0)
123
+ return [];
124
+ const sizes = git(hqRoot, ["cat-file", "--batch-check=%(objectsize)"], `${entries.map((e) => e.oid).join("\n")}\n`);
125
+ if (!sizes.ok)
126
+ return [];
127
+ // One output line per input line, in order.
128
+ const lines = sizes.stdout.split("\n");
129
+ const oversized = [];
130
+ for (let i = 0; i < entries.length; i += 1) {
131
+ const size = Number(lines[i]);
132
+ if (Number.isFinite(size) && size > threshold)
133
+ oversized.push(entries[i].file);
134
+ }
135
+ return oversized;
136
+ }
137
+ /**
138
+ * Oversized files git would pick up on the next `add -A` — untracked or
139
+ * modified. `git status` already honours `.gitignore` and stops at nested
140
+ * repository boundaries, so neither needs handling here.
141
+ */
142
+ function oversizedInWorktree(hqRoot, threshold) {
143
+ const status = git(hqRoot, ["status", "--porcelain", "-z", "--untracked-files=all"]);
144
+ if (!status.ok)
145
+ return [];
146
+ const records = splitNul(status.stdout);
147
+ const oversized = [];
148
+ for (let i = 0; i < records.length; i += 1) {
149
+ const record = records[i];
150
+ if (record.length < 4)
151
+ continue;
152
+ const code = record.slice(0, 2);
153
+ const file = record.slice(3);
154
+ // Renames and copies emit the source path as a second record; consume it
155
+ // so it is not parsed as a status line of its own.
156
+ if (code.startsWith("R") || code.startsWith("C"))
157
+ i += 1;
158
+ if (code.includes("D"))
159
+ continue; // going away — nothing to guard
160
+ try {
161
+ const stat = fs.statSync(path.join(hqRoot, file));
162
+ if (stat.isFile() && stat.size > threshold)
163
+ oversized.push(file);
164
+ }
165
+ catch {
166
+ // Vanished between status and stat; nothing to do.
167
+ }
168
+ }
169
+ return oversized;
170
+ }
171
+ /**
172
+ * Render a repo-relative path as a literal `.gitignore` pattern.
173
+ *
174
+ * The leading slash anchors it to the repo root — without it a bare filename
175
+ * would also ignore same-named files in every subdirectory. Glob and
176
+ * character-class metacharacters are escaped so the rule matches the one real
177
+ * path and nothing else, and a trailing space is escaped because git strips
178
+ * unescaped ones.
179
+ */
180
+ export function toGitignorePattern(relPath) {
181
+ const escaped = relPath.replace(/([\\*?[\]!#])/g, "\\$1");
182
+ const anchored = `/${escaped}`;
183
+ return anchored.endsWith(" ") ? `${anchored.slice(0, -1)}\\ ` : anchored;
184
+ }
185
+ function appendGitignore(hqRoot, patterns) {
186
+ if (patterns.length === 0)
187
+ return true;
188
+ const file = path.join(hqRoot, ".gitignore");
189
+ let existing = "";
190
+ try {
191
+ existing = fs.readFileSync(file, "utf8");
192
+ }
193
+ catch {
194
+ // No .gitignore yet — it gets created below.
195
+ }
196
+ const present = new Set(existing.split("\n").map((line) => line.trim()));
197
+ const fresh = patterns.filter((p) => !present.has(p));
198
+ if (fresh.length === 0)
199
+ return true;
200
+ const needsNewline = existing.length > 0 && !existing.endsWith("\n");
201
+ const header = existing.includes(GITIGNORE_HEADER) ? "" : `${GITIGNORE_HEADER}\n`;
202
+ try {
203
+ fs.appendFileSync(file, `${needsNewline ? "\n" : ""}${header}${fresh.join("\n")}\n`);
204
+ return true;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /**
211
+ * Scan `hqRoot` for files above the size limit, ignore them, and drop any that
212
+ * are already in the index.
213
+ *
214
+ * Never throws: every failure path degrades to "did nothing", because a guard
215
+ * that can take `hq reindex` down is worse than the problem it prevents.
216
+ */
217
+ export function guardLargeFiles(hqRoot, options = {}) {
218
+ const threshold = options.thresholdBytes ?? DEFAULT_THRESHOLD_BYTES;
219
+ const now = options.now ?? Date.now();
220
+ const gitDir = resolveGitDir(hqRoot);
221
+ if (!gitDir)
222
+ return SKIPPED;
223
+ const stampPath = path.join(gitDir, STAMP_BASENAME);
224
+ if (!options.force && isThrottled(stampPath, now))
225
+ return SKIPPED;
226
+ writeStamp(stampPath, now);
227
+ const tracked = oversizedInIndex(hqRoot, threshold);
228
+ const trackedSet = new Set(tracked);
229
+ const worktree = oversizedInWorktree(hqRoot, threshold).filter((p) => !trackedSet.has(p));
230
+ const all = [...tracked, ...worktree];
231
+ if (all.length === 0)
232
+ return { scanned: true, ignored: [], untracked: [] };
233
+ // Ignore first, so a concurrent `git add -A` cannot re-stage what we are
234
+ // about to remove from the index.
235
+ const patterns = all.map(toGitignorePattern);
236
+ const wroteIgnore = appendGitignore(hqRoot, patterns);
237
+ const untracked = [];
238
+ for (const file of tracked) {
239
+ const removed = git(hqRoot, ["rm", "--cached", "--quiet", "--ignore-unmatch", "--", file]);
240
+ if (removed.ok)
241
+ untracked.push(file);
242
+ }
243
+ return {
244
+ scanned: true,
245
+ ignored: wroteIgnore ? all : [],
246
+ untracked,
247
+ };
248
+ }
249
+ //# sourceMappingURL=large-file-guard.js.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The actionable remedy shown to the user. Written to be accurate whether it is
3
+ * qmd's bundled better-sqlite3 or hq-cli's own copy that is unbuilt (the same
4
+ * pnpm install leaves both uncompiled), and to distinguish a global install
5
+ * (the shape recorded in HQ-CLI-J) from a local project.
6
+ */
7
+ export declare const QMD_NATIVE_BINDING_REMEDY: string;
8
+ /**
9
+ * True when `err` is a better-sqlite3 native-binding load failure (qmd's or
10
+ * hq-cli's own). A true result means the caller should print
11
+ * `QMD_NATIVE_BINDING_REMEDY`, exit non-zero, and SKIP Sentry capture.
12
+ */
13
+ export declare function isQmdNativeBindingError(err: unknown): boolean;
14
+ /**
15
+ * If `err` is a native-binding failure, return the actionable remedy message;
16
+ * otherwise return `null`. Mirrors environmentalFsErrorMessage /
17
+ * networkTransportErrorMessage so the top-level handler can branch on it the
18
+ * same way: a non-null result means print-and-skip-Sentry, null means
19
+ * "handle as usual (capture to Sentry)".
20
+ */
21
+ export declare function qmdNativeBindingErrorMessage(err: unknown): string | null;
22
+ //# sourceMappingURL=qmd-native-binding-error.d.ts.map
@@ -0,0 +1,93 @@
1
+ // src/utils/qmd-native-binding-error.ts
2
+ //
3
+ // Classify a qmd failure caused by MISSING/UNLOADABLE NATIVE BINDINGS —
4
+ // better-sqlite3's compiled `better_sqlite3.node` was never built (or was built
5
+ // for a different ABI) — rather than an hq-cli code defect. This is the caller's
6
+ // install shape, not a bug HQ can fix in code, so the CLI surfaces an actionable
7
+ // remedy and SKIPS Sentry capture. Sibling of environmental-error.ts (HQ-CLI-2,
8
+ // full disk) and network-transport-error.ts (HQ-CLI-G, connectivity): a failure
9
+ // that is NOT an hq-cli defect is printed with an actionable message and never
10
+ // filed as a crash.
11
+ //
12
+ // HQ-CLI-J (Sentry 7656662146): pnpm 10 blocks dependency lifecycle scripts by
13
+ // default, so `pnpm add [-g] @indigoai-us/hq-cli` lands with better-sqlite3
14
+ // unbuilt. The bundled @tobilu/qmd then dies with `Could not locate the
15
+ // bindings file` the moment it opens its SQLite store; `hq index status`
16
+ // spawned it, runQmd wrapped the exit-1 in a QmdExitError, and that reached the
17
+ // top-level handler's final else, which captured it — 7 identical, unfixable
18
+ // "crashes" for a purely environmental packaging condition.
19
+ //
20
+ // Matching is deliberately narrow, on two axes, so it can neither be tripped by
21
+ // user-controlled input nor silence a real bug:
22
+ // 1. FIELD: for a QmdExitError the qmd process's OWN stderr/stdout is the
23
+ // diagnostic — NOT the synthesized `message`, which echoes the user's qmd
24
+ // arguments (a query for "better_sqlite3.node" must never classify). Only a
25
+ // raw Error with no captured streams falls back to its message (e.g. an
26
+ // hq-cli `hq db` bindings failure).
27
+ // 2. SHAPE: the distinctive "Could not locate the bindings file" text, OR a
28
+ // `better_sqlite3.node` reference that co-occurs with a genuine
29
+ // module-load failure. Either alone would be too loose.
30
+ /** The `bindings` module's exact "not found" text — sufficient on its own. */
31
+ const BINDINGS_NOT_LOCATED = /could not locate the bindings file/i;
32
+ /** A reference to the compiled better-sqlite3 module. */
33
+ const NATIVE_MODULE = /better_sqlite3\.node/i;
34
+ /**
35
+ * A genuine native-module LOAD failure, required alongside a bare
36
+ * better_sqlite3.node reference. Covers the missing-binding tries-list, an ABI
37
+ * mismatch, and OS-level dlopen faults — none of which a user's qmd arguments
38
+ * can synthesize.
39
+ */
40
+ const NATIVE_LOAD_FAILURE = /could not locate the bindings file|\bTried:|was compiled against a different node|NODE_MODULE_VERSION|invalid elf header|cannot open shared object|dlopen|is not a valid win32 application/i;
41
+ /**
42
+ * The actionable remedy shown to the user. Written to be accurate whether it is
43
+ * qmd's bundled better-sqlite3 or hq-cli's own copy that is unbuilt (the same
44
+ * pnpm install leaves both uncompiled), and to distinguish a global install
45
+ * (the shape recorded in HQ-CLI-J) from a local project.
46
+ */
47
+ export const QMD_NATIVE_BINDING_REMEDY = "hq's local search index can't start: its better-sqlite3 native module was " +
48
+ "not built during install. pnpm 10 skips dependency build scripts by default, " +
49
+ "which leaves better-sqlite3 uncompiled. Approve the build and reinstall — for " +
50
+ "a global install run `pnpm approve-builds -g better-sqlite3` (drop -g for a " +
51
+ "local project) — or reinstall hq with npm, then run the command again.";
52
+ /**
53
+ * The text to inspect. For a QmdExitError the captured stderr/stdout is the qmd
54
+ * process's real diagnostic; the synthesized `message` (which embeds the user's
55
+ * arguments) is used ONLY when no streams were captured, so user input can never
56
+ * trip the classifier.
57
+ */
58
+ function diagnosticText(err) {
59
+ if (typeof err === "string")
60
+ return err;
61
+ if (err === null || typeof err !== "object")
62
+ return "";
63
+ const record = err;
64
+ const stderr = typeof record.stderr === "string" ? record.stderr : "";
65
+ const stdout = typeof record.stdout === "string" ? record.stdout : "";
66
+ if (stderr || stdout)
67
+ return `${stderr}\n${stdout}`;
68
+ return typeof record.message === "string" ? record.message : "";
69
+ }
70
+ /**
71
+ * True when `err` is a better-sqlite3 native-binding load failure (qmd's or
72
+ * hq-cli's own). A true result means the caller should print
73
+ * `QMD_NATIVE_BINDING_REMEDY`, exit non-zero, and SKIP Sentry capture.
74
+ */
75
+ export function isQmdNativeBindingError(err) {
76
+ const text = diagnosticText(err);
77
+ if (!text)
78
+ return false;
79
+ if (BINDINGS_NOT_LOCATED.test(text))
80
+ return true;
81
+ return NATIVE_MODULE.test(text) && NATIVE_LOAD_FAILURE.test(text);
82
+ }
83
+ /**
84
+ * If `err` is a native-binding failure, return the actionable remedy message;
85
+ * otherwise return `null`. Mirrors environmentalFsErrorMessage /
86
+ * networkTransportErrorMessage so the top-level handler can branch on it the
87
+ * same way: a non-null result means print-and-skip-Sentry, null means
88
+ * "handle as usual (capture to Sentry)".
89
+ */
90
+ export function qmdNativeBindingErrorMessage(err) {
91
+ return isQmdNativeBindingError(err) ? QMD_NATIVE_BINDING_REMEDY : null;
92
+ }
93
+ //# sourceMappingURL=qmd-native-binding-error.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.96.0",
3
+ "version": "5.97.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {