@indigoai-us/hq-cli 5.106.0 → 5.106.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 +20 -0
- package/dist/commands/core-checkpoint.d.ts +10 -0
- package/dist/commands/core-checkpoint.js +29 -6
- package/dist/commands/index-cmd.js +13 -0
- package/dist/lib/search-index/index.d.ts +48 -4
- package/dist/lib/search-index/index.js +92 -7
- package/dist/main.js +38 -2
- package/dist/utils/cli-telemetry.js +1 -0
- package/dist/utils/hq-state-write-error.d.ts +41 -0
- package/dist/utils/hq-state-write-error.js +113 -0
- package/dist/utils/qmd-store-unopenable-error.d.ts +19 -0
- package/dist/utils/qmd-store-unopenable-error.js +99 -0
- package/dist/utils/self-update.js +11 -2
- package/dist/utils/sentry-fingerprint.js +1 -0
- package/dist/utils/version-gate.d.ts +64 -2
- package/dist/utils/version-gate.js +173 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.106.2] — 2026-09-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- An `hq` installed as a local project dependency no longer updates itself in an
|
|
10
|
+
endless loop. The updater used to treat a pnpm virtual store
|
|
11
|
+
(`node_modules/.pnpm/...`) as an npm install prefix and install there — a
|
|
12
|
+
directory nothing on PATH ever reads — then report success, so the next
|
|
13
|
+
command found the same old build and updated again. On one outpost this ran
|
|
14
|
+
`npm install -g` up to 31 times an hour for days. Such copies are now
|
|
15
|
+
identified as local, reported with the path to fix, and left for their owning
|
|
16
|
+
project to update.
|
|
17
|
+
- The updater refuses an "update" to a version that is not newer than the one
|
|
18
|
+
already installed, instead of reinstalling it on every invocation.
|
|
19
|
+
- Success is now reported only after verifying the new version is the one PATH
|
|
20
|
+
actually resolves. An install that lands somewhere the running `hq` never
|
|
21
|
+
reads is reported as a failed update rather than as a success.
|
|
22
|
+
|
|
23
|
+
## [5.106.1] — 2026-09-02
|
|
24
|
+
|
|
5
25
|
## [5.106.0] — 2026-09-02
|
|
6
26
|
|
|
7
27
|
### Fixed
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* reads/writes. It never waits for the sibling, and it never performs network
|
|
7
7
|
* I/O itself.
|
|
8
8
|
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
9
10
|
import { Command } from "commander";
|
|
10
11
|
type Backend = "claude" | "codex" | "grok" | "none";
|
|
11
12
|
type SpawnableBackend = Exclude<Backend, "none">;
|
|
@@ -41,6 +42,15 @@ export declare const CHECKPOINT_IDLE_REPLY_REMINDER: string;
|
|
|
41
42
|
*/
|
|
42
43
|
export declare const SIBLING_PROMPT_TEMPLATE = "You are the HQ checkpoint sibling \u2014 a background maintenance agent for this\nHQ install. Your parent session's state is in <payloadPath>. Work\nquietly and do not ask questions; if something is ambiguous, record it in the\nreport instead of guessing.\n\n1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)\n both for session context and to extract additional reusable learnings/insights\n the parent did not pass explicitly. Never quote secrets\n or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists\n under this HQ root, read it and follow it wherever it goes beyond these instructions;\n the write bounds below always win over the skill text.\n2. Upgrade the thread file named in the payload IN PLACE: verify/repair its\n JSON; fill git.remote_url, git.initial_commit, git.commits_made, and\n git.knowledge_repos by scanning core/knowledge/public/*,\n core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge\n for symlinks or directories containing .git, recording dirty repositories\n as {\"<name>\": {\"commit\": \"<short>\", \"dirty\": true}}. Fill worker,\n next_steps, and insights; set type to \"checkpoint\"; then rename the file to\n drop -auto- from its filename. Use the renamed path in every reference you\n write afterwards.\n3. For every explicit or transcript-derived learning that is a reusable rule,\n FIRST search the existing policies for one the learning refines, contradicts\n or duplicates. Then take exactly one of these actions and name it in the\n report \u2014 the policy set is curated, not append-only:\n - AMEND an existing policy in place when the learning sharpens it, narrows\n its scope, or adds a case, and the rule as written is still correct.\n - SUPERSEDE it when the learning contradicts it: rewrite the rule to what is\n now true and record inside the file what changed and why.\n - MERGE near-duplicates into the single best-named file, then delete the\n files you merged away.\n - CREATE a new policy only when no existing policy covers the rule.\n Follow core/knowledge/public/hq-core/policies-spec.md. Write under\n personal/policies/ or, only when the payload names a company and the rule is\n company-specific, companies/<company>/policies/.\n DELETION BOUNDS: delete a policy only as the MERGE or SUPERSEDE step above,\n only inside those two directories, and never one whose body marks it HARD \u2014\n if a hard policy now looks wrong, leave it untouched and flag it in the\n report for a human to decide. Never delete a file you have not read.\n Apply the same curation to durable facts (not rules) under\n personal/knowledge/ or companies/<company>/knowledge/: correct a stale fact\n in place rather than appending a second, contradictory copy of it.\n Store up to two explicit or transcript-derived insights per\n core/knowledge/public/hq-core/insights-spec.md when present, otherwise\n workspace/insights/.\n4. Close an active session journal fail-soft with\n bash .claude/skills/_shared/journal.sh close \"<project_dir>\" \"<one-line synthesis>\".\n Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with\n id, created_at, summary, files, and next_steps for backward compatibility.\n5. Update workspace/threads/recent.md and regenerate\n workspace/threads/INDEX.md. For each company whose knowledge path appears\n in files_touched, regenerate companies/<company>/knowledge/INDEX.md under\n core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation\n is allowed for those companies, but knowledge/policy content writes remain\n restricted to the payload's named company.\n6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;\n skip silently on any failure. Hook or automation improvements go ONLY under\n personal/hooks/ as proposals.\n7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.\n8. Write <runDir>/report.md \u2014 full prose: what you read, what you changed\n (paths), and what you skipped and why. List every policy or knowledge file\n you amended, superseded, merged or deleted with the reason, so a human can\n audit and reverse it; a deletion you do not name in the report is a defect.\n Then drain the queue: while\n workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim\n it atomically by renaming it aside \u2014\n mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl\n (N counting up from 2) \u2014 and process the claimed payloads with this same\n flow. Repeat until a claim finds nothing left, then update the report.\n NEVER read the queue and truncate it in place: a payload appended between\n your read and the truncate is lost, and its checkpoint is never enriched.\n";
|
|
43
44
|
export declare function renderSiblingPrompt(runDir: string, payloadPath: string): string;
|
|
45
|
+
export type WriteStampsOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* Filesystem seam (default the real `node:fs`); injected in tests to force a
|
|
48
|
+
* permission errno so the HqStateWriteError conversion can be proven without a
|
|
49
|
+
* real unwritable path.
|
|
50
|
+
*/
|
|
51
|
+
fs?: Pick<typeof fs, "mkdirSync" | "writeFileSync">;
|
|
52
|
+
};
|
|
53
|
+
export declare function writeStamps(liveRoot: string, sessionId: string | undefined, options?: WriteStampsOptions): string[];
|
|
44
54
|
/**
|
|
45
55
|
* Preference order for `--backend auto`: the caller's own backend first, so a
|
|
46
56
|
* claude session keeps using claude and a codex session keeps using codex
|
|
@@ -12,6 +12,7 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
14
14
|
import { peekIdToken } from "../utils/id-token.js";
|
|
15
|
+
import { toHqStateWriteError } from "../utils/hq-state-write-error.js";
|
|
15
16
|
const DEFAULT_TRIGGER = "stop-gate";
|
|
16
17
|
const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
|
|
17
18
|
// Pinned by operator directive 2026-07-31; change defaults here deliberately.
|
|
@@ -368,9 +369,30 @@ function gitStateFor(cwd, liveRoot, initialCommit, explicitCommits) {
|
|
|
368
369
|
: [],
|
|
369
370
|
};
|
|
370
371
|
}
|
|
371
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Perform a write into HQ's OWN state tree, converting a filesystem errno into a
|
|
374
|
+
* typed, path-naming {@link HqStateWriteError} (the boundary prints that and does
|
|
375
|
+
* NOT capture it — the fault is the user's machine, not an hq bug) while leaving
|
|
376
|
+
* any NON-errno failure — a genuine defect — to propagate unchanged and stay
|
|
377
|
+
* reportable. Scoped by the caller to the checkpoint state paths this command
|
|
378
|
+
* derives. The checkpoint still FAILS (exits non-zero) on such an error; only
|
|
379
|
+
* the classification and the message change (HQ-CLI-19, Sentry 7702730566).
|
|
380
|
+
*/
|
|
381
|
+
function writeHqState(targetPath, operation, perform) {
|
|
382
|
+
try {
|
|
383
|
+
perform();
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
const typed = toHqStateWriteError(error, targetPath, operation);
|
|
387
|
+
if (typed)
|
|
388
|
+
throw typed;
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export function writeStamps(liveRoot, sessionId, options = {}) {
|
|
393
|
+
const fsImpl = options.fs ?? fs;
|
|
372
394
|
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
373
|
-
|
|
395
|
+
writeHqState(stateDir, "create the checkpoint state directory", () => fsImpl.mkdirSync(stateDir, { recursive: true }));
|
|
374
396
|
const sessionKey = sessionId
|
|
375
397
|
? sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown"
|
|
376
398
|
: "unknown";
|
|
@@ -379,8 +401,9 @@ function writeStamps(liveRoot, sessionId) {
|
|
|
379
401
|
path.join(stateDir, "checkpoint-cli-last"),
|
|
380
402
|
path.join(stateDir, `checkpoint-cli-last-${sessionKey}`),
|
|
381
403
|
];
|
|
382
|
-
for (const stampPath of stampPaths)
|
|
383
|
-
|
|
404
|
+
for (const stampPath of stampPaths) {
|
|
405
|
+
writeHqState(stampPath, "write its checkpoint state file", () => fsImpl.writeFileSync(stampPath, timestamp));
|
|
406
|
+
}
|
|
384
407
|
return stampPaths;
|
|
385
408
|
}
|
|
386
409
|
/**
|
|
@@ -894,8 +917,8 @@ function runCheckpoint(options, command, group) {
|
|
|
894
917
|
trigger: input.trigger,
|
|
895
918
|
},
|
|
896
919
|
};
|
|
897
|
-
fs.mkdirSync(path.dirname(threadPath), { recursive: true });
|
|
898
|
-
fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`);
|
|
920
|
+
writeHqState(path.dirname(threadPath), "create the checkpoint thread directory", () => fs.mkdirSync(path.dirname(threadPath), { recursive: true }));
|
|
921
|
+
writeHqState(threadPath, "write the checkpoint thread file", () => fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`));
|
|
899
922
|
writeStamps(liveRoot, input.sessionId);
|
|
900
923
|
printResult(`checkpoint: ${relativeThreadPath}`);
|
|
901
924
|
// finally: the thread is already written at this point, so the agent must
|
|
@@ -4,6 +4,7 @@ import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher,
|
|
|
4
4
|
import { findHqRoot } from '../utils/manifest.js';
|
|
5
5
|
import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
|
|
6
6
|
import { isQmdStoreMissingError, qmdStoreMissingMessage, } from '../utils/qmd-store-missing-error.js';
|
|
7
|
+
import { isQmdStoreUnopenableError, qmdStoreUnopenableMessage, } from '../utils/qmd-store-unopenable-error.js';
|
|
7
8
|
const defaults = {
|
|
8
9
|
reconcileCollections,
|
|
9
10
|
deriveCollections,
|
|
@@ -134,6 +135,18 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
134
135
|
process.stderr.write(`qmd: unusable — ${remedy}\n`);
|
|
135
136
|
process.exitCode = 1;
|
|
136
137
|
}
|
|
138
|
+
else if (isQmdStoreUnopenableError(error)) {
|
|
139
|
+
// The local qmd store directory exists but cannot be opened —
|
|
140
|
+
// typically not writable, so better-sqlite3 throws SQLITE_CANTOPEN
|
|
141
|
+
// (HQ-CLI-18). Like the store-missing case, this diagnostic command
|
|
142
|
+
// should DESCRIBE the broken local store, not crash on it (and never
|
|
143
|
+
// forward qmd's macOS-only Homebrew advisory): print the classified
|
|
144
|
+
// reason + remedy and exit 1 without rethrowing. Every OTHER qmd
|
|
145
|
+
// failure keeps propagating.
|
|
146
|
+
const remedy = qmdStoreUnopenableMessage(error) ?? 'its local search store could not be opened';
|
|
147
|
+
process.stderr.write(`qmd: unusable — ${remedy}\n`);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
}
|
|
137
150
|
else {
|
|
138
151
|
throw error;
|
|
139
152
|
}
|
|
@@ -175,6 +175,31 @@ export declare class QmdStoreMissingError extends QmdExitError {
|
|
|
175
175
|
name: string;
|
|
176
176
|
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, ensureReason?: string | undefined);
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* qmd could not OPEN its SQLite store even though the store's directory EXISTS.
|
|
180
|
+
* better-sqlite3 throws `SqliteError: unable to open database file`
|
|
181
|
+
* (SQLITE_CANTOPEN) — most often because the store directory is not writable, so
|
|
182
|
+
* WAL mode (`PRAGMA journal_mode = WAL`) cannot create its -wal/-shm sidecars.
|
|
183
|
+
* The DISJOINT other half of "hq's store could not be opened": {@link
|
|
184
|
+
* QmdStoreMissingError} is a directory that does not EXIST; this is a directory
|
|
185
|
+
* that exists but is not USABLE. Either way it is the caller's ENVIRONMENT —
|
|
186
|
+
* permissions on the cache/index directory or its files — never a bug HQ can fix
|
|
187
|
+
* in code.
|
|
188
|
+
*
|
|
189
|
+
* `storeDir` carries the directory hq resolved qmd would open its store in, and
|
|
190
|
+
* `probeReason` the bounded errno phrase from hq's own best-effort WRITABILITY
|
|
191
|
+
* probe of that directory (undefined when the probe passed or did not run — e.g.
|
|
192
|
+
* a read-only DB file inside a writable directory). Both are hq-DERIVED — the
|
|
193
|
+
* resolved path plus a finite errno reason — so the boundary can name them in an
|
|
194
|
+
* input-free remedy and NEVER re-emit qmd's macOS-only Homebrew/BREW_PREFIX
|
|
195
|
+
* advisory to a Linux terminal. HQ-CLI-18 (Sentry 7702459375).
|
|
196
|
+
*/
|
|
197
|
+
export declare class QmdStoreUnopenableError extends QmdExitError {
|
|
198
|
+
readonly storeDir?: string | undefined;
|
|
199
|
+
readonly probeReason?: string | undefined;
|
|
200
|
+
name: string;
|
|
201
|
+
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, probeReason?: string | undefined);
|
|
202
|
+
}
|
|
178
203
|
export type ResolveQmdBinOptions = {
|
|
179
204
|
env?: Record<string, string | undefined>;
|
|
180
205
|
isExecutable?: (candidate: string) => boolean;
|
|
@@ -231,12 +256,15 @@ export declare function resolveQmdVersion(): string | undefined;
|
|
|
231
256
|
* directory so the mkdir happens at most once per distinct directory per process
|
|
232
257
|
* (idempotent, bounded — a process opens a tiny, fixed set of store dirs). Read
|
|
233
258
|
* by finishRunQmd to stamp the directory and the errno reason onto a
|
|
234
|
-
* QmdStoreMissingError
|
|
235
|
-
*
|
|
259
|
+
* QmdStoreMissingError (mkdir failed) or a QmdStoreUnopenableError (mkdir
|
|
260
|
+
* succeeded but the writability probe failed), turning a condition that carried
|
|
261
|
+
* no cause in the reported event into a self-describing one. `writable` records
|
|
262
|
+
* the create+unlink probe result; `created` records whether the mkdir ran.
|
|
236
263
|
*/
|
|
237
264
|
type QmdStoreEnsureOutcome = {
|
|
238
265
|
dir: string;
|
|
239
266
|
created: boolean;
|
|
267
|
+
writable: boolean;
|
|
240
268
|
reason?: string;
|
|
241
269
|
};
|
|
242
270
|
/** Test-only: read the most recent store-directory ensure outcome. */
|
|
@@ -246,16 +274,32 @@ export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | und
|
|
|
246
274
|
* pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
|
|
247
275
|
* qmdHomedir) against the SAME env the qmd child receives:
|
|
248
276
|
* - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
|
|
249
|
-
* so the store file's directory is the one that must exist)
|
|
277
|
+
* so the store file's directory is the one that must exist). A RELATIVE
|
|
278
|
+
* INDEX_PATH is resolved against `cwd` — the working directory the qmd child
|
|
279
|
+
* will run in (index sync/status/background pass one) — because qmd opens
|
|
280
|
+
* `new Database(INDEX_PATH)` relative to its own cwd, not the CLI process's;
|
|
250
281
|
* - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
|
|
251
282
|
* $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
|
|
252
283
|
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
253
284
|
* changes it turns CI red instead of silently restoring the noise.
|
|
254
285
|
*/
|
|
255
|
-
export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv): string;
|
|
286
|
+
export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv, cwd?: string): string;
|
|
256
287
|
export type EnsureQmdStoreDirOptions = {
|
|
288
|
+
/**
|
|
289
|
+
* The working directory the qmd child will run in, used to resolve a RELATIVE
|
|
290
|
+
* INDEX_PATH the same way qmd does. Defaults to `process.cwd()` (spawnSync's
|
|
291
|
+
* default when no cwd is given), so it only matters when a caller passes a cwd.
|
|
292
|
+
*/
|
|
293
|
+
cwd?: string;
|
|
257
294
|
/** mkdir seam (default `fs.mkdirSync(dir, { recursive: true })`); injected in tests. */
|
|
258
295
|
mkdir?: (dir: string) => void;
|
|
296
|
+
/**
|
|
297
|
+
* Writability probe seam (default: create+unlink a uniquely-named temp file
|
|
298
|
+
* inside `dir`); injected in tests. MUST throw on failure. A create+unlink
|
|
299
|
+
* probe is used rather than `fs.accessSync(W_OK)` because access() reports
|
|
300
|
+
* writable for root and can disagree with open() under macOS TCC.
|
|
301
|
+
*/
|
|
302
|
+
probeWritable?: (dir: string) => void;
|
|
259
303
|
};
|
|
260
304
|
/**
|
|
261
305
|
* Best-effort, once-per-directory self-provisioning of qmd's store directory
|
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
|
|
8
8
|
import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
|
|
9
9
|
import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
|
|
10
|
+
import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
|
|
10
11
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
11
12
|
import { planCommandSpawn } from '../../utils/windows-spawn.js';
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
@@ -159,6 +160,35 @@ export class QmdStoreMissingError extends QmdExitError {
|
|
|
159
160
|
this.ensureReason = ensureReason;
|
|
160
161
|
}
|
|
161
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* qmd could not OPEN its SQLite store even though the store's directory EXISTS.
|
|
165
|
+
* better-sqlite3 throws `SqliteError: unable to open database file`
|
|
166
|
+
* (SQLITE_CANTOPEN) — most often because the store directory is not writable, so
|
|
167
|
+
* WAL mode (`PRAGMA journal_mode = WAL`) cannot create its -wal/-shm sidecars.
|
|
168
|
+
* The DISJOINT other half of "hq's store could not be opened": {@link
|
|
169
|
+
* QmdStoreMissingError} is a directory that does not EXIST; this is a directory
|
|
170
|
+
* that exists but is not USABLE. Either way it is the caller's ENVIRONMENT —
|
|
171
|
+
* permissions on the cache/index directory or its files — never a bug HQ can fix
|
|
172
|
+
* in code.
|
|
173
|
+
*
|
|
174
|
+
* `storeDir` carries the directory hq resolved qmd would open its store in, and
|
|
175
|
+
* `probeReason` the bounded errno phrase from hq's own best-effort WRITABILITY
|
|
176
|
+
* probe of that directory (undefined when the probe passed or did not run — e.g.
|
|
177
|
+
* a read-only DB file inside a writable directory). Both are hq-DERIVED — the
|
|
178
|
+
* resolved path plus a finite errno reason — so the boundary can name them in an
|
|
179
|
+
* input-free remedy and NEVER re-emit qmd's macOS-only Homebrew/BREW_PREFIX
|
|
180
|
+
* advisory to a Linux terminal. HQ-CLI-18 (Sentry 7702459375).
|
|
181
|
+
*/
|
|
182
|
+
export class QmdStoreUnopenableError extends QmdExitError {
|
|
183
|
+
storeDir;
|
|
184
|
+
probeReason;
|
|
185
|
+
name = 'QmdStoreUnopenableError';
|
|
186
|
+
constructor(message, args, status, stdout, stderr, storeDir, probeReason) {
|
|
187
|
+
super(message, args, status, stdout, stderr);
|
|
188
|
+
this.storeDir = storeDir;
|
|
189
|
+
this.probeReason = probeReason;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
162
192
|
function isExecutable(candidate) {
|
|
163
193
|
try {
|
|
164
194
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -305,16 +335,21 @@ export function lastQmdStoreEnsureOutcome() {
|
|
|
305
335
|
* pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
|
|
306
336
|
* qmdHomedir) against the SAME env the qmd child receives:
|
|
307
337
|
* - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
|
|
308
|
-
* so the store file's directory is the one that must exist)
|
|
338
|
+
* so the store file's directory is the one that must exist). A RELATIVE
|
|
339
|
+
* INDEX_PATH is resolved against `cwd` — the working directory the qmd child
|
|
340
|
+
* will run in (index sync/status/background pass one) — because qmd opens
|
|
341
|
+
* `new Database(INDEX_PATH)` relative to its own cwd, not the CLI process's;
|
|
309
342
|
* - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
|
|
310
343
|
* $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
|
|
311
344
|
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
312
345
|
* changes it turns CI red instead of silently restoring the noise.
|
|
313
346
|
*/
|
|
314
|
-
export function resolveQmdStoreDir(env = process.env) {
|
|
347
|
+
export function resolveQmdStoreDir(env = process.env, cwd = process.cwd()) {
|
|
315
348
|
const indexPath = env.INDEX_PATH;
|
|
349
|
+
// path.resolve leaves an absolute INDEX_PATH untouched and anchors a relative
|
|
350
|
+
// one to the child cwd, so the probed/stamped directory is the one qmd uses.
|
|
316
351
|
if (indexPath)
|
|
317
|
-
return path.dirname(indexPath);
|
|
352
|
+
return path.dirname(path.resolve(cwd, indexPath));
|
|
318
353
|
const home = env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
|
|
319
354
|
const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
320
355
|
return path.join(cacheDir, 'qmd');
|
|
@@ -338,6 +373,23 @@ function ensureFailureReason(error) {
|
|
|
338
373
|
return typeof code === 'string' && code.length > 0 ? code : 'an unknown error';
|
|
339
374
|
}
|
|
340
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Create AND unlink a uniquely-named temp file inside `dir` to prove qmd will be
|
|
378
|
+
* able to open (and, under WAL, create/delete the -wal/-shm sidecars in) its
|
|
379
|
+
* store. A recursive mkdir is a NO-OP on an existing directory, so a successful
|
|
380
|
+
* mkdir does NOT prove writability — this is what makes the store-unopenable
|
|
381
|
+
* remedy self-describing instead of evidence-free (HQ-CLI-18). BOTH the create
|
|
382
|
+
* and the unlink must succeed: a directory that allows creation but denies
|
|
383
|
+
* deletion cannot support WAL sidecar churn, so a failed unlink is a real
|
|
384
|
+
* writability failure (and swallowing it would also litter a probe file every
|
|
385
|
+
* process). Either failure throws so the caller records the bounded errno
|
|
386
|
+
* reason; on the happy path the probe file is always removed.
|
|
387
|
+
*/
|
|
388
|
+
function probeDirWritable(dir) {
|
|
389
|
+
const probe = path.join(dir, `.hq-write-probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
390
|
+
fs.writeFileSync(probe, '');
|
|
391
|
+
fs.unlinkSync(probe);
|
|
392
|
+
}
|
|
341
393
|
/**
|
|
342
394
|
* Best-effort, once-per-directory self-provisioning of qmd's store directory
|
|
343
395
|
* BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
|
|
@@ -351,7 +403,7 @@ function ensureFailureReason(error) {
|
|
|
351
403
|
export function ensureQmdStoreDir(env = process.env, options = {}) {
|
|
352
404
|
let dir;
|
|
353
405
|
try {
|
|
354
|
-
dir = resolveQmdStoreDir(env);
|
|
406
|
+
dir = resolveQmdStoreDir(env, options.cwd);
|
|
355
407
|
}
|
|
356
408
|
catch {
|
|
357
409
|
// Resolution itself must never break a qmd run.
|
|
@@ -360,12 +412,23 @@ export function ensureQmdStoreDir(env = process.env, options = {}) {
|
|
|
360
412
|
let outcome = storeEnsureByDir.get(dir);
|
|
361
413
|
if (!outcome) {
|
|
362
414
|
const mkdir = options.mkdir ?? ((target) => { fs.mkdirSync(target, { recursive: true }); });
|
|
415
|
+
const probeWritable = options.probeWritable ?? probeDirWritable;
|
|
363
416
|
try {
|
|
364
417
|
mkdir(dir);
|
|
365
|
-
|
|
418
|
+
// mkdir(recursive) is a no-op on an EXISTING directory, so success does not
|
|
419
|
+
// prove qmd can open its store there. Probe real writability so an
|
|
420
|
+
// existing-but-unwritable store dir — the reported HQ-CLI-18 shape — is
|
|
421
|
+
// recorded with its errno reason rather than looking successful.
|
|
422
|
+
try {
|
|
423
|
+
probeWritable(dir);
|
|
424
|
+
outcome = { dir, created: true, writable: true };
|
|
425
|
+
}
|
|
426
|
+
catch (probeError) {
|
|
427
|
+
outcome = { dir, created: true, writable: false, reason: ensureFailureReason(probeError) };
|
|
428
|
+
}
|
|
366
429
|
}
|
|
367
430
|
catch (error) {
|
|
368
|
-
outcome = { dir, created: false, reason: ensureFailureReason(error) };
|
|
431
|
+
outcome = { dir, created: false, writable: false, reason: ensureFailureReason(error) };
|
|
369
432
|
}
|
|
370
433
|
storeEnsureByDir.set(dir, outcome);
|
|
371
434
|
}
|
|
@@ -951,6 +1014,28 @@ function finishRunQmd(result, bin, args) {
|
|
|
951
1014
|
const ensure = lastQmdStoreEnsure;
|
|
952
1015
|
throw new QmdStoreMissingError(`qmd ${subcommand} could not run: its local search store could not be opened (its directory does not exist)`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.created ? ensure.reason : undefined);
|
|
953
1016
|
}
|
|
1017
|
+
// qmd could not OPEN its SQLite store even though the directory EXISTS
|
|
1018
|
+
// (better-sqlite3: `SqliteError: unable to open database file` /
|
|
1019
|
+
// SQLITE_CANTOPEN — typically the store directory is not writable, so WAL mode
|
|
1020
|
+
// cannot create its -wal/-shm sidecars). That is the caller's environment —
|
|
1021
|
+
// permissions on the cache/index directory or its files — not an hq-cli
|
|
1022
|
+
// defect. Type it so the boundary prints hq's OWN platform-correct remedy
|
|
1023
|
+
// (naming the resolved store dir and the recorded writability-probe reason)
|
|
1024
|
+
// and skips capture, INSTEAD of forwarding qmd's macOS-only Homebrew/BREW_PREFIX
|
|
1025
|
+
// advisory verbatim to a Linux terminal (HQ-CLI-18, Sentry 7702459375). Read
|
|
1026
|
+
// qmd's OWN captured streams only, never the synthesized message, so a user
|
|
1027
|
+
// query containing that phrase can never trip it. Placed AFTER the
|
|
1028
|
+
// store-MISSING check (its sentence is the DISJOINT `Cannot open database
|
|
1029
|
+
// because the directory does not exist`, so it keeps priority for its own
|
|
1030
|
+
// shape) and BEFORE the collection-missing regex (that regex needs
|
|
1031
|
+
// `collection`/`qmd://` adjacent to a not-found token, which this stderr
|
|
1032
|
+
// lacks), so no existing branch changes behaviour. The stamped
|
|
1033
|
+
// storeDir/probeReason come from the pre-spawn ensure that ran for this exact
|
|
1034
|
+
// store directory.
|
|
1035
|
+
if (isQmdStoreUnopenableError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
1036
|
+
const ensure = lastQmdStoreEnsure;
|
|
1037
|
+
throw new QmdStoreUnopenableError(`qmd ${subcommand} could not run: its local search store could not be opened`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.writable ? ensure.reason : undefined);
|
|
1038
|
+
}
|
|
954
1039
|
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
|
|
955
1040
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
956
1041
|
}
|
|
@@ -995,7 +1080,7 @@ export function runQmd(args, options = {}) {
|
|
|
995
1080
|
// classified with its cause instead of captured (HQ-CLI-16). Best-effort and
|
|
996
1081
|
// idempotent; only the real spawn path needs it (the injected-runner path
|
|
997
1082
|
// above never opens a store).
|
|
998
|
-
ensureQmdStoreDir(options.env ?? process.env);
|
|
1083
|
+
ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
|
|
999
1084
|
const result = spawnQmd(invocation, args, {
|
|
1000
1085
|
cwd: options.cwd,
|
|
1001
1086
|
env: options.env ?? process.env,
|
package/dist/main.js
CHANGED
|
@@ -72,6 +72,8 @@ import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
|
|
|
72
72
|
import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
|
|
73
73
|
import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
|
|
74
74
|
import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
|
|
75
|
+
import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
|
|
76
|
+
import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
|
|
75
77
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
76
78
|
import { isEpipe } from "./utils/epipe.js";
|
|
77
79
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
@@ -556,7 +558,33 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
556
558
|
const storeMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
|
|
557
559
|
? null
|
|
558
560
|
: qmdStoreMissingMessage(err);
|
|
559
|
-
|
|
561
|
+
// A qmd store-UNOPENABLE failure (better-sqlite3 `SqliteError: unable to
|
|
562
|
+
// open database file` / SQLITE_CANTOPEN: the store directory EXISTS but is
|
|
563
|
+
// not writable, so WAL sidecars cannot be created) is the caller's local
|
|
564
|
+
// filesystem/permissions, not an hq-cli defect. finishRunQmd types it
|
|
565
|
+
// QmdStoreUnopenableError, carrying the resolved store directory and the
|
|
566
|
+
// writability-probe errno reason; print that self-describing, PLATFORM-
|
|
567
|
+
// CORRECT remedy and skip capture, so qmd's macOS-only Homebrew/BREW_PREFIX
|
|
568
|
+
// advisory can NEVER reach a Linux terminal (HQ-CLI-18, Sentry 7702459375).
|
|
569
|
+
// Evaluated immediately AFTER the store-MISSING check (disjoint sentence, so
|
|
570
|
+
// it keeps priority for its own shape) and BEFORE the environmental /
|
|
571
|
+
// transport / generic branches.
|
|
572
|
+
const storeUnopenableMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
|
|
573
|
+
? null
|
|
574
|
+
: qmdStoreUnopenableMessage(err);
|
|
575
|
+
// An hq STATE-TREE write that failed for an environmental errno
|
|
576
|
+
// (permission/quota/read-only/parent-gone) is typed at the write site as an
|
|
577
|
+
// HqStateWriteError carrying the exact hq-DERIVED path it tried to write.
|
|
578
|
+
// The reported EPERM on the checkpoint stamp file (Node 26 dropped the path)
|
|
579
|
+
// otherwise reached the capture below and gave the user nothing to act on
|
|
580
|
+
// (HQ-CLI-19, Sentry 7702730566). Print the path-naming, platform-aware
|
|
581
|
+
// remedy and skip capture. Placed with the environmental-fs family — BEFORE
|
|
582
|
+
// the generic envMsg computation — so this typed carrier wins even though
|
|
583
|
+
// it also carries an errno `code` environmentalFsErrorMessage could read.
|
|
584
|
+
const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg
|
|
585
|
+
? null
|
|
586
|
+
: hqStateWriteErrorMessage(err);
|
|
587
|
+
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || stateWriteMsg
|
|
560
588
|
? null
|
|
561
589
|
: environmentalFsErrorMessage(err);
|
|
562
590
|
// A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
|
|
@@ -573,7 +601,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
573
601
|
// environmental-fs check, before network-transport — is pinned by tests.
|
|
574
602
|
// The `in-process-async-holder` reason is deliberately NOT suppressed here
|
|
575
603
|
// (see sync-state-lock-error.ts); it stays captured.
|
|
576
|
-
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || envMsg
|
|
604
|
+
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || stateWriteMsg || envMsg
|
|
577
605
|
? null
|
|
578
606
|
: syncStateLockMessage(err);
|
|
579
607
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
@@ -592,6 +620,8 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
592
620
|
llmDisabledMsg ||
|
|
593
621
|
moduleMissingMsg ||
|
|
594
622
|
storeMissingMsg ||
|
|
623
|
+
storeUnopenableMsg ||
|
|
624
|
+
stateWriteMsg ||
|
|
595
625
|
envMsg ||
|
|
596
626
|
lockMsg
|
|
597
627
|
? null
|
|
@@ -614,6 +644,12 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
614
644
|
else if (storeMissingMsg) {
|
|
615
645
|
deps.stderr.write(`hq: ${storeMissingMsg}\n`);
|
|
616
646
|
}
|
|
647
|
+
else if (storeUnopenableMsg) {
|
|
648
|
+
deps.stderr.write(`hq: ${storeUnopenableMsg}\n`);
|
|
649
|
+
}
|
|
650
|
+
else if (stateWriteMsg) {
|
|
651
|
+
deps.stderr.write(`hq: ${stateWriteMsg}\n`);
|
|
652
|
+
}
|
|
617
653
|
else if (envMsg) {
|
|
618
654
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
619
655
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Map an errno code to a bounded reason; anything outside the closed set to a fixed fallback. */
|
|
2
|
+
export declare function stateWriteReason(code: unknown): string;
|
|
3
|
+
/**
|
|
4
|
+
* A permission/filesystem write into HQ's OWN state tree that hq cannot fix in
|
|
5
|
+
* code. Carries the hq-DERIVED absolute `path` hq tried to write, the
|
|
6
|
+
* `operation` (a fixed label, never free text), the bounded errno `reason`, and
|
|
7
|
+
* the raw errno `code` — all of finite cardinality, so the printed line and any
|
|
8
|
+
* grouping stay bounded.
|
|
9
|
+
*/
|
|
10
|
+
export declare class HqStateWriteError extends Error {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly operation: string;
|
|
13
|
+
readonly reason: string;
|
|
14
|
+
readonly code: string;
|
|
15
|
+
name: string;
|
|
16
|
+
constructor(path: string, operation: string, reason: string, code: string);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* If `error` is an ENVIRONMENTAL filesystem failure — its `.code` is one of the
|
|
20
|
+
* CLOSED set of permission/quota/read-only/parent-shape errnos above — convert
|
|
21
|
+
* it into an {@link HqStateWriteError} that NAMES `targetPath` (the hq-derived
|
|
22
|
+
* path the caller was writing) and the `operation` label; otherwise return
|
|
23
|
+
* `null` so the caller rethrows the original error UNCHANGED. Keeping the set
|
|
24
|
+
* closed is what makes "genuine faults stay reportable" hold: a NON-errno error
|
|
25
|
+
* (a code defect) and an UNUSUAL errno (e.g. `EISDIR`/`EEXIST`, which mean hq
|
|
26
|
+
* wrote to the wrong shape) both stay captured. Scoped by the CALLER to hq's own
|
|
27
|
+
* state paths, never applied as a blanket boundary rule.
|
|
28
|
+
*/
|
|
29
|
+
export declare function toHqStateWriteError(error: unknown, targetPath: string, operation: string): HqStateWriteError | null;
|
|
30
|
+
/** True when `err` is an {@link HqStateWriteError} (structural, so a rehydrated shape matches). */
|
|
31
|
+
export declare function isHqStateWriteError(err: unknown): err is HqStateWriteError;
|
|
32
|
+
/**
|
|
33
|
+
* If `err` is an {@link HqStateWriteError}, return the actionable, path-naming
|
|
34
|
+
* remedy; otherwise return `null`. A non-null result means the caller should
|
|
35
|
+
* print the message and SKIP Sentry capture — the condition is the user's
|
|
36
|
+
* machine, not a bug HQ can fix. The wording is platform-aware from `platform`
|
|
37
|
+
* (injected in tests), never from any upstream text, and always names the exact
|
|
38
|
+
* hq-derived path because the runtime does not (Node 26 drops it).
|
|
39
|
+
*/
|
|
40
|
+
export declare function hqStateWriteErrorMessage(err: unknown, platform?: NodeJS.Platform): string | null;
|
|
41
|
+
//# sourceMappingURL=hq-state-write-error.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// src/utils/hq-state-write-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Type a filesystem write into HQ's OWN state tree that failed for an
|
|
4
|
+
// ENVIRONMENTAL reason (a permission/quota/read-only/parent-gone errno) rather
|
|
5
|
+
// than an hq-cli code defect. These are unactionable from our side — the file
|
|
6
|
+
// or its directory is owned by another user, the terminal lacks Full Disk
|
|
7
|
+
// Access on macOS, the mount is read-only — so the CLI surfaces an actionable
|
|
8
|
+
// message and does NOT report them to Sentry.
|
|
9
|
+
//
|
|
10
|
+
// HQ-CLI-19 (Sentry indigo-d0/hq-cli 7702730566): `hq core checkpoint` wrote its
|
|
11
|
+
// thread file fine, then `fs.writeFileSync` on the EXISTING checkpoint stamp
|
|
12
|
+
// file threw `EPERM: operation not permitted, open` on macOS 26 / Node 26. The
|
|
13
|
+
// raw error reached the boundary; environmentalFsErrorMessage maps only
|
|
14
|
+
// ENOSPC/EDQUOT/EROFS (never EPERM/EACCES — see below), so it was captured. And
|
|
15
|
+
// because Node 26's writeFileSync fast path carried NO `path` on the thrown
|
|
16
|
+
// error, both the user and the issue got `EPERM: operation not permitted, open`
|
|
17
|
+
// with nothing to act on.
|
|
18
|
+
//
|
|
19
|
+
// Why type this at the WRITE SITE instead of widening the boundary's errno
|
|
20
|
+
// allowlist: EPERM/EACCES are AMBIGUOUS — "the user's machine" vs "hq wrote to
|
|
21
|
+
// the wrong place". A blanket errno rule at the boundary would silently suppress
|
|
22
|
+
// EVERY permission error CLI-wide, including a genuine hq path defect that must
|
|
23
|
+
// stay reportable. Typing only the specific hq-DERIVED state paths this command
|
|
24
|
+
// computes keeps the blast radius bounded, and because the remedy NAMES the
|
|
25
|
+
// exact path hq tried to write (the runtime supplies none on Node 26), a
|
|
26
|
+
// wrong-path defect is immediately visible to the user and to support even
|
|
27
|
+
// though it is not captured.
|
|
28
|
+
/** The bounded, human errno reason for a state-write failure (never free text). */
|
|
29
|
+
const STATE_WRITE_REASONS = {
|
|
30
|
+
EACCES: "permission denied",
|
|
31
|
+
EPERM: "operation not permitted (permission denied)",
|
|
32
|
+
EROFS: "the filesystem is read-only",
|
|
33
|
+
ENOSPC: "no space left on device",
|
|
34
|
+
EDQUOT: "the disk quota is exhausted",
|
|
35
|
+
ENOTDIR: "a path component is not a directory",
|
|
36
|
+
ENOENT: "a parent path does not exist",
|
|
37
|
+
};
|
|
38
|
+
/** Map an errno code to a bounded reason; anything outside the closed set to a fixed fallback. */
|
|
39
|
+
export function stateWriteReason(code) {
|
|
40
|
+
if (typeof code === "string" && code in STATE_WRITE_REASONS) {
|
|
41
|
+
return STATE_WRITE_REASONS[code];
|
|
42
|
+
}
|
|
43
|
+
return typeof code === "string" && code.length > 0 ? code : "a filesystem error";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A permission/filesystem write into HQ's OWN state tree that hq cannot fix in
|
|
47
|
+
* code. Carries the hq-DERIVED absolute `path` hq tried to write, the
|
|
48
|
+
* `operation` (a fixed label, never free text), the bounded errno `reason`, and
|
|
49
|
+
* the raw errno `code` — all of finite cardinality, so the printed line and any
|
|
50
|
+
* grouping stay bounded.
|
|
51
|
+
*/
|
|
52
|
+
export class HqStateWriteError extends Error {
|
|
53
|
+
path;
|
|
54
|
+
operation;
|
|
55
|
+
reason;
|
|
56
|
+
code;
|
|
57
|
+
name = "HqStateWriteError";
|
|
58
|
+
constructor(path, operation, reason, code) {
|
|
59
|
+
super(`hq could not ${operation} ${path}: ${reason}`);
|
|
60
|
+
this.path = path;
|
|
61
|
+
this.operation = operation;
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* If `error` is an ENVIRONMENTAL filesystem failure — its `.code` is one of the
|
|
68
|
+
* CLOSED set of permission/quota/read-only/parent-shape errnos above — convert
|
|
69
|
+
* it into an {@link HqStateWriteError} that NAMES `targetPath` (the hq-derived
|
|
70
|
+
* path the caller was writing) and the `operation` label; otherwise return
|
|
71
|
+
* `null` so the caller rethrows the original error UNCHANGED. Keeping the set
|
|
72
|
+
* closed is what makes "genuine faults stay reportable" hold: a NON-errno error
|
|
73
|
+
* (a code defect) and an UNUSUAL errno (e.g. `EISDIR`/`EEXIST`, which mean hq
|
|
74
|
+
* wrote to the wrong shape) both stay captured. Scoped by the CALLER to hq's own
|
|
75
|
+
* state paths, never applied as a blanket boundary rule.
|
|
76
|
+
*/
|
|
77
|
+
export function toHqStateWriteError(error, targetPath, operation) {
|
|
78
|
+
const code = error?.code;
|
|
79
|
+
if (typeof code !== "string" || !(code in STATE_WRITE_REASONS))
|
|
80
|
+
return null;
|
|
81
|
+
return new HqStateWriteError(targetPath, operation, STATE_WRITE_REASONS[code], code);
|
|
82
|
+
}
|
|
83
|
+
/** True when `err` is an {@link HqStateWriteError} (structural, so a rehydrated shape matches). */
|
|
84
|
+
export function isHqStateWriteError(err) {
|
|
85
|
+
if (err instanceof HqStateWriteError)
|
|
86
|
+
return true;
|
|
87
|
+
return (err !== null &&
|
|
88
|
+
typeof err === "object" &&
|
|
89
|
+
err.name === "HqStateWriteError" &&
|
|
90
|
+
typeof err.path === "string");
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* If `err` is an {@link HqStateWriteError}, return the actionable, path-naming
|
|
94
|
+
* remedy; otherwise return `null`. A non-null result means the caller should
|
|
95
|
+
* print the message and SKIP Sentry capture — the condition is the user's
|
|
96
|
+
* machine, not a bug HQ can fix. The wording is platform-aware from `platform`
|
|
97
|
+
* (injected in tests), never from any upstream text, and always names the exact
|
|
98
|
+
* hq-derived path because the runtime does not (Node 26 drops it).
|
|
99
|
+
*/
|
|
100
|
+
export function hqStateWriteErrorMessage(err, platform = process.platform) {
|
|
101
|
+
if (!isHqStateWriteError(err))
|
|
102
|
+
return null;
|
|
103
|
+
const base = `hq could not ${err.operation} ${err.path}: ${err.reason}. ` +
|
|
104
|
+
"This is your machine's filesystem, not an hq bug: check that this file and its " +
|
|
105
|
+
"directory exist and are owned by you and writable";
|
|
106
|
+
const platformTail = platform === "darwin"
|
|
107
|
+
? ", and on macOS grant your terminal Full Disk Access (System Settings → " +
|
|
108
|
+
"Privacy & Security → Full Disk Access) or move your HQ root out of a protected " +
|
|
109
|
+
"location, then run your command again."
|
|
110
|
+
: ", then run your command again.";
|
|
111
|
+
return `${base}${platformTail}`;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=hq-state-write-error.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `err` is a qmd store-unopenable failure: better-sqlite3's fixed
|
|
3
|
+
* sentence (or the SQLITE_CANTOPEN errno token) appears in qmd's captured
|
|
4
|
+
* STDERR. Accepts either the thrown error (reads its `stderr`) or a bare
|
|
5
|
+
* `{ stderr }` probe object. A true result means the caller should print the
|
|
6
|
+
* classified remedy, exit non-zero, and SKIP Sentry capture.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isQmdStoreUnopenableError(err: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* If `err` is a qmd store-unopenable failure, return the actionable remedy;
|
|
11
|
+
* otherwise return `null`. Mirrors qmdStoreMissingMessage so the top-level
|
|
12
|
+
* handler can branch on it the same way: a non-null result means
|
|
13
|
+
* print-and-skip-Sentry, null means "handle as usual (capture to Sentry)". The
|
|
14
|
+
* store directory and errno reason are read STRUCTURALLY from the error's own
|
|
15
|
+
* hq-populated `storeDir`/`probeReason` fields (absent on a bare probe object —
|
|
16
|
+
* then the message names neither), NEVER from qmd's output.
|
|
17
|
+
*/
|
|
18
|
+
export declare function qmdStoreUnopenableMessage(err: unknown): string | null;
|
|
19
|
+
//# sourceMappingURL=qmd-store-unopenable-error.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/utils/qmd-store-unopenable-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a qmd failure caused by an UNOPENABLE STORE — the SQLite store's
|
|
4
|
+
// directory EXISTS but qmd cannot open the database (better-sqlite3 throws
|
|
5
|
+
// `SqliteError: unable to open database file` / SQLITE_CANTOPEN, most often
|
|
6
|
+
// because the store directory is not writable, so WAL mode cannot create its
|
|
7
|
+
// -wal/-shm sidecars). This is the caller's LOCAL filesystem/permissions, not
|
|
8
|
+
// an hq-cli code defect, so the CLI surfaces an actionable remedy and SKIPS
|
|
9
|
+
// Sentry capture. Sibling of qmd-store-missing-error.ts (HQ-CLI-16, a store
|
|
10
|
+
// directory that does not exist) — the DISJOINT other half of "hq's store
|
|
11
|
+
// could not be opened".
|
|
12
|
+
//
|
|
13
|
+
// HQ-CLI-18 (Sentry indigo-d0/hq-cli 7702459375): `hq search …` spawned qmd,
|
|
14
|
+
// whose better-sqlite3 could not open the store (SQLITE_CANTOPEN). @tobilu/qmd
|
|
15
|
+
// FIRST console.warn's a macOS-only remedy built by createSqliteVecUnavailableError
|
|
16
|
+
// ("… Install Homebrew SQLite … set BREW_PREFIX …") for ANY probe failure, then
|
|
17
|
+
// throws the uncaught SqliteError from `PRAGMA journal_mode = WAL`. hq-cli's
|
|
18
|
+
// classifier chain missed this shape (store-missing keys on the DISJOINT
|
|
19
|
+
// sentence `Cannot open database because the directory does not exist`), so it
|
|
20
|
+
// stayed a plain QmdExitError, reached the boundary's final else, was captured,
|
|
21
|
+
// and — the visible harm — forwarded qmd's Homebrew/BREW_PREFIX advisory
|
|
22
|
+
// VERBATIM to a Linux terminal. Typing the condition lets the boundary print
|
|
23
|
+
// hq's OWN platform-correct, input-free remedy and never echo upstream text,
|
|
24
|
+
// exactly as qmdTerminatedMessage already does for qmd's raw advisory stderr.
|
|
25
|
+
//
|
|
26
|
+
// Matching is deliberately narrow so it can neither be tripped by user input nor
|
|
27
|
+
// silence a real bug: better-sqlite3's FIXED sentence / SQLite's fixed errno
|
|
28
|
+
// token is required in qmd's captured STDERR ONLY — never the synthesized
|
|
29
|
+
// `message` (which echoes the caller's argv) and never STDOUT. better-sqlite3's
|
|
30
|
+
// uncaught SqliteError and qmd's advisory both land on stderr; STDOUT for a
|
|
31
|
+
// `search`/`get` carries indexed DOCUMENT content, so keying on it would let a
|
|
32
|
+
// result body that merely contains the phrase suppress an UNRELATED nonzero
|
|
33
|
+
// exit. Every other qmd store failure (a locked db, a corrupt store, an unknown
|
|
34
|
+
// exit) matches none of this and stays a reportable QmdExitError.
|
|
35
|
+
/**
|
|
36
|
+
* better-sqlite3's fixed "cannot open" sentence and SQLite's fixed errno token.
|
|
37
|
+
* Either, on its own, is sufficient. Both were present in the reported event and
|
|
38
|
+
* in the local SQLITE_CANTOPEN reproduction.
|
|
39
|
+
*/
|
|
40
|
+
const STORE_UNOPENABLE = /SqliteError:\s*unable to open database file|\bSQLITE_CANTOPEN\b/i;
|
|
41
|
+
/**
|
|
42
|
+
* qmd's captured STDERR only. STDOUT is deliberately NOT consulted: for a
|
|
43
|
+
* `search`/`get` it is untrusted document content, so a result body containing
|
|
44
|
+
* this signature must never classify. The synthesized `message` is likewise not
|
|
45
|
+
* consulted — it embeds the caller's qmd arguments.
|
|
46
|
+
*/
|
|
47
|
+
function capturedStderr(err) {
|
|
48
|
+
if (err === null || typeof err !== "object")
|
|
49
|
+
return "";
|
|
50
|
+
const record = err;
|
|
51
|
+
return typeof record.stderr === "string" ? record.stderr : "";
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* True when `err` is a qmd store-unopenable failure: better-sqlite3's fixed
|
|
55
|
+
* sentence (or the SQLITE_CANTOPEN errno token) appears in qmd's captured
|
|
56
|
+
* STDERR. Accepts either the thrown error (reads its `stderr`) or a bare
|
|
57
|
+
* `{ stderr }` probe object. A true result means the caller should print the
|
|
58
|
+
* classified remedy, exit non-zero, and SKIP Sentry capture.
|
|
59
|
+
*/
|
|
60
|
+
export function isQmdStoreUnopenableError(err) {
|
|
61
|
+
return STORE_UNOPENABLE.test(capturedStderr(err));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The actionable, PLATFORM-NEUTRAL remedy, naming the store directory qmd could
|
|
65
|
+
* not open and (when the store-directory ensure recorded one) the errno reason
|
|
66
|
+
* its writability probe failed with. Both are hq-DERIVED values — the resolved
|
|
67
|
+
* store path and a bounded errno phrase — never caller argv/query or upstream
|
|
68
|
+
* text, so the line stays input-free and can NEVER re-emit qmd's macOS-only
|
|
69
|
+
* Homebrew/BREW_PREFIX advisory to a Linux terminal.
|
|
70
|
+
*/
|
|
71
|
+
function remedyMessage(storeDir, probeReason) {
|
|
72
|
+
const where = storeDir
|
|
73
|
+
? `hq's local search store (${storeDir}) could not be opened`
|
|
74
|
+
: "hq's local search store could not be opened";
|
|
75
|
+
const because = probeReason ? ` — ${probeReason}.` : ".";
|
|
76
|
+
return (`${where}${because} This is your machine's filesystem, not an hq bug: check that ` +
|
|
77
|
+
"the store directory and its database files are owned by you and writable (or " +
|
|
78
|
+
"point INDEX_PATH at a writable location), then run your command again.");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* If `err` is a qmd store-unopenable failure, return the actionable remedy;
|
|
82
|
+
* otherwise return `null`. Mirrors qmdStoreMissingMessage so the top-level
|
|
83
|
+
* handler can branch on it the same way: a non-null result means
|
|
84
|
+
* print-and-skip-Sentry, null means "handle as usual (capture to Sentry)". The
|
|
85
|
+
* store directory and errno reason are read STRUCTURALLY from the error's own
|
|
86
|
+
* hq-populated `storeDir`/`probeReason` fields (absent on a bare probe object —
|
|
87
|
+
* then the message names neither), NEVER from qmd's output.
|
|
88
|
+
*/
|
|
89
|
+
export function qmdStoreUnopenableMessage(err) {
|
|
90
|
+
if (!isQmdStoreUnopenableError(err))
|
|
91
|
+
return null;
|
|
92
|
+
const record = err;
|
|
93
|
+
const storeDir = typeof record.storeDir === "string" && record.storeDir.length > 0 ? record.storeDir : null;
|
|
94
|
+
const probeReason = typeof record.probeReason === "string" && record.probeReason.length > 0
|
|
95
|
+
? record.probeReason
|
|
96
|
+
: null;
|
|
97
|
+
return remedyMessage(storeDir, probeReason);
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=qmd-store-unopenable-error.js.map
|
|
@@ -60,7 +60,7 @@ import { spawnSync } from "node:child_process";
|
|
|
60
60
|
import semver from "semver";
|
|
61
61
|
import chalk from "chalk";
|
|
62
62
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
63
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
63
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, inOwnProcessGroup, isLocalDependencyInstall, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
/**
|
|
66
66
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
@@ -222,11 +222,20 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
222
222
|
if (!interactive && flavor.onlyWhenAttended) {
|
|
223
223
|
return { action: "deferred", latest };
|
|
224
224
|
}
|
|
225
|
+
// A local copy (a project dependency, a `pnpm dlx` cache) has no self-update
|
|
226
|
+
// path: every install argv here targets a GLOBAL install, so the copy that is
|
|
227
|
+
// actually running would stay exactly as stale as it started while the CLI
|
|
228
|
+
// reported success and re-exec'd into the same build. That is the shape of
|
|
229
|
+
// the 2026-09-02 gate loop — see isLocalDependencyInstall in version-gate.ts.
|
|
230
|
+
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
231
|
+
if (isLocalDependencyInstall(install)) {
|
|
232
|
+
console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
|
|
233
|
+
return { action: "skipped", latest };
|
|
234
|
+
}
|
|
225
235
|
const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
|
|
226
236
|
if (!releaseLock)
|
|
227
237
|
return { action: "skipped", latest };
|
|
228
238
|
let result;
|
|
229
|
-
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
230
239
|
const plan = buildSelfUpdatePlan(install);
|
|
231
240
|
// A pnpm global install needs PNPM_HOME to find its global bin dir. A
|
|
232
241
|
// minimal-environment parent (systemd, cron, non-login shell) lacks it and
|
|
@@ -83,6 +83,7 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
83
83
|
"QmdLlmDisabledError",
|
|
84
84
|
"QmdModuleMissingError",
|
|
85
85
|
"QmdStoreMissingError",
|
|
86
|
+
"QmdStoreUnopenableError",
|
|
86
87
|
]);
|
|
87
88
|
/** Fixed bucket for any error name outside the closed allowlist. */
|
|
88
89
|
const FALLBACK_ERROR_NAME = "other";
|
|
@@ -70,6 +70,59 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
|
70
70
|
export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
|
|
71
71
|
/** Whether the running package lives inside Bun's global install tree. */
|
|
72
72
|
export declare function isBunManagedPackageDir(pkgDir: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Whether the package dir sits inside a pnpm **virtual store** — the adjacent
|
|
75
|
+
* `node_modules/.pnpm` segment pair pnpm creates for every install, global or
|
|
76
|
+
* not:
|
|
77
|
+
*
|
|
78
|
+
* <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
|
|
79
|
+
*
|
|
80
|
+
* A virtual store is a content-addressed cache keyed by the EXACT version in
|
|
81
|
+
* the directory name. Nothing on PATH ever resolves through
|
|
82
|
+
* `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
|
|
83
|
+
* one thing that must never happen is treating it as one.
|
|
84
|
+
*
|
|
85
|
+
* Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
|
|
86
|
+
* answers a different question ("is this the copy `pnpm add -g` updates?") and
|
|
87
|
+
* is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
|
|
88
|
+
* decides anything, a pnpm *global* store has already been classified.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isPnpmVirtualStorePackageDir(pkgDir: string): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Whether the running CLI is a **local** copy — a project dependency or a
|
|
93
|
+
* `pnpm dlx` cache — rather than a global install this process may replace.
|
|
94
|
+
*
|
|
95
|
+
* This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
|
|
96
|
+
* `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
|
|
97
|
+
* sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
|
|
98
|
+
* it. The gate saw a build below `minVersion`, derived an npm prefix from the
|
|
99
|
+
* store path, ran
|
|
100
|
+
*
|
|
101
|
+
* npm install -g --prefix <store> @indigoai-us/hq-cli@latest
|
|
102
|
+
*
|
|
103
|
+
* which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
|
|
104
|
+
* pnpm's shim never reads — then reported success. The next invocation resolved
|
|
105
|
+
* the same 5.69.0 shim and did it all again, ~25 times an hour forever.
|
|
106
|
+
*
|
|
107
|
+
* A local copy has no self-update path at all: replacing it is its owning
|
|
108
|
+
* project's job, and installing globally would leave the copy that is actually
|
|
109
|
+
* running untouched. So the gate must say so and stop, not install.
|
|
110
|
+
*/
|
|
111
|
+
export declare function isLocalDependencyPackageDir(pkgDir: string, platform?: NodeJS.Platform): boolean;
|
|
112
|
+
export declare function isLocalDependencyInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Whether an update to `target` would actually move the install forward.
|
|
115
|
+
*
|
|
116
|
+
* Loop protection, independent of the layout bug above: an "update" to a
|
|
117
|
+
* version that is not strictly newer than what is running can never converge,
|
|
118
|
+
* so however the target was resolved — a stale dist-tag, a bad cache, a
|
|
119
|
+
* misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
|
|
120
|
+
* every invocation and announce success each time.
|
|
121
|
+
*
|
|
122
|
+
* Unparseable versions return true: this guard exists to stop a provable
|
|
123
|
+
* no-op, not to become a new way for the gate to refuse to work.
|
|
124
|
+
*/
|
|
125
|
+
export declare function isNewerVersion(target: string, current: string): boolean;
|
|
73
126
|
/**
|
|
74
127
|
* Where the running CLI is installed and who owns it. Resolved in ONE pass so
|
|
75
128
|
* the package-root walk (which reads and parses a `package.json` per directory
|
|
@@ -278,7 +331,7 @@ export declare function probeCliVersion(bin: string): string | null;
|
|
|
278
331
|
export declare function checkUpdateConvergence(targetVersion: string, deps?: {
|
|
279
332
|
resolveBin?: () => string | null;
|
|
280
333
|
probeVersion?: (bin: string) => string | null;
|
|
281
|
-
}):
|
|
334
|
+
}): boolean;
|
|
282
335
|
/** Injectable surface for {@link enforceUpdateRequired} (unit tests). */
|
|
283
336
|
interface EnforceUpdateDeps {
|
|
284
337
|
performUpdateString?: (command: string) => UpdateResult;
|
|
@@ -286,7 +339,12 @@ interface EnforceUpdateDeps {
|
|
|
286
339
|
runner?: UpdateRunner;
|
|
287
340
|
cleanStale?: (prefix: string) => string[];
|
|
288
341
|
acquireLock?: () => UpdateLockHandle | null;
|
|
289
|
-
|
|
342
|
+
/**
|
|
343
|
+
* `false` means the probe *disproved* convergence. `void`/`undefined` keeps
|
|
344
|
+
* the historical "unverified is fine" behaviour, so an injected stub that
|
|
345
|
+
* returns nothing still exercises the success path.
|
|
346
|
+
*/
|
|
347
|
+
checkConvergence?: (targetVersion: string) => boolean | void;
|
|
290
348
|
}
|
|
291
349
|
/**
|
|
292
350
|
* Hard enforcement when the server says we're below `minVersion`. Print a
|
|
@@ -346,7 +404,11 @@ export declare const __test__: {
|
|
|
346
404
|
enforceUpdateRequired: typeof enforceUpdateRequired;
|
|
347
405
|
isBunManagedPackageDir: typeof isBunManagedPackageDir;
|
|
348
406
|
pnpmUpdateEnv: typeof pnpmUpdateEnv;
|
|
407
|
+
isLocalDependencyInstall: typeof isLocalDependencyInstall;
|
|
408
|
+
isLocalDependencyPackageDir: typeof isLocalDependencyPackageDir;
|
|
409
|
+
isNewerVersion: typeof isNewerVersion;
|
|
349
410
|
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
411
|
+
isPnpmVirtualStorePackageDir: typeof isPnpmVirtualStorePackageDir;
|
|
350
412
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
351
413
|
nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
|
|
352
414
|
performUpdate: typeof performUpdate;
|
|
@@ -34,6 +34,7 @@ import os from "node:os";
|
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
36
|
import chalk from "chalk";
|
|
37
|
+
import semver from "semver";
|
|
37
38
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
38
39
|
import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
|
|
39
40
|
import { acquireUpdateLock, } from "./update-lock.js";
|
|
@@ -140,6 +141,106 @@ export function isBunManagedPackageDir(pkgDir) {
|
|
|
140
141
|
}
|
|
141
142
|
return false;
|
|
142
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether the package dir sits inside a pnpm **virtual store** — the adjacent
|
|
146
|
+
* `node_modules/.pnpm` segment pair pnpm creates for every install, global or
|
|
147
|
+
* not:
|
|
148
|
+
*
|
|
149
|
+
* <proj>/node_modules/.pnpm/@indigoai-us+hq-cli@5.69.0/node_modules/@indigoai-us/hq-cli
|
|
150
|
+
*
|
|
151
|
+
* A virtual store is a content-addressed cache keyed by the EXACT version in
|
|
152
|
+
* the directory name. Nothing on PATH ever resolves through
|
|
153
|
+
* `<store>/lib/node_modules`, and the store dir is not an npm prefix — so the
|
|
154
|
+
* one thing that must never happen is treating it as one.
|
|
155
|
+
*
|
|
156
|
+
* Note this is deliberately broader than {@link isPnpmManagedPackageDir}, which
|
|
157
|
+
* answers a different question ("is this the copy `pnpm add -g` updates?") and
|
|
158
|
+
* is checked FIRST by {@link resolveRunningInstall}. By the time this predicate
|
|
159
|
+
* decides anything, a pnpm *global* store has already been classified.
|
|
160
|
+
*/
|
|
161
|
+
export function isPnpmVirtualStorePackageDir(pkgDir) {
|
|
162
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
163
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
164
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
165
|
+
if (segments[i] === "node_modules" && segments[i + 1] === ".pnpm")
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether the running CLI is a **local** copy — a project dependency or a
|
|
172
|
+
* `pnpm dlx` cache — rather than a global install this process may replace.
|
|
173
|
+
*
|
|
174
|
+
* This is the guard for HQ-CLI update-loop incident 2026-09-02. A stale
|
|
175
|
+
* `@indigoai-us/hq-cli@5.69.0` sat in an HQ tree's pnpm virtual store, and the
|
|
176
|
+
* sync runner (npx, which puts `<cwd>/node_modules/.bin` on PATH) kept invoking
|
|
177
|
+
* it. The gate saw a build below `minVersion`, derived an npm prefix from the
|
|
178
|
+
* store path, ran
|
|
179
|
+
*
|
|
180
|
+
* npm install -g --prefix <store> @indigoai-us/hq-cli@latest
|
|
181
|
+
*
|
|
182
|
+
* which unpacked a pristine copy into `<store>/lib/node_modules` — a directory
|
|
183
|
+
* pnpm's shim never reads — then reported success. The next invocation resolved
|
|
184
|
+
* the same 5.69.0 shim and did it all again, ~25 times an hour forever.
|
|
185
|
+
*
|
|
186
|
+
* A local copy has no self-update path at all: replacing it is its owning
|
|
187
|
+
* project's job, and installing globally would leave the copy that is actually
|
|
188
|
+
* running untouched. So the gate must say so and stop, not install.
|
|
189
|
+
*/
|
|
190
|
+
export function isLocalDependencyPackageDir(pkgDir, platform = process.platform) {
|
|
191
|
+
// A pnpm virtual store is local by construction on every platform. (A pnpm
|
|
192
|
+
// *global* store is classified earlier, by isPnpmManagedPackageDir.)
|
|
193
|
+
if (isPnpmVirtualStorePackageDir(pkgDir))
|
|
194
|
+
return true;
|
|
195
|
+
// On every non-Windows platform npm's global root is ALWAYS
|
|
196
|
+
// `<prefix>/lib/node_modules` — that is what `npm root -g` reports for
|
|
197
|
+
// /usr/local, Homebrew, nvm and a user-level `--prefix` alike. So a package
|
|
198
|
+
// under a `node_modules` with no `lib` parent is not a global install: it is
|
|
199
|
+
// a project dependency (`<proj>/node_modules/@scope/pkg`) or an npx cache
|
|
200
|
+
// (`~/.npm/_npx/<hash>/node_modules/@scope/pkg`). Both reproduce the same
|
|
201
|
+
// loop as the pnpm store — `npm install -g --prefix <dir>` writes
|
|
202
|
+
// `<dir>/lib/node_modules` while the `.bin` shim keeps resolving
|
|
203
|
+
// `<dir>/node_modules`.
|
|
204
|
+
//
|
|
205
|
+
// Windows is the exception and must keep the old behaviour: its global
|
|
206
|
+
// layout is `<prefix>\node_modules` with no `lib` segment, so the same test
|
|
207
|
+
// would misread a genuine global install as local.
|
|
208
|
+
if (platform === "win32")
|
|
209
|
+
return false;
|
|
210
|
+
const segments = pkgDir
|
|
211
|
+
.replace(/\\/g, "/")
|
|
212
|
+
.replace(/\/+$/, "")
|
|
213
|
+
.split("/")
|
|
214
|
+
.filter(Boolean);
|
|
215
|
+
const nodeModulesIndex = segments.lastIndexOf("node_modules");
|
|
216
|
+
if (nodeModulesIndex <= 0)
|
|
217
|
+
return false;
|
|
218
|
+
return segments[nodeModulesIndex - 1] !== "lib";
|
|
219
|
+
}
|
|
220
|
+
export function isLocalDependencyInstall(install, platform = process.platform) {
|
|
221
|
+
return (install.manager === "npm" &&
|
|
222
|
+
install.packageRoot !== null &&
|
|
223
|
+
isLocalDependencyPackageDir(install.packageRoot, platform));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Whether an update to `target` would actually move the install forward.
|
|
227
|
+
*
|
|
228
|
+
* Loop protection, independent of the layout bug above: an "update" to a
|
|
229
|
+
* version that is not strictly newer than what is running can never converge,
|
|
230
|
+
* so however the target was resolved — a stale dist-tag, a bad cache, a
|
|
231
|
+
* misconfigured hq-pro pin — the gate must refuse it rather than reinstall on
|
|
232
|
+
* every invocation and announce success each time.
|
|
233
|
+
*
|
|
234
|
+
* Unparseable versions return true: this guard exists to stop a provable
|
|
235
|
+
* no-op, not to become a new way for the gate to refuse to work.
|
|
236
|
+
*/
|
|
237
|
+
export function isNewerVersion(target, current) {
|
|
238
|
+
const t = semver.valid(target);
|
|
239
|
+
const c = semver.valid(current);
|
|
240
|
+
if (!t || !c)
|
|
241
|
+
return true;
|
|
242
|
+
return semver.gt(t, c);
|
|
243
|
+
}
|
|
143
244
|
export function resolveRunningInstall() {
|
|
144
245
|
try {
|
|
145
246
|
const packageRoot = findRunningPackageRoot();
|
|
@@ -151,6 +252,13 @@ export function resolveRunningInstall() {
|
|
|
151
252
|
if (isBunManagedPackageDir(packageRoot)) {
|
|
152
253
|
return { manager: "bun", prefix: null, packageRoot };
|
|
153
254
|
}
|
|
255
|
+
// A local copy — project dependency, pnpm virtual store, or npx cache.
|
|
256
|
+
// `npmPrefixFromPackageDir` would hand back the project/cache directory and
|
|
257
|
+
// `npm install -g --prefix <dir>` then writes where nothing loads from —
|
|
258
|
+
// see isLocalDependencyPackageDir. There is no npm prefix to report here.
|
|
259
|
+
if (isLocalDependencyPackageDir(packageRoot)) {
|
|
260
|
+
return { manager: "npm", prefix: null, packageRoot };
|
|
261
|
+
}
|
|
154
262
|
return {
|
|
155
263
|
manager: "npm",
|
|
156
264
|
prefix: npmPrefixFromPackageDir(packageRoot),
|
|
@@ -506,6 +614,14 @@ function manualUpdateCommand(install, decision) {
|
|
|
506
614
|
function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
|
|
507
615
|
const msg = chalk.yellow(`⚠ A new version of hq-cli is available: ${decision.latestVersion} (current: ${decision.currentVersion}).`);
|
|
508
616
|
console.error(msg);
|
|
617
|
+
// A local copy is not updated by any global command. Printing the server's
|
|
618
|
+
// npm-shaped one would send the user to install a second copy that this
|
|
619
|
+
// invocation still would not use — the same wrong advice the pnpm-global
|
|
620
|
+
// carve-out exists to avoid.
|
|
621
|
+
if (isLocalDependencyInstall(install)) {
|
|
622
|
+
console.error(chalk.dim(` This copy is a local dependency (${install.packageRoot}); update the project that owns it, or invoke the global \`hq\`.`));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
509
625
|
const command = install.manager === "pnpm"
|
|
510
626
|
? `pnpm ${buildPnpmInstallArgv().join(" ")}`
|
|
511
627
|
: install.manager === "bun"
|
|
@@ -584,21 +700,41 @@ export function checkUpdateConvergence(targetVersion, deps = {}) {
|
|
|
584
700
|
const bin = (deps.resolveBin ?? resolveHqOnPath)();
|
|
585
701
|
if (!bin) {
|
|
586
702
|
console.error(chalk.yellow("⚠ Updated, but couldn't resolve `hq` on PATH to verify the new version took effect."));
|
|
587
|
-
|
|
703
|
+
// Unverifiable, not disproven — the caller keeps its success report.
|
|
704
|
+
return true;
|
|
588
705
|
}
|
|
589
706
|
const reported = (deps.probeVersion ?? probeCliVersion)(bin);
|
|
590
707
|
if (!reported) {
|
|
591
708
|
console.error(chalk.yellow(`⚠ Updated, but \`${bin} --version\` did not respond — couldn't verify the new version took effect.`));
|
|
592
|
-
return;
|
|
709
|
+
return true; // unverifiable, see above
|
|
593
710
|
}
|
|
594
711
|
if (reported === targetVersion)
|
|
595
|
-
return; // converged — the normal case
|
|
712
|
+
return true; // converged — the normal case
|
|
596
713
|
console.error(chalk.yellow(`⚠ hq updated to ${targetVersion} but PATH still resolves ${bin} at version ${reported} — ` +
|
|
597
714
|
`a second install is shadowing the managed one. Remove it (e.g. \`pnpm remove -g ${CLI_NAME}\`) ` +
|
|
598
715
|
"or the updater will loop forever."));
|
|
716
|
+
// The warning above fires for ANY mismatch — something other than the copy
|
|
717
|
+
// we just wrote is winning PATH resolution, which is worth saying either
|
|
718
|
+
// way. But only a STALE result disproves convergence.
|
|
719
|
+
//
|
|
720
|
+
// The install runs `@latest`, not `@<targetVersion>`, so it can legitimately
|
|
721
|
+
// land a version NEWER than the `latestVersion` the gate was handed: the npm
|
|
722
|
+
// dist-tag moves between the version-check response and the install, or the
|
|
723
|
+
// service's value was briefly behind. That is a successful upgrade. Failing
|
|
724
|
+
// it would exit 75 and reinstall on the next invocation — the very loop this
|
|
725
|
+
// guard exists to stop.
|
|
726
|
+
const reportedSemver = semver.valid(reported);
|
|
727
|
+
const targetSemver = semver.valid(targetVersion);
|
|
728
|
+
if (reportedSemver &&
|
|
729
|
+
targetSemver &&
|
|
730
|
+
semver.gte(reportedSemver, targetSemver)) {
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
return false;
|
|
599
734
|
}
|
|
600
735
|
catch {
|
|
601
736
|
// Verification is best-effort; never break the CLI over a probe.
|
|
737
|
+
return true;
|
|
602
738
|
}
|
|
603
739
|
}
|
|
604
740
|
/**
|
|
@@ -629,6 +765,26 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
629
765
|
// `resolveRunningInstall`
|
|
630
766
|
// already reports `prefix: null` there and neither is consulted below.
|
|
631
767
|
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
768
|
+
// Loop protection #1 — nothing here can update a copy this process does not
|
|
769
|
+
// own. Installing globally would leave the local copy that is actually
|
|
770
|
+
// running stale, so the gate would fire again on the very next invocation.
|
|
771
|
+
// Say what to fix and stop; do NOT spend an install.
|
|
772
|
+
if (isLocalDependencyInstall(install)) {
|
|
773
|
+
console.error(chalk.red(` This copy is a local dependency, not a global install: ${install.packageRoot}`));
|
|
774
|
+
console.error(chalk.dim(" Self-update cannot replace it — an install would land where this copy is never loaded from, and the gate would fire again on the next run."));
|
|
775
|
+
console.error(chalk.dim(` Update the project that owns it (e.g. \`pnpm update ${CLI_NAME}\` in its root, or remove the stale dependency), or invoke the global \`hq\` instead.`));
|
|
776
|
+
process.exit(75);
|
|
777
|
+
}
|
|
778
|
+
// Loop protection #2 — an "update" to a version that is not strictly newer
|
|
779
|
+
// than what is running can never converge. Whatever produced the target (a
|
|
780
|
+
// stale dist-tag, a bad cache, a misconfigured pin), reinstalling it on every
|
|
781
|
+
// invocation and announcing success each time is strictly worse than saying
|
|
782
|
+
// so once.
|
|
783
|
+
if (!isNewerVersion(decision.latestVersion, decision.currentVersion)) {
|
|
784
|
+
console.error(chalk.red(` Refusing to update: the offered version ${decision.latestVersion} is not newer than the installed ${decision.currentVersion}.`));
|
|
785
|
+
console.error(chalk.dim(" This is a server-side or registry problem, not a local one — reinstalling would loop without ever converging."));
|
|
786
|
+
process.exit(75);
|
|
787
|
+
}
|
|
632
788
|
const isManagedOutsideNpm = install.manager !== "npm";
|
|
633
789
|
const prefix = install.prefix;
|
|
634
790
|
if (!isManagedOutsideNpm && !command && !prefix) {
|
|
@@ -768,11 +924,17 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
768
924
|
}
|
|
769
925
|
return 75;
|
|
770
926
|
}
|
|
771
|
-
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
772
927
|
// Read-your-writes: a "successful" install into the npm prefix does not
|
|
773
|
-
// prove the user's PATH resolves it.
|
|
774
|
-
// install
|
|
775
|
-
|
|
928
|
+
// prove the user's PATH resolves it. Verify BEFORE announcing, so a ghost
|
|
929
|
+
// install shadowing the copy we just wrote is reported as the failed update
|
|
930
|
+
// it is rather than as a success the caller will keep retrying — see
|
|
931
|
+
// checkUpdateConvergence.
|
|
932
|
+
const converged = (deps.checkConvergence ?? checkUpdateConvergence)(decision.latestVersion);
|
|
933
|
+
if (converged === false) {
|
|
934
|
+
console.error(chalk.red(`✗ Update did not take effect: \`hq\` on PATH still resolves a different build than ${decision.latestVersion}.`));
|
|
935
|
+
return 75;
|
|
936
|
+
}
|
|
937
|
+
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
776
938
|
return 0;
|
|
777
939
|
}
|
|
778
940
|
export async function enforceVersionGate(onUpdateRecommended) {
|
|
@@ -829,7 +991,11 @@ export const __test__ = {
|
|
|
829
991
|
enforceUpdateRequired,
|
|
830
992
|
isBunManagedPackageDir,
|
|
831
993
|
pnpmUpdateEnv,
|
|
994
|
+
isLocalDependencyInstall,
|
|
995
|
+
isLocalDependencyPackageDir,
|
|
996
|
+
isNewerVersion,
|
|
832
997
|
isPnpmManagedPackageDir,
|
|
998
|
+
isPnpmVirtualStorePackageDir,
|
|
833
999
|
npmPrefixFromPackageDir,
|
|
834
1000
|
nudgeUpdateRecommended,
|
|
835
1001
|
performUpdate,
|