@indigoai-us/hq-cli 5.97.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 +29 -0
- package/dist/commands/index-cmd.js +33 -7
- package/dist/lib/search-index/index.d.ts +70 -0
- package/dist/lib/search-index/index.js +257 -22
- package/dist/main.js +15 -3
- package/dist/utils/qmd-native-binding-error.d.ts +22 -0
- package/dist/utils/qmd-native-binding-error.js +93 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
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
|
+
|
|
5
34
|
## [5.97.0]
|
|
6
35
|
|
|
7
36
|
### Added
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
130
|
+
if (qmdStatus?.stdout)
|
|
105
131
|
process.stdout.write(qmdStatus.stdout);
|
|
106
|
-
if (qmdStatus
|
|
132
|
+
if (qmdStatus?.stderr)
|
|
107
133
|
process.stderr.write(qmdStatus.stderr);
|
|
108
134
|
});
|
|
109
135
|
}
|
|
@@ -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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
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:
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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,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
|