@indigoai-us/hq-cli 5.98.0 → 5.98.2
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 +44 -0
- package/dist/bin/hq-auth-refresh.d.ts +1 -0
- package/dist/bin/hq-auth-refresh.js +1 -0
- package/dist/commands/index-cmd.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/core-utils/common.js +27 -3
- package/dist/lib/search-index/index.d.ts +113 -0
- package/dist/lib/search-index/index.js +287 -41
- package/dist/main.d.ts +1 -0
- package/dist/main.js +1 -0
- package/dist/node-network-compat.d.ts +31 -0
- package/dist/node-network-compat.js +52 -0
- package/dist/utils/version-gate.d.ts +2 -21
- package/dist/utils/version-gate.js +7 -26
- package/dist/utils/windows-spawn.d.ts +44 -0
- package/dist/utils/windows-spawn.js +74 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.98.2] — 2026-08-11
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq core hq-status-summary` — and through it the `/handoff` status summary —
|
|
10
|
+
no longer crashes, or files a Sentry report, on a mature HQ root (HQ-CLI-N,
|
|
11
|
+
Sentry 7664842324). The shared `command()` helper behind the native `hq core`
|
|
12
|
+
utilities ran `spawnSync` with no `maxBuffer`, so every child inherited Node's
|
|
13
|
+
1 MiB default. `hq-status-summary` shells out to `git status --porcelain
|
|
14
|
+
--ignored`, whose output exceeds 1 MiB on any HQ root carrying the usual
|
|
15
|
+
individually-ignored files alongside tracked content; Node then killed git with
|
|
16
|
+
SIGTERM, handed back truncated stdout, and surfaced `Error: spawnSync git
|
|
17
|
+
ENOBUFS`, failing the handoff (observed once, from a single user). The helper
|
|
18
|
+
now caps captured output at 512 MiB — the same ceiling the repo already uses
|
|
19
|
+
for git reads — matching the bundled shell oracle, which redirects git straight
|
|
20
|
+
to a temp file and has no ceiling at all. Spawn errors stay fatal, so a
|
|
21
|
+
truncated status is never reported as a real summary, and an ENOBUFS above the
|
|
22
|
+
new ceiling now throws an error naming the command, its args, and the cap
|
|
23
|
+
instead of the opaque original.
|
|
24
|
+
|
|
25
|
+
## [5.98.1] — 2026-08-10
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Every qmd-backed surface (`hq index`, `hq index status`, `hq search`,
|
|
30
|
+
`hq search get`, and the background reindex) no longer crashes — or files a
|
|
31
|
+
Sentry report — on Windows (HQ-CLI-M, Sentry 7663851953). The CLI resolved the
|
|
32
|
+
bundled qmd to the npm-generated batch shim `node_modules\.bin\qmd.cmd` and
|
|
33
|
+
spawned it with no shell, but since Node's CVE-2024-27980 hardening (all of
|
|
34
|
+
Node 22) Node refuses to run a `.cmd`/`.bat` without a shell and returns
|
|
35
|
+
`EINVAL`. That surfaced as an unactionable `QmdBinaryMissingError` on every
|
|
36
|
+
Windows install. The bundled qmd is now dispatched shell-free through its Node
|
|
37
|
+
launcher (`node <@tobilu/qmd>/bin/qmd`), so the `.cmd` shim — and cmd.exe — is
|
|
38
|
+
off the default path entirely and user search text is never handed to a shell.
|
|
39
|
+
A resolved `.cmd`/`.bat` that does reach a spawn (an `HQ_QMD_BIN` override or a
|
|
40
|
+
PATH shim) now goes through a properly quoted shell plan instead of a bare
|
|
41
|
+
`EINVAL` spawn, the qmd usability probe and the one-shot native-binding repair
|
|
42
|
+
path (including `prebuild-install.cmd` / `node-gyp.cmd` resolution) are fixed
|
|
43
|
+
the same way, and the resolver's diagnostics now report a spawn failure as a
|
|
44
|
+
spawn failure rather than mislabelling it "native bindings unbuilt". The one
|
|
45
|
+
Windows spawn policy (`buildSpawnPlan`) is now shared by the self-update path
|
|
46
|
+
and the qmd path, and additionally quotes the command path (not just argv) so
|
|
47
|
+
an absolute path containing spaces is passed intact.
|
|
48
|
+
|
|
5
49
|
## [5.98.0] — 2026-08-10
|
|
6
50
|
|
|
7
51
|
## [5.97.3] — 2026-08-10
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// MUST be first: guard the Node version before any dependency that needs a
|
|
14
14
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
15
15
|
import "../node-preflight.js";
|
|
16
|
+
import "../node-network-compat.js";
|
|
16
17
|
import { initSentry, Sentry } from "../sentry.js";
|
|
17
18
|
import { refreshCachedSession } from "../utils/cognito-session.js";
|
|
18
19
|
initSentry();
|
|
@@ -111,8 +111,12 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
111
111
|
let registered;
|
|
112
112
|
let qmdStatus;
|
|
113
113
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
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))
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// MUST be first: guard the Node version before any dependency that needs a
|
|
3
3
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
4
4
|
import "./node-preflight.js";
|
|
5
|
+
import "./node-network-compat.js";
|
|
5
6
|
import { CLI_VERSION } from "./cli-version.js";
|
|
6
7
|
function isVersionRequest(argv) {
|
|
7
8
|
const args = argv.slice(2);
|
|
@@ -9,10 +9,34 @@ export function ioFor(io = {}) {
|
|
|
9
9
|
export function line(write, value) {
|
|
10
10
|
write(`${value}\n`);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Ceiling for a single child's captured stdout, mirroring the git-read cap the
|
|
14
|
+
* repo already uses (src/utils/large-file-guard.ts:48). spawnSync defaults
|
|
15
|
+
* maxBuffer to 1 MiB; once a child's output crosses that, Node kills it with
|
|
16
|
+
* SIGTERM and hands back TRUNCATED stdout plus an ENOBUFS error. `hq core
|
|
17
|
+
* hq-status-summary` runs `git status --porcelain --ignored`, whose output
|
|
18
|
+
* exceeds 1 MiB on any mature HQ root (the individually-ignored files that sit
|
|
19
|
+
* beside tracked content), so the default turned a routine /handoff status
|
|
20
|
+
* summary into a crash. The bundled shell oracle this module ports
|
|
21
|
+
* (assets/scaffold/core/scripts/hq-status-summary.sh:108) redirects git straight
|
|
22
|
+
* to a temp file and has no ceiling at all; an explicit, generous cap matches it.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_SPAWN_BUFFER = 512 * 1024 * 1024;
|
|
12
25
|
export function command(command, args, cwd) {
|
|
13
|
-
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
const result = spawnSync(command, args, { cwd, encoding: "utf8", maxBuffer: MAX_SPAWN_BUFFER });
|
|
27
|
+
// Spawn errors stay fatal: a truncated child result must never be reported as
|
|
28
|
+
// a real one — that would silently under-count a handoff's status instead of
|
|
29
|
+
// failing loudly. Above the new ceiling ENOBUFS is still theoretically
|
|
30
|
+
// reachable, so wrap it to name the command, its args and the cap; a future
|
|
31
|
+
// recurrence then arrives in Sentry self-diagnosed instead of as the opaque
|
|
32
|
+
// "spawnSync <cmd> ENOBUFS".
|
|
33
|
+
if (result.error) {
|
|
34
|
+
const error = result.error;
|
|
35
|
+
if (error.code === "ENOBUFS") {
|
|
36
|
+
throw new Error(`spawnSync ${command} ${args.join(" ")} exceeded the ${MAX_SPAWN_BUFFER}-byte output ceiling`, { cause: error });
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
16
40
|
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
17
41
|
}
|
|
18
42
|
export function exists(pathname) {
|
|
@@ -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
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
|
250
|
+
* timeout. Callers cache the boolean per invocation so resolution stays one spawn.
|
|
144
251
|
*/
|
|
145
|
-
function probeQmd(
|
|
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 =
|
|
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
|
-
|
|
182
|
-
|
|
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(
|
|
186
|
-
usableQmdCache.set(
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
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},
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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))
|
package/dist/main.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* HQ CLI - Module management, package management, and cloud sync for HQ
|
|
4
4
|
*/
|
|
5
5
|
import "./node-preflight.js";
|
|
6
|
+
import "./node-network-compat.js";
|
|
6
7
|
import { Sentry } from "./sentry.js";
|
|
7
8
|
export declare function runCli(): Promise<void>;
|
|
8
9
|
export type TopLevelErrorDependencies = {
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// MUST be first: guard the Node version before any dependency that needs a
|
|
6
6
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
7
7
|
import "./node-preflight.js";
|
|
8
|
+
import "./node-network-compat.js";
|
|
8
9
|
import { Command } from "commander";
|
|
9
10
|
import { initSentry, Sentry } from "./sentry.js";
|
|
10
11
|
import { registerAddCommand } from "./commands/add.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node network defaults for HQ CLI entry points.
|
|
3
|
+
*
|
|
4
|
+
* Node's default 250 ms address-family attempt window can fail on hosts where
|
|
5
|
+
* IPv6 is advertised but black-holed. On affected WSL installations, undici's
|
|
6
|
+
* fetch() reaches ETIMEDOUT before its usable IPv4 path completes. A 1 second
|
|
7
|
+
* window fixes the connection while retaining Node's automatic family choice.
|
|
8
|
+
*
|
|
9
|
+
* Keep this module dependency-free and import it immediately after the Node
|
|
10
|
+
* version preflight. Operators can retain a custom value through NODE_OPTIONS
|
|
11
|
+
* or a direct Node CLI option; HQ only supplies the compatibility default when
|
|
12
|
+
* neither is present.
|
|
13
|
+
*
|
|
14
|
+
* Use a namespace import of `node:net` (not a static named import of
|
|
15
|
+
* `setDefaultAutoSelectFamilyAttemptTimeout`). Named ESM imports are linked
|
|
16
|
+
* before any entry-module body runs — including `node-preflight` — so importing
|
|
17
|
+
* a Node 18.13+/20.4+ API by name would turn unsupported runtimes into a
|
|
18
|
+
* module-link SyntaxError instead of the preflight's upgrade message.
|
|
19
|
+
*/
|
|
20
|
+
export declare const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
|
|
21
|
+
export interface NodeNetworkCompatibilityOptions {
|
|
22
|
+
nodeOptions?: string;
|
|
23
|
+
execArgv?: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Inject the net timeout setter (tests). Pass `null` to simulate a runtime
|
|
26
|
+
* that lacks `setDefaultAutoSelectFamilyAttemptTimeout`.
|
|
27
|
+
*/
|
|
28
|
+
setAttemptTimeout?: ((milliseconds: number) => void) | null;
|
|
29
|
+
}
|
|
30
|
+
export declare function configureNodeNetworkCompatibility(options?: NodeNetworkCompatibilityOptions): boolean;
|
|
31
|
+
//# sourceMappingURL=node-network-compat.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node network defaults for HQ CLI entry points.
|
|
3
|
+
*
|
|
4
|
+
* Node's default 250 ms address-family attempt window can fail on hosts where
|
|
5
|
+
* IPv6 is advertised but black-holed. On affected WSL installations, undici's
|
|
6
|
+
* fetch() reaches ETIMEDOUT before its usable IPv4 path completes. A 1 second
|
|
7
|
+
* window fixes the connection while retaining Node's automatic family choice.
|
|
8
|
+
*
|
|
9
|
+
* Keep this module dependency-free and import it immediately after the Node
|
|
10
|
+
* version preflight. Operators can retain a custom value through NODE_OPTIONS
|
|
11
|
+
* or a direct Node CLI option; HQ only supplies the compatibility default when
|
|
12
|
+
* neither is present.
|
|
13
|
+
*
|
|
14
|
+
* Use a namespace import of `node:net` (not a static named import of
|
|
15
|
+
* `setDefaultAutoSelectFamilyAttemptTimeout`). Named ESM imports are linked
|
|
16
|
+
* before any entry-module body runs — including `node-preflight` — so importing
|
|
17
|
+
* a Node 18.13+/20.4+ API by name would turn unsupported runtimes into a
|
|
18
|
+
* module-link SyntaxError instead of the preflight's upgrade message.
|
|
19
|
+
*/
|
|
20
|
+
import * as nodeNet from "node:net";
|
|
21
|
+
export const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
|
|
22
|
+
const ATTEMPT_TIMEOUT_OPTION = "--network-family-autoselection-attempt-timeout";
|
|
23
|
+
function hasExplicitAttemptTimeout(nodeOptions, execArgv) {
|
|
24
|
+
const optionPattern = new RegExp(`(?:^|\\s)${ATTEMPT_TIMEOUT_OPTION}(?:=|\\s|$)`);
|
|
25
|
+
return (optionPattern.test(nodeOptions) ||
|
|
26
|
+
execArgv.some((argument) => argument === ATTEMPT_TIMEOUT_OPTION ||
|
|
27
|
+
argument.startsWith(`${ATTEMPT_TIMEOUT_OPTION}=`)));
|
|
28
|
+
}
|
|
29
|
+
function resolveSetAttemptTimeout(override) {
|
|
30
|
+
if (override === null)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (override)
|
|
33
|
+
return override;
|
|
34
|
+
const fn = nodeNet.setDefaultAutoSelectFamilyAttemptTimeout;
|
|
35
|
+
return typeof fn === "function" ? fn.bind(nodeNet) : undefined;
|
|
36
|
+
}
|
|
37
|
+
export function configureNodeNetworkCompatibility(options = {}) {
|
|
38
|
+
const nodeOptions = options.nodeOptions ?? process.env.NODE_OPTIONS ?? "";
|
|
39
|
+
const execArgv = options.execArgv ?? process.execArgv;
|
|
40
|
+
if (hasExplicitAttemptTimeout(nodeOptions, execArgv)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const setAttemptTimeout = resolveSetAttemptTimeout(options.setAttemptTimeout);
|
|
44
|
+
if (!setAttemptTimeout) {
|
|
45
|
+
// Runtime lacks the API (pre-Node 18.13 / preflight should already have exited).
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
setAttemptTimeout(HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
configureNodeNetworkCompatibility();
|
|
52
|
+
//# sourceMappingURL=node-network-compat.js.map
|
|
@@ -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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|