@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 +46 -0
- package/dist/commands/index-cmd.js +33 -7
- package/dist/commands/reindex.js +23 -0
- 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/large-file-guard.d.ts +39 -0
- package/dist/utils/large-file-guard.js +249 -0
- 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,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
|
-
|
|
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
|
}
|
package/dist/commands/reindex.js
CHANGED
|
@@ -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
|
-
*
|
|
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,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
|