@indigoai-us/hq-cli 5.98.0 → 5.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.98.1] — 2026-08-10
6
+
7
+ ### Fixed
8
+
9
+ - Every qmd-backed surface (`hq index`, `hq index status`, `hq search`,
10
+ `hq search get`, and the background reindex) no longer crashes — or files a
11
+ Sentry report — on Windows (HQ-CLI-M, Sentry 7663851953). The CLI resolved the
12
+ bundled qmd to the npm-generated batch shim `node_modules\.bin\qmd.cmd` and
13
+ spawned it with no shell, but since Node's CVE-2024-27980 hardening (all of
14
+ Node 22) Node refuses to run a `.cmd`/`.bat` without a shell and returns
15
+ `EINVAL`. That surfaced as an unactionable `QmdBinaryMissingError` on every
16
+ Windows install. The bundled qmd is now dispatched shell-free through its Node
17
+ launcher (`node <@tobilu/qmd>/bin/qmd`), so the `.cmd` shim — and cmd.exe — is
18
+ off the default path entirely and user search text is never handed to a shell.
19
+ A resolved `.cmd`/`.bat` that does reach a spawn (an `HQ_QMD_BIN` override or a
20
+ PATH shim) now goes through a properly quoted shell plan instead of a bare
21
+ `EINVAL` spawn, the qmd usability probe and the one-shot native-binding repair
22
+ path (including `prebuild-install.cmd` / `node-gyp.cmd` resolution) are fixed
23
+ the same way, and the resolver's diagnostics now report a spawn failure as a
24
+ spawn failure rather than mislabelling it "native bindings unbuilt". The one
25
+ Windows spawn policy (`buildSpawnPlan`) is now shared by the self-update path
26
+ and the qmd path, and additionally quotes the command path (not just argv) so
27
+ an absolute path containing spaces is passed intact.
28
+
5
29
  ## [5.98.0] — 2026-08-10
6
30
 
7
31
  ## [5.97.3] — 2026-08-10
@@ -111,8 +111,12 @@ export function registerIndexCommand(program, dependencies = defaults) {
111
111
  let registered;
112
112
  let qmdStatus;
113
113
  try {
114
- registered = dependencies.listRegisteredCollections(hqRoot, { bin, cwd: hqRoot });
115
- qmdStatus = dependencies.runQmd(['status'], { bin, cwd: hqRoot });
114
+ // Dispatch WITHOUT an explicit bin so these calls resolve through the
115
+ // shell-free Node launcher (resolveQmdInvocation), instead of spawning
116
+ // the bundled `.cmd` shim bare — the exact frame that crashed on Windows
117
+ // with EINVAL. `bin` above stays the display/version identity only.
118
+ registered = dependencies.listRegisteredCollections(hqRoot, { cwd: hqRoot });
119
+ qmdStatus = dependencies.runQmd(['status'], { cwd: hqRoot });
116
120
  }
117
121
  catch (error) {
118
122
  if (!isQmdNativeBindingError(error))
@@ -8,6 +8,47 @@ export type QmdProcessRunner = (bin: string, args: string[], options: {
8
8
  cwd?: string;
9
9
  env?: NodeJS.ProcessEnv;
10
10
  }) => QmdProcessResult;
11
+ /**
12
+ * How to actually launch qmd. A bare string bin cannot express the shell-free
13
+ * bundled path on Windows, where npm/pnpm generate a `.cmd` batch shim that Node
14
+ * refuses to spawn without a shell (CVE-2024-27980 → EINVAL). So a resolved qmd
15
+ * is an invocation: `command` plus fixed `prefixArgs` that precede the user's
16
+ * argv.
17
+ *
18
+ * - Bundled node entry (preferred): `{ command: process.execPath,
19
+ * prefixArgs: [<@tobilu/qmd launcher>], execDir: <dir of node> }` — spawned
20
+ * shell-free on every platform, so user search text never traverses cmd.exe.
21
+ * - Every legacy path (HQ_QMD_BIN, a PATH hit, the `.bin` shim): `{ command:
22
+ * bin, prefixArgs: [] }` — spawned shell-free unless it is a Windows
23
+ * `.cmd`/`.bat`, which is routed through the quoted shell plan.
24
+ *
25
+ * `execDir` is set only for the node entry: qmd's launcher re-spawns the bare
26
+ * name `node`, so the child env PATH is prefixed with the running Node's
27
+ * directory (the reported install put its toolchain Node off PATH).
28
+ */
29
+ export type QmdInvocation = {
30
+ command: string;
31
+ prefixArgs: string[];
32
+ execDir?: string;
33
+ };
34
+ /** Low-level spawn seam (defaults to `spawnSync`); injected in tests. */
35
+ export type QmdSpawn = (cmd: string, args: string[], options: {
36
+ cwd?: string;
37
+ env?: NodeJS.ProcessEnv;
38
+ encoding: 'utf8';
39
+ timeout?: number;
40
+ shell?: boolean;
41
+ }) => {
42
+ status: number | null;
43
+ stdout?: string;
44
+ stderr?: string;
45
+ error?: Error;
46
+ };
47
+ /**
48
+ * Prefix `execDir` onto the child env PATH so a node-entry launcher can re-spawn
49
+ * the bare name `node`. Returns the env unchanged when there is nothing to add.
50
+ */
51
+ export declare function withNodeDirOnPath(env: NodeJS.ProcessEnv | undefined, execDir: string | undefined, platform?: NodeJS.Platform): NodeJS.ProcessEnv | undefined;
11
52
  export declare class QmdBinaryMissingError extends Error {
12
53
  name: string;
13
54
  }
@@ -52,6 +93,19 @@ export type ResolveQmdBinOptions = {
52
93
  * independent of the dependency's own exports map.
53
94
  */
54
95
  export declare function packageLocalBin(): string | undefined;
96
+ /**
97
+ * Locate the bundled qmd's Node LAUNCHER — `@tobilu/qmd`'s `bin.qmd` entry
98
+ * (`bin/qmd`, a `#!/usr/bin/env node` script) — by the same upward walk
99
+ * {@link resolveQmdVersion} uses to find the package. Returns the absolute
100
+ * launcher path only when both the manifest and the launcher file exist.
101
+ *
102
+ * This is the shell-free preferred resolution: invoking `node <launcher>`
103
+ * bypasses the npm-generated `.cmd` shim entirely, so the Windows CVE-2024-27980
104
+ * EINVAL never arises and no user-supplied search text reaches cmd.exe. A
105
+ * missing/partial layout returns undefined so the caller falls back to the
106
+ * `.bin` shim (which the shared spawn plan now makes spawnable on Windows too).
107
+ */
108
+ export declare function packageLocalNodeEntry(): string | undefined;
55
109
  /**
56
110
  * Return the pinned package version when qmd is supplied by this CLI.
57
111
  *
@@ -62,7 +116,23 @@ export declare function packageLocalBin(): string | undefined;
62
116
  export declare function resolveQmdVersion(): string | undefined;
63
117
  /** Reset per-process probe/repair memoisation. Test-only. */
64
118
  export declare function __resetQmdProbeStateForTests(): void;
119
+ /** Probe a resolved invocation, memoised per invocation; records the failure
120
+ * detail under the qmd bin identity so repair can read it. */
121
+ export declare function isUsableInvocation(invocation: QmdInvocation, options?: {
122
+ platform?: NodeJS.Platform;
123
+ spawn?: QmdSpawn;
124
+ }): boolean;
125
+ /** Backwards-compatible string form: probe a bare bin path shell-free (or via
126
+ * the shell plan when it is a Windows `.cmd`/`.bat`). */
65
127
  export declare function isUsableQmd(bin: string): boolean;
128
+ /**
129
+ * Honest one-line reason a package-local qmd probe failed, for the resolver's
130
+ * probes note. A spawn-level failure — the binary could not be executed at all
131
+ * (the Windows `.cmd` EINVAL, or ENOENT) — must NOT be described as "native
132
+ * bindings unbuilt": that wording is exactly what sent this Windows EINVAL down
133
+ * the native-bindings story. Only a genuine better-sqlite3 load failure keeps it.
134
+ */
135
+ export declare function describeProbeFailure(detail: string): string;
66
136
  /**
67
137
  * Derive the better-sqlite3 PACKAGE directory qmd tried to load, from a bindings
68
138
  * failure. `bindings` lists every path it tried, each ending in
@@ -92,6 +162,8 @@ export type QmdRepairOptions = {
92
162
  failureDetail?: string;
93
163
  /** Injected in tests so the real build tools are never spawned. */
94
164
  spawn?: RepairSpawn;
165
+ /** Injectable platform so the Windows shim resolution is provable on Linux CI. */
166
+ platform?: NodeJS.Platform;
95
167
  };
96
168
  /**
97
169
  * Bounded, one-shot self-repair of a package-local qmd whose better-sqlite3
@@ -106,13 +178,54 @@ export type QmdRepairOptions = {
106
178
  * waits unbounded, and never touches anything outside hq's own dependency tree.
107
179
  */
108
180
  export declare function repairQmdNativeBindings(bin: string, options?: QmdRepairOptions): boolean;
181
+ /**
182
+ * Resolve a dependency executable better-sqlite3 would run at install time. pnpm
183
+ * links a package's dependency bins into the SIBLING `.bin` of its virtual-store
184
+ * `node_modules` (`.pnpm/better-sqlite3@x/node_modules/.bin/<tool>`), NOT inside
185
+ * `better-sqlite3/node_modules/.bin`; npm's hoisted layout and some pnpm configs
186
+ * use the nested form. Check both so the self-repair works on the pnpm-installed
187
+ * hosts it exists for. Both candidates sit inside the already-confined tree.
188
+ */
189
+ export declare function resolveRepairTool(betterSqlite3Dir: string, tool: string, platform?: NodeJS.Platform): string | undefined;
109
190
  /** Resolve qmd without relying on a globally installed copy. */
110
191
  export declare function resolveQmdBin(options?: ResolveQmdBinOptions): string;
192
+ export type ResolveQmdInvocationOptions = {
193
+ env?: Record<string, string | undefined>;
194
+ isExecutable?: (candidate: string) => boolean;
195
+ /** Locate the bundled qmd's Node launcher (preferred). Defaults to {@link packageLocalNodeEntry}. */
196
+ packageNodeEntry?: () => string | undefined;
197
+ /** Locate the bundled qmd's `.bin` shim (fallback). Defaults to {@link packageLocalBin}. */
198
+ packageBin?: () => string | undefined;
199
+ pathBin?: () => string | undefined;
200
+ isUsable?: (invocation: QmdInvocation) => boolean;
201
+ repair?: (bin: string) => boolean;
202
+ /** Injectable platform / Node path so Windows resolution is provable on Linux CI. */
203
+ platform?: NodeJS.Platform;
204
+ execPath?: string;
205
+ /** Low-level spawn seam threaded into the usability probe. */
206
+ spawn?: QmdSpawn;
207
+ };
208
+ /**
209
+ * Resolve HOW to launch qmd, preferring the shell-free Node launcher for the
210
+ * bundled copy so the Windows `.cmd` shim (and its CVE-2024-27980 EINVAL) is
211
+ * never on the default path. Mirrors {@link resolveQmdBin}'s contract — explicit
212
+ * HQ_QMD_BIN honoured verbatim, probe + one bounded repair, PATH fallback, and a
213
+ * classified (not opaque) last resort — but returns an invocation instead of a
214
+ * bare string.
215
+ */
216
+ export declare function resolveQmdInvocation(options?: ResolveQmdInvocationOptions): QmdInvocation;
111
217
  export type RunQmdOptions = {
112
218
  bin?: string;
113
219
  cwd?: string;
114
220
  env?: NodeJS.ProcessEnv;
115
221
  runner?: QmdProcessRunner;
222
+ /** Low-level spawn seam (default `spawnSync`); injected in tests. */
223
+ spawn?: QmdSpawn;
224
+ /** Injectable platform / Node path so Windows dispatch is provable on Linux CI. */
225
+ platform?: NodeJS.Platform;
226
+ execPath?: string;
227
+ /** Invocation resolver seam (default {@link resolveQmdInvocation}). */
228
+ resolveInvocation?: (options: ResolveQmdInvocationOptions) => QmdInvocation;
116
229
  };
117
230
  /** Run qmd with captured output and typed failures. */
118
231
  export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
@@ -5,7 +5,23 @@ import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
8
+ import { planCommandSpawn } from '../../utils/windows-spawn.js';
8
9
  const require = createRequire(import.meta.url);
10
+ /** The `path` implementation for a (possibly injected) platform. */
11
+ function pathFor(platform) {
12
+ return platform === 'win32' ? path.win32 : path.posix;
13
+ }
14
+ /**
15
+ * Prefix `execDir` onto the child env PATH so a node-entry launcher can re-spawn
16
+ * the bare name `node`. Returns the env unchanged when there is nothing to add.
17
+ */
18
+ export function withNodeDirOnPath(env, execDir, platform = process.platform) {
19
+ if (!execDir)
20
+ return env;
21
+ const delimiter = pathFor(platform).delimiter;
22
+ const current = env?.PATH ?? '';
23
+ return { ...env, PATH: current ? `${execDir}${delimiter}${current}` : execDir };
24
+ }
9
25
  export class QmdBinaryMissingError extends Error {
10
26
  name = 'QmdBinaryMissingError';
11
27
  }
@@ -67,6 +83,47 @@ export function packageLocalBin() {
67
83
  }
68
84
  return undefined;
69
85
  }
86
+ /**
87
+ * Locate the bundled qmd's Node LAUNCHER — `@tobilu/qmd`'s `bin.qmd` entry
88
+ * (`bin/qmd`, a `#!/usr/bin/env node` script) — by the same upward walk
89
+ * {@link resolveQmdVersion} uses to find the package. Returns the absolute
90
+ * launcher path only when both the manifest and the launcher file exist.
91
+ *
92
+ * This is the shell-free preferred resolution: invoking `node <launcher>`
93
+ * bypasses the npm-generated `.cmd` shim entirely, so the Windows CVE-2024-27980
94
+ * EINVAL never arises and no user-supplied search text reaches cmd.exe. A
95
+ * missing/partial layout returns undefined so the caller falls back to the
96
+ * `.bin` shim (which the shared spawn plan now makes spawnable on Windows too).
97
+ */
98
+ export function packageLocalNodeEntry() {
99
+ let directory = path.dirname(fileURLToPath(import.meta.url));
100
+ for (let depth = 0; depth < 10; depth++) {
101
+ const manifest = path.join(directory, 'node_modules', '@tobilu', 'qmd', 'package.json');
102
+ if (fs.existsSync(manifest)) {
103
+ try {
104
+ const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
105
+ const bin = parsed.bin;
106
+ const relative = typeof bin === 'string'
107
+ ? bin
108
+ : bin && typeof bin === 'object' && typeof bin.qmd === 'string'
109
+ ? bin.qmd
110
+ : undefined;
111
+ if (relative) {
112
+ const launcher = path.join(path.dirname(manifest), relative);
113
+ if (fs.existsSync(launcher))
114
+ return launcher;
115
+ }
116
+ }
117
+ catch { /* unreadable/partial manifest: fall back to the .bin shim */ }
118
+ return undefined;
119
+ }
120
+ const parent = path.dirname(directory);
121
+ if (parent === directory)
122
+ break;
123
+ directory = parent;
124
+ }
125
+ return undefined;
126
+ }
70
127
  /**
71
128
  * Return the pinned package version when qmd is supplied by this CLI.
72
129
  *
@@ -130,19 +187,69 @@ export function __resetQmdProbeStateForTests() {
130
187
  lastProbeFailure.clear();
131
188
  nativeBindingRepairAttempted = false;
132
189
  }
190
+ /** The qmd file identity of an invocation: the launcher for a node entry, else
191
+ * the command. Used to key probe failures and confine native-binding repair. */
192
+ function invocationBin(invocation) {
193
+ return invocation.prefixArgs.length > 0
194
+ ? invocation.prefixArgs[invocation.prefixArgs.length - 1]
195
+ : invocation.command;
196
+ }
197
+ /** Cache key for a resolved invocation (command + fixed prefix args). */
198
+ function invocationKey(invocation) {
199
+ return [invocation.command, ...invocation.prefixArgs].join('');
200
+ }
201
+ /** Default low-level spawn: `spawnSync` with captured utf8 output. */
202
+ const defaultSpawn = (cmd, args, options) => {
203
+ const result = spawnSync(cmd, args, {
204
+ cwd: options.cwd,
205
+ env: options.env,
206
+ encoding: 'utf8',
207
+ timeout: options.timeout,
208
+ shell: options.shell,
209
+ });
210
+ return {
211
+ status: result.status,
212
+ stdout: result.stdout ?? undefined,
213
+ stderr: result.stderr ?? undefined,
214
+ error: result.error,
215
+ };
216
+ };
217
+ /**
218
+ * Spawn a resolved qmd invocation through the shared Windows-aware plan
219
+ * ({@link planCommandSpawn}): shell-free everywhere except a Windows `.cmd`/`.bat`
220
+ * target, which is routed through the quoted shell so Node's CVE-2024-27980
221
+ * hardening does not reject it with EINVAL. A node-entry invocation additionally
222
+ * gets the running Node's directory prefixed onto the child PATH so the
223
+ * launcher's bare `node` re-spawn resolves.
224
+ */
225
+ function spawnQmd(invocation, args, options = {}) {
226
+ const platform = options.platform ?? process.platform;
227
+ const spawn = options.spawn ?? defaultSpawn;
228
+ const fullArgs = [...invocation.prefixArgs, ...args];
229
+ const plan = planCommandSpawn(invocation.command, fullArgs, platform);
230
+ const env = withNodeDirOnPath(options.env, invocation.execDir, platform);
231
+ return spawn(plan.cmd, plan.args, {
232
+ cwd: options.cwd,
233
+ env,
234
+ encoding: 'utf8',
235
+ timeout: options.timeout,
236
+ shell: plan.shell,
237
+ });
238
+ }
133
239
  /**
134
- * Does this qmd binary actually OPEN ITS STORE? The 5.94.2 probe spawned
240
+ * Does this qmd invocation actually OPEN ITS STORE? The 5.94.2 probe spawned
135
241
  * `--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.
242
+ * is missing — the exact false positive that let HQ-CLI-J ship. `collection
243
+ * list` instead opens the SQLite database, which is the operation that fails
244
+ * when `better_sqlite3.node` was never built. It is also spawned through the
245
+ * shared plan, so probing a Windows `.cmd` shim reports its real result rather
246
+ * than a spurious EINVAL.
140
247
  *
141
248
  * The probe is pointed at a throwaway INDEX_PATH / QMD_CONFIG_DIR / HOME so it
142
249
  * 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.
250
+ * timeout. Callers cache the boolean per invocation so resolution stays one spawn.
144
251
  */
145
- function probeQmd(bin) {
252
+ function probeQmd(invocation, options = {}) {
146
253
  let scratch;
147
254
  try {
148
255
  scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-qmd-probe-'));
@@ -154,11 +261,12 @@ function probeQmd(bin) {
154
261
  XDG_CONFIG_HOME: path.join(scratch, 'xdg-config'),
155
262
  XDG_CACHE_HOME: path.join(scratch, 'xdg-cache'),
156
263
  };
157
- const probe = spawnSync(bin, ['collection', 'list'], {
264
+ const probe = spawnQmd(invocation, ['collection', 'list'], {
158
265
  cwd: scratch,
159
266
  env,
160
- encoding: 'utf8',
161
267
  timeout: PROBE_TIMEOUT_MS,
268
+ platform: options.platform,
269
+ spawn: options.spawn,
162
270
  });
163
271
  if (probe.error)
164
272
  return { usable: false, detail: probe.error.message };
@@ -178,18 +286,42 @@ function probeQmd(bin) {
178
286
  }
179
287
  }
180
288
  }
181
- export function isUsableQmd(bin) {
182
- const cached = usableQmdCache.get(bin);
289
+ /** Probe a resolved invocation, memoised per invocation; records the failure
290
+ * detail under the qmd bin identity so repair can read it. */
291
+ export function isUsableInvocation(invocation, options = {}) {
292
+ const key = invocationKey(invocation);
293
+ const cached = usableQmdCache.get(key);
183
294
  if (cached !== undefined)
184
295
  return cached;
185
- const result = probeQmd(bin);
186
- usableQmdCache.set(bin, result.usable);
296
+ const result = probeQmd(invocation, options);
297
+ usableQmdCache.set(key, result.usable);
298
+ const bin = invocationBin(invocation);
187
299
  if (result.usable)
188
300
  lastProbeFailure.delete(bin);
189
301
  else
190
302
  lastProbeFailure.set(bin, result.detail);
191
303
  return result.usable;
192
304
  }
305
+ /** Backwards-compatible string form: probe a bare bin path shell-free (or via
306
+ * the shell plan when it is a Windows `.cmd`/`.bat`). */
307
+ export function isUsableQmd(bin) {
308
+ return isUsableInvocation({ command: bin, prefixArgs: [] });
309
+ }
310
+ /**
311
+ * Honest one-line reason a package-local qmd probe failed, for the resolver's
312
+ * probes note. A spawn-level failure — the binary could not be executed at all
313
+ * (the Windows `.cmd` EINVAL, or ENOENT) — must NOT be described as "native
314
+ * bindings unbuilt": that wording is exactly what sent this Windows EINVAL down
315
+ * the native-bindings story. Only a genuine better-sqlite3 load failure keeps it.
316
+ */
317
+ export function describeProbeFailure(detail) {
318
+ if (/\b(?:EINVAL|ENOENT|EACCES|ENOEXEC)\b/.test(detail) || /^spawnSync\b/.test(detail)) {
319
+ return 'present but could not be spawned';
320
+ }
321
+ if (isQmdNativeBindingError(detail))
322
+ return 'present but native bindings unbuilt';
323
+ return 'present but unusable';
324
+ }
193
325
  /** Native-binding self-repair is on unless explicitly disabled. */
194
326
  function repairEnabled(env) {
195
327
  const flag = env.HQ_QMD_NO_REPAIR;
@@ -232,12 +364,17 @@ export function repairConfinedTo(qmdBin, betterSqlite3Dir) {
232
364
  return commonAncestor.includes('node_modules') || commonAncestor.includes('.pnpm');
233
365
  }
234
366
  const defaultRepairSpawn = (cmd, args, opts) => {
235
- const result = spawnSync(cmd, args, {
367
+ // Route through the shared plan: on Windows a repair tool is a `.cmd` shim
368
+ // (prebuild-install.cmd / node-gyp.cmd) that Node refuses to spawn without a
369
+ // shell, exactly like the qmd shim itself.
370
+ const plan = planCommandSpawn(cmd, args);
371
+ const result = spawnSync(plan.cmd, plan.args, {
236
372
  cwd: opts.cwd,
237
373
  env: opts.env,
238
374
  timeout: opts.timeout,
239
375
  encoding: 'utf8',
240
376
  stdio: 'ignore',
377
+ shell: plan.shell,
241
378
  });
242
379
  return { status: result.status, error: result.error };
243
380
  };
@@ -277,7 +414,7 @@ export function repairQmdNativeBindings(bin, options = {}) {
277
414
  try {
278
415
  const env = { ...process.env, ...(options.env ?? {}) };
279
416
  const spawn = options.spawn ?? defaultRepairSpawn;
280
- runBetterSqlite3Install(betterSqlite3Dir, env, spawn);
417
+ runBetterSqlite3Install(betterSqlite3Dir, env, spawn, options.platform ?? process.platform);
281
418
  }
282
419
  finally {
283
420
  try {
@@ -321,27 +458,36 @@ function acquireRepairLock(lockDir) {
321
458
  * use the nested form. Check both so the self-repair works on the pnpm-installed
322
459
  * hosts it exists for. Both candidates sit inside the already-confined tree.
323
460
  */
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)
461
+ export function resolveRepairTool(betterSqlite3Dir, tool, platform = process.platform) {
462
+ // On Windows, npm/pnpm generate a `.cmd` shim (and sometimes `.exe`) for a JS
463
+ // bin; the extensionless file is a POSIX sh script Node cannot spawn there.
464
+ // Prefer the platform-appropriate shim so the one-shot native-binding repair is
465
+ // reachable on Windows too, not just POSIX — otherwise the repair path hits the
466
+ // same EINVAL/ENOEXEC as the primary qmd spawn.
467
+ const names = platform === 'win32' ? [`${tool}.cmd`, `${tool}.exe`, tool] : [tool];
468
+ const dirs = [
469
+ path.join(path.dirname(betterSqlite3Dir), '.bin'), // pnpm virtual-store sibling
470
+ path.join(betterSqlite3Dir, 'node_modules', '.bin'), // nested (npm / some pnpm)
328
471
  ];
329
- for (const candidate of candidates) {
330
- if (fs.existsSync(candidate))
331
- return candidate;
472
+ for (const dir of dirs) {
473
+ for (const name of names) {
474
+ const candidate = path.join(dir, name);
475
+ if (fs.existsSync(candidate))
476
+ return candidate;
477
+ }
332
478
  }
333
479
  return undefined;
334
480
  }
335
481
  /** Run better-sqlite3's declared install step: prebuild-install, then node-gyp. */
336
- function runBetterSqlite3Install(dir, env, spawn) {
482
+ function runBetterSqlite3Install(dir, env, spawn, platform) {
337
483
  const artifact = path.join(dir, 'build', 'Release', 'better_sqlite3.node');
338
- const prebuild = resolveRepairTool(dir, 'prebuild-install');
484
+ const prebuild = resolveRepairTool(dir, 'prebuild-install', platform);
339
485
  if (prebuild) {
340
486
  spawn(prebuild, [], { cwd: dir, env, timeout: REPAIR_STEP_TIMEOUT_MS });
341
487
  if (fs.existsSync(artifact))
342
488
  return;
343
489
  }
344
- const nodeGyp = resolveRepairTool(dir, 'node-gyp');
490
+ const nodeGyp = resolveRepairTool(dir, 'node-gyp', platform);
345
491
  if (nodeGyp) {
346
492
  spawn(nodeGyp, ['rebuild', '--release'], { cwd: dir, env, timeout: REPAIR_STEP_TIMEOUT_MS });
347
493
  }
@@ -383,7 +529,7 @@ export function resolveQmdBin(options = {}) {
383
529
  return installed;
384
530
  }
385
531
  }
386
- probes.push(`package-local @tobilu/qmd (${installed}, present but native bindings unbuilt)`);
532
+ probes.push(`package-local @tobilu/qmd (${installed}, ${describeProbeFailure(lastProbeFailure.get(installed) ?? '')})`);
387
533
  }
388
534
  else {
389
535
  probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
@@ -403,30 +549,130 @@ export function resolveQmdBin(options = {}) {
403
549
  return installed;
404
550
  throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
405
551
  }
406
- function defaultRunner(bin, args, options) {
407
- const result = spawnSync(bin, args, { cwd: options.cwd, env: options.env, encoding: 'utf8' });
408
- return {
552
+ /**
553
+ * Resolve HOW to launch qmd, preferring the shell-free Node launcher for the
554
+ * bundled copy so the Windows `.cmd` shim (and its CVE-2024-27980 EINVAL) is
555
+ * never on the default path. Mirrors {@link resolveQmdBin}'s contract — explicit
556
+ * HQ_QMD_BIN honoured verbatim, probe + one bounded repair, PATH fallback, and a
557
+ * classified (not opaque) last resort — but returns an invocation instead of a
558
+ * bare string.
559
+ */
560
+ export function resolveQmdInvocation(options = {}) {
561
+ const env = options.env ?? process.env;
562
+ const executable = options.isExecutable ?? isExecutable;
563
+ const platform = options.platform ?? process.platform;
564
+ const execPath = options.execPath ?? process.execPath;
565
+ const isUsable = options.isUsable
566
+ ?? ((invocation) => isUsableInvocation(invocation, { platform, spawn: options.spawn }));
567
+ const probes = [];
568
+ const override = env.HQ_QMD_BIN;
569
+ if (override) {
570
+ // An explicit user choice is honoured verbatim: never probe or repair a
571
+ // binary the operator pointed us at.
572
+ if (executable(override))
573
+ return { command: override, prefixArgs: [] };
574
+ probes.push(`HQ_QMD_BIN (${override})`);
575
+ }
576
+ else {
577
+ probes.push('HQ_QMD_BIN (not set)');
578
+ }
579
+ const nodeEntry = (options.packageNodeEntry ?? packageLocalNodeEntry)();
580
+ const shim = (options.packageBin ?? packageLocalBin)();
581
+ const onPath = (options.pathBin ?? pathBin)();
582
+ const fallback = onPath && executable(onPath) ? onPath : undefined;
583
+ // The bundled qmd, PREFERRING the shell-free Node launcher over the `.cmd`/`.bat`
584
+ // shim. `bundledBin` keeps the qmd file identity (the launcher, else the shim)
585
+ // for probe-failure keying and repair-tree confinement. The launcher is read by
586
+ // Node, not exec'd, so it needs existence (guaranteed by packageLocalNodeEntry),
587
+ // not the +x bit.
588
+ let bundled;
589
+ let bundledBin;
590
+ if (nodeEntry) {
591
+ bundled = { command: execPath, prefixArgs: [nodeEntry], execDir: pathFor(platform).dirname(execPath) };
592
+ bundledBin = nodeEntry;
593
+ }
594
+ else if (shim && executable(shim)) {
595
+ bundled = { command: shim, prefixArgs: [] };
596
+ bundledBin = shim;
597
+ }
598
+ if (bundled && bundledBin) {
599
+ // Executable is not the same as usable: a bundled qmd can be present yet fail
600
+ // to open its SQLite store (pnpm 10 skips better-sqlite3's build). ALWAYS
601
+ // probe, then attempt one bounded native-binding repair before giving up.
602
+ if (isUsable(bundled))
603
+ return bundled;
604
+ if (repairEnabled(env)) {
605
+ const repair = options.repair ?? repairQmdNativeBindings;
606
+ if (repair(bundledBin)) {
607
+ usableQmdCache.delete(invocationKey(bundled));
608
+ if (isUsable(bundled))
609
+ return bundled;
610
+ }
611
+ }
612
+ probes.push(`package-local @tobilu/qmd (${bundledBin}, ${describeProbeFailure(lastProbeFailure.get(bundledBin) ?? '')})`);
613
+ }
614
+ else {
615
+ probes.push(`package-local @tobilu/qmd (${shim ?? nodeEntry ?? 'not found'})`);
616
+ }
617
+ // A working qmd on PATH is the right answer when the bundled one is broken.
618
+ if (fallback)
619
+ return { command: fallback, prefixArgs: [] };
620
+ probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
621
+ // Last resort: no usable qmd anywhere, but the bundled binary IS present. Hand
622
+ // it back rather than throwing an opaque error — running it yields a classified
623
+ // failure (native-binding remedy + no Sentry) instead of an unactionable crash.
624
+ if (bundled)
625
+ return bundled;
626
+ throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
627
+ }
628
+ /** Normalise a spawn result and raise the typed qmd failures. */
629
+ function finishRunQmd(result, bin, args) {
630
+ const normalized = {
409
631
  status: result.status,
410
632
  stdout: result.stdout ?? '',
411
633
  stderr: result.stderr ?? '',
412
634
  error: result.error,
413
635
  };
636
+ if (normalized.error) {
637
+ throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`);
638
+ }
639
+ if (normalized.status === 0)
640
+ return normalized;
641
+ const detail = normalized.stderr || normalized.stdout || 'qmd returned no diagnostic output';
642
+ const message = `qmd ${args.join(' ')} exited with ${normalized.status ?? 'an unknown status'}: ${detail}`;
643
+ if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
644
+ throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
645
+ }
646
+ throw new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
414
647
  }
415
648
  /** Run qmd with captured output and typed failures. */
416
649
  export function runQmd(args, options = {}) {
417
- const bin = options.bin ?? resolveQmdBin({ env: options.env });
418
- const result = (options.runner ?? defaultRunner)(bin, args, { cwd: options.cwd, env: options.env });
419
- if (result.error) {
420
- throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${result.error.message}`);
421
- }
422
- if (result.status === 0)
423
- return result;
424
- const detail = result.stderr || result.stdout || 'qmd returned no diagnostic output';
425
- const message = `qmd ${args.join(' ')} exited with ${result.status ?? 'an unknown status'}: ${detail}`;
426
- if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
427
- throw new QmdCollectionMissingError(message, args, result.status, result.stdout, result.stderr);
650
+ // Legacy string-bin runner seam (tests and callers that inject a fake process):
651
+ // honoured exactly as before via the (bin, args, options) signature.
652
+ if (options.runner) {
653
+ const bin = options.bin ?? resolveQmdBin({ env: options.env });
654
+ const result = options.runner(bin, args, { cwd: options.cwd, env: options.env });
655
+ return finishRunQmd(result, bin, args);
428
656
  }
429
- throw new QmdExitError(message, args, result.status, result.stdout, result.stderr);
657
+ // Default path: resolve the full invocation (shell-free Node launcher preferred)
658
+ // and spawn it through the shared Windows-aware plan. An explicit string bin is
659
+ // wrapped as a legacy invocation; a `.cmd`/`.bat` there is routed through the
660
+ // quoted shell plan by planCommandSpawn rather than spawned bare (EINVAL).
661
+ const invocation = options.bin
662
+ ? { command: options.bin, prefixArgs: [] }
663
+ : (options.resolveInvocation ?? resolveQmdInvocation)({
664
+ env: options.env,
665
+ platform: options.platform,
666
+ execPath: options.execPath,
667
+ spawn: options.spawn,
668
+ });
669
+ const result = spawnQmd(invocation, args, {
670
+ cwd: options.cwd,
671
+ env: options.env ?? process.env,
672
+ platform: options.platform,
673
+ spawn: options.spawn,
674
+ });
675
+ return finishRunQmd(result, invocationBin(invocation), args);
430
676
  }
431
677
  function containsIndexedMarkdown(directory) {
432
678
  if (!fs.existsSync(directory))
@@ -27,6 +27,7 @@
27
27
  * Opt-out: `HQ_NO_UPDATE_CHECK=1` (same env as `version-check.ts` — one knob
28
28
  * to silence both check + gate).
29
29
  */
30
+ import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
30
31
  /** Which package manager owns the running global install. */
31
32
  export type InstallManager = "npm" | "pnpm";
32
33
  export interface VersionCheckResponse {
@@ -146,26 +147,7 @@ export type UpdateResult = {
146
147
  code?: string;
147
148
  };
148
149
  type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
149
- /**
150
- * Quote an argv entry for a Windows `cmd.exe` invocation. Needed because Node
151
- * does NOT quote argv when spawning with `shell: true` on Windows — it joins
152
- * the array with spaces — so an npm prefix like `C:\Program Files\…` would be
153
- * split into two arguments.
154
- */
155
- export declare function quoteForWindowsShell(arg: string): string;
156
- /**
157
- * How to hand `<cmd> <args…>` to `spawnSync` on this platform.
158
- *
159
- * On Windows both `npm` and `pnpm` are `.cmd` shims, and since the
160
- * CVE-2024-27980 hardening Node refuses to spawn a `.cmd`/`.bat` file without
161
- * a shell. Without this the update would fail with EINVAL/ENOENT on every
162
- * Windows install — including the pnpm layouts this gate claims to detect.
163
- */
164
- export declare function buildSpawnPlan(cmd: string, args: readonly string[], platform?: NodeJS.Platform): {
165
- cmd: string;
166
- args: string[];
167
- shell: boolean;
168
- };
150
+ export { buildSpawnPlan, quoteForWindowsShell };
169
151
  export declare function runUpdateCommand(cmd: string, args: string[]): UpdateResult;
170
152
  declare function performUpdateCommand(cmd: string, args: string[], runner?: UpdateRunner): UpdateResult;
171
153
  declare function performUpdate(command: string, runner?: UpdateRunner): UpdateResult;
@@ -243,5 +225,4 @@ export declare const __test__: {
243
225
  resolveRunningManager: typeof resolveRunningManager;
244
226
  resolveRunningPrefix: typeof resolveRunningPrefix;
245
227
  };
246
- export {};
247
228
  //# sourceMappingURL=version-gate.d.ts.map
@@ -28,6 +28,7 @@
28
28
  * to silence both check + gate).
29
29
  */
30
30
  import { spawnSync } from "node:child_process";
31
+ import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
31
32
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
32
33
  import path from "node:path";
33
34
  import { fileURLToPath } from "node:url";
@@ -280,32 +281,12 @@ async function fetchVersionDecision() {
280
281
  return null;
281
282
  }
282
283
  }
283
- /**
284
- * Quote an argv entry for a Windows `cmd.exe` invocation. Needed because Node
285
- * does NOT quote argv when spawning with `shell: true` on Windows — it joins
286
- * the array with spaces so an npm prefix like `C:\Program Files\…` would be
287
- * split into two arguments.
288
- */
289
- export function quoteForWindowsShell(arg) {
290
- if (arg === "")
291
- return '""';
292
- if (!/[\s"^&|<>()]/.test(arg))
293
- return arg;
294
- return `"${arg.replace(/"/g, '\\"')}"`;
295
- }
296
- /**
297
- * How to hand `<cmd> <args…>` to `spawnSync` on this platform.
298
- *
299
- * On Windows both `npm` and `pnpm` are `.cmd` shims, and since the
300
- * CVE-2024-27980 hardening Node refuses to spawn a `.cmd`/`.bat` file without
301
- * a shell. Without this the update would fail with EINVAL/ENOENT on every
302
- * Windows install — including the pnpm layouts this gate claims to detect.
303
- */
304
- export function buildSpawnPlan(cmd, args, platform = process.platform) {
305
- if (platform !== "win32")
306
- return { cmd, args: [...args], shell: false };
307
- return { cmd, args: args.map(quoteForWindowsShell), shell: true };
308
- }
284
+ // The Windows spawn recipe (`buildSpawnPlan` + `quoteForWindowsShell`) lives in
285
+ // ./windows-spawn.ts so the self-update path and the qmd spawn path share one
286
+ // implementation. Imported at the top and re-exported here to keep this module's
287
+ // public surface (and the `__test__` block below) stable for existing callers
288
+ // and tests.
289
+ export { buildSpawnPlan, quoteForWindowsShell };
309
290
  export function runUpdateCommand(cmd, args) {
310
291
  try {
311
292
  const plan = buildSpawnPlan(cmd, args);
@@ -0,0 +1,44 @@
1
+ export type SpawnPlan = {
2
+ cmd: string;
3
+ args: string[];
4
+ shell: boolean;
5
+ };
6
+ /**
7
+ * Quote one entry for a Windows `cmd.exe` invocation. Node does not quote for
8
+ * `shell: true` on Windows — it joins the array with spaces — so a bare prefix
9
+ * or path like `C:\Program Files\…` would otherwise be split into two arguments,
10
+ * and a value containing a shell metacharacter (`&`, `|`, `^`, `<`, `>`, `(`,
11
+ * `)`) could be reinterpreted by the shell.
12
+ */
13
+ export declare function quoteForWindowsShell(arg: string): string;
14
+ /**
15
+ * How to hand `<cmd> <args…>` to `spawnSync` when the command MUST go through a
16
+ * shell on Windows (a bare manager name like `npm`/`pnpm` that relies on
17
+ * PATH/PATHEXT resolution, or a resolved `.cmd`/`.bat` file).
18
+ *
19
+ * On non-Windows this is a passthrough with `shell: false` and argv untouched.
20
+ * On Windows it sets `shell: true` and quotes BOTH the command and every
21
+ * argument. Quoting the command matters because a resolved path can be absolute
22
+ * and contain spaces — e.g. `C:\Users\First Last\AppData\…\qmd.cmd` — unlike the
23
+ * bare `npm`/`pnpm` names this helper was first written for (those quote to
24
+ * themselves, so their behaviour is unchanged).
25
+ */
26
+ export declare function buildSpawnPlan(cmd: string, args: readonly string[], platform?: NodeJS.Platform): SpawnPlan;
27
+ /**
28
+ * True when a resolved, path-qualified command is a Windows batch file
29
+ * (`.cmd`/`.bat`) — the shape Node refuses to spawn without a shell. Bare
30
+ * command NAMES (which also need a shell on Windows for PATHEXT resolution) are
31
+ * the caller's concern; version-gate passes bare `npm`/`pnpm` and always uses
32
+ * {@link buildSpawnPlan} directly.
33
+ */
34
+ export declare function isWindowsBatchFile(command: string, platform?: NodeJS.Platform): boolean;
35
+ /**
36
+ * Spawn plan for a fully-resolved command (an absolute binary or interpreter
37
+ * path, plus its arguments). Shell-free everywhere EXCEPT a Windows `.cmd`/`.bat`
38
+ * target, which is routed through the quoted shell plan. This keeps user-supplied
39
+ * text off `cmd.exe` on the default path: an ordinary executable (a Node
40
+ * interpreter, an `.exe`, or a POSIX binary) is spawned directly with argv
41
+ * passed through untouched.
42
+ */
43
+ export declare function planCommandSpawn(command: string, args: readonly string[], platform?: NodeJS.Platform): SpawnPlan;
44
+ //# sourceMappingURL=windows-spawn.d.ts.map
@@ -0,0 +1,74 @@
1
+ // src/utils/windows-spawn.ts
2
+ //
3
+ // One Windows spawn policy for the whole CLI.
4
+ //
5
+ // On Windows, `npm`/`pnpm` and every npm-generated dependency bin are `.cmd`
6
+ // batch shims, and since the CVE-2024-27980 hardening (Node 18.20.2 / 20.12.2 /
7
+ // 21.7.3+, i.e. all of Node 22) Node refuses to spawn a `.cmd`/`.bat` file
8
+ // without a shell — `spawnSync` reports `EINVAL` on its result. Anything that
9
+ // must reach such a shim therefore has to go through `shell: true`, and because
10
+ // Node quotes NEITHER the command NOR argv for `shell: true` on Windows, every
11
+ // entry that can contain spaces or `cmd.exe` metacharacters must be quoted here.
12
+ //
13
+ // This module is the single implementation of that policy. The self-update path
14
+ // (version-gate) and the qmd spawn path (search-index) both import it, so there
15
+ // is exactly one Windows spawn recipe in the repo.
16
+ /**
17
+ * Quote one entry for a Windows `cmd.exe` invocation. Node does not quote for
18
+ * `shell: true` on Windows — it joins the array with spaces — so a bare prefix
19
+ * or path like `C:\Program Files\…` would otherwise be split into two arguments,
20
+ * and a value containing a shell metacharacter (`&`, `|`, `^`, `<`, `>`, `(`,
21
+ * `)`) could be reinterpreted by the shell.
22
+ */
23
+ export function quoteForWindowsShell(arg) {
24
+ if (arg === "")
25
+ return '""';
26
+ if (!/[\s"^&|<>()]/.test(arg))
27
+ return arg;
28
+ return `"${arg.replace(/"/g, '\\"')}"`;
29
+ }
30
+ /**
31
+ * How to hand `<cmd> <args…>` to `spawnSync` when the command MUST go through a
32
+ * shell on Windows (a bare manager name like `npm`/`pnpm` that relies on
33
+ * PATH/PATHEXT resolution, or a resolved `.cmd`/`.bat` file).
34
+ *
35
+ * On non-Windows this is a passthrough with `shell: false` and argv untouched.
36
+ * On Windows it sets `shell: true` and quotes BOTH the command and every
37
+ * argument. Quoting the command matters because a resolved path can be absolute
38
+ * and contain spaces — e.g. `C:\Users\First Last\AppData\…\qmd.cmd` — unlike the
39
+ * bare `npm`/`pnpm` names this helper was first written for (those quote to
40
+ * themselves, so their behaviour is unchanged).
41
+ */
42
+ export function buildSpawnPlan(cmd, args, platform = process.platform) {
43
+ if (platform !== "win32")
44
+ return { cmd, args: [...args], shell: false };
45
+ return {
46
+ cmd: quoteForWindowsShell(cmd),
47
+ args: args.map(quoteForWindowsShell),
48
+ shell: true,
49
+ };
50
+ }
51
+ /**
52
+ * True when a resolved, path-qualified command is a Windows batch file
53
+ * (`.cmd`/`.bat`) — the shape Node refuses to spawn without a shell. Bare
54
+ * command NAMES (which also need a shell on Windows for PATHEXT resolution) are
55
+ * the caller's concern; version-gate passes bare `npm`/`pnpm` and always uses
56
+ * {@link buildSpawnPlan} directly.
57
+ */
58
+ export function isWindowsBatchFile(command, platform = process.platform) {
59
+ return platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
60
+ }
61
+ /**
62
+ * Spawn plan for a fully-resolved command (an absolute binary or interpreter
63
+ * path, plus its arguments). Shell-free everywhere EXCEPT a Windows `.cmd`/`.bat`
64
+ * target, which is routed through the quoted shell plan. This keeps user-supplied
65
+ * text off `cmd.exe` on the default path: an ordinary executable (a Node
66
+ * interpreter, an `.exe`, or a POSIX binary) is spawned directly with argv
67
+ * passed through untouched.
68
+ */
69
+ export function planCommandSpawn(command, args, platform = process.platform) {
70
+ if (isWindowsBatchFile(command, platform))
71
+ return buildSpawnPlan(command, args, platform);
72
+ return { cmd: command, args: [...args], shell: false };
73
+ }
74
+ //# sourceMappingURL=windows-spawn.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.98.0",
3
+ "version": "5.98.1",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {