@indigoai-us/hq-cli 5.103.32 → 5.103.34
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 +4 -0
- package/dist/commands/auth.js +3 -13
- package/dist/commands/index-cmd.js +17 -3
- package/dist/commands/skill.js +22 -5
- package/dist/lib/search-index/index.d.ts +65 -0
- package/dist/lib/search-index/index.js +135 -0
- package/dist/main.js +48 -3
- package/dist/utils/callback-port-busy.d.ts +29 -0
- package/dist/utils/callback-port-busy.js +87 -0
- package/dist/utils/qmd-store-missing-error.d.ts +20 -0
- package/dist/utils/qmd-store-missing-error.js +96 -0
- package/dist/utils/sentry-fingerprint.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/dist/commands/auth.js
CHANGED
|
@@ -17,6 +17,7 @@ import chalk from "chalk";
|
|
|
17
17
|
import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
|
|
18
18
|
import { DEFAULT_COGNITO, refreshCachedSession, } from "../utils/cognito-session.js";
|
|
19
19
|
import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
|
|
20
|
+
import { callbackPortBusyGuidance, DEFAULT_CALLBACK_PORT, isCallbackPortBusy, } from "../utils/callback-port-busy.js";
|
|
20
21
|
/**
|
|
21
22
|
* Decode the (unverified) ID token payload for display purposes only.
|
|
22
23
|
* The token was just returned by Cognito's token endpoint, so its contents
|
|
@@ -46,17 +47,6 @@ function machineIdentityLabel() {
|
|
|
46
47
|
const creds = loadMachineCreds();
|
|
47
48
|
return creds ? `machine identity ${creds.username}` : "machine identity";
|
|
48
49
|
}
|
|
49
|
-
function isCallbackPortCollision(error) {
|
|
50
|
-
if (!error || typeof error !== "object")
|
|
51
|
-
return false;
|
|
52
|
-
const { code, message } = error;
|
|
53
|
-
return code === "EADDRINUSE" || message?.includes("EADDRINUSE") === true;
|
|
54
|
-
}
|
|
55
|
-
function callbackPortCollisionGuidance(port) {
|
|
56
|
-
return (` The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
|
|
57
|
-
"Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
|
|
58
|
-
"or stop its terminal/process, then retry.");
|
|
59
|
-
}
|
|
60
50
|
export function registerAuthCommands(program) {
|
|
61
51
|
const authCmd = program
|
|
62
52
|
.command("auth")
|
|
@@ -83,7 +73,7 @@ export function registerAuthCommands(program) {
|
|
|
83
73
|
console.log(chalk.dim(` Token cached at ~/.hq/cognito-tokens.json (expires ${tokens.expiresAt})`));
|
|
84
74
|
}
|
|
85
75
|
catch (err) {
|
|
86
|
-
const callbackPortCollision =
|
|
76
|
+
const callbackPortCollision = isCallbackPortBusy(err);
|
|
87
77
|
const msg = callbackPortCollision
|
|
88
78
|
? "Browser-login callback port is already in use."
|
|
89
79
|
: err instanceof CognitoAuthError
|
|
@@ -93,7 +83,7 @@ export function registerAuthCommands(program) {
|
|
|
93
83
|
: String(err);
|
|
94
84
|
console.error(chalk.red(`Login failed: ${msg}`));
|
|
95
85
|
if (callbackPortCollision) {
|
|
96
|
-
console.error(chalk.dim(
|
|
86
|
+
console.error(chalk.dim(` ${callbackPortBusyGuidance(DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}`));
|
|
97
87
|
}
|
|
98
88
|
else {
|
|
99
89
|
console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.hq.computer"));
|
|
@@ -3,6 +3,7 @@ import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcil
|
|
|
3
3
|
import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
|
|
4
4
|
import { findHqRoot } from '../utils/manifest.js';
|
|
5
5
|
import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
|
|
6
|
+
import { isQmdStoreMissingError, qmdStoreMissingMessage, } from '../utils/qmd-store-missing-error.js';
|
|
6
7
|
const defaults = {
|
|
7
8
|
reconcileCollections,
|
|
8
9
|
deriveCollections,
|
|
@@ -119,10 +120,23 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
119
120
|
qmdStatus = dependencies.runQmd(['status'], { cwd: hqRoot });
|
|
120
121
|
}
|
|
121
122
|
catch (error) {
|
|
122
|
-
if (
|
|
123
|
+
if (isQmdNativeBindingError(error)) {
|
|
124
|
+
process.stderr.write(`qmd: unusable — native bindings unbuilt. ${QMD_NATIVE_BINDING_REMEDY}\n`);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
else if (isQmdStoreMissingError(error)) {
|
|
128
|
+
// The local qmd store directory does not exist (HQ-CLI-16). Like the
|
|
129
|
+
// native-binding case, this diagnostic command should DESCRIBE the
|
|
130
|
+
// broken local store, not crash on it: print the classified reason +
|
|
131
|
+
// remedy and exit 1 without rethrowing, so the boundary never files it
|
|
132
|
+
// as a Sentry crash. Every OTHER qmd failure keeps propagating.
|
|
133
|
+
const remedy = qmdStoreMissingMessage(error) ?? 'its local search store directory does not exist';
|
|
134
|
+
process.stderr.write(`qmd: unusable — ${remedy}\n`);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
123
138
|
throw error;
|
|
124
|
-
|
|
125
|
-
process.exitCode = 1;
|
|
139
|
+
}
|
|
126
140
|
}
|
|
127
141
|
if (registered) {
|
|
128
142
|
console.log(collectionSummary(expected, registered));
|
package/dist/commands/skill.js
CHANGED
|
@@ -136,11 +136,17 @@ export function resolveSkillUid(target, cwd) {
|
|
|
136
136
|
return uid;
|
|
137
137
|
}
|
|
138
138
|
export function canonicalCompanySkillPath(hqRoot, companySlug, skillSlug) {
|
|
139
|
+
// HQ-CLI-17 (Sentry 7699881899): both validators reject CALLER-supplied input
|
|
140
|
+
// — the `--company` slug and the `create <slug>` argument. A malformed value is
|
|
141
|
+
// the caller's request, not an hq-cli defect, so throw `localSkillError`
|
|
142
|
+
// (expected: true) and let the top-level boundary print the remedy and skip
|
|
143
|
+
// Sentry, exactly as the sibling caller-input throws in this module already do.
|
|
144
|
+
// The message text is unchanged; only its CLASS changes.
|
|
139
145
|
if (!COMPANY_SLUG_PATTERN.test(companySlug)) {
|
|
140
|
-
throw
|
|
146
|
+
throw localSkillError("Company slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
141
147
|
}
|
|
142
148
|
if (!SKILL_SLUG_PATTERN.test(skillSlug)) {
|
|
143
|
-
throw
|
|
149
|
+
throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
144
150
|
}
|
|
145
151
|
return path.join(hqRoot, "companies", companySlug, "skills", skillSlug, "SKILL.md");
|
|
146
152
|
}
|
|
@@ -277,7 +283,10 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
277
283
|
.option("--surface-only", "Refresh local skill discovery without registration or sync")
|
|
278
284
|
.action(async (slug, opts) => {
|
|
279
285
|
if (!SKILL_SLUG_PATTERN.test(slug)) {
|
|
280
|
-
|
|
286
|
+
// HQ-CLI-17: the `create <slug>` argument is caller input; a malformed
|
|
287
|
+
// slug is a correctly-enforced refusal, not a crash. Mark it expected
|
|
288
|
+
// (via localSkillError) so it is printed and skipped for Sentry.
|
|
289
|
+
throw localSkillError("Skill slug must start with a lowercase letter or number and contain only lowercase letters, numbers, and hyphens.");
|
|
281
290
|
}
|
|
282
291
|
const parentOpts = skill.opts();
|
|
283
292
|
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
@@ -285,7 +294,13 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
285
294
|
const filePath = canonicalCompanySkillPath(resolvedRoot, companySlug, slug);
|
|
286
295
|
const legacyPrefix = readCompanyPrefix(resolvedRoot, companySlug);
|
|
287
296
|
if (fs.existsSync(filePath) && !fs.statSync(filePath).isFile()) {
|
|
288
|
-
|
|
297
|
+
// HQ-CLI-17: the target path is derived from caller input and its
|
|
298
|
+
// on-disk shape is the caller's own filesystem, not an hq-cli defect.
|
|
299
|
+
// The bare Error also embedded this absolute (home-relative) path, so it
|
|
300
|
+
// minted a NEW Sentry fingerprint per machine. Mark it expected so it is
|
|
301
|
+
// printed (with the path the caller needs) and never captured. Redaction
|
|
302
|
+
// in localSkillError scrubs credentials but leaves plain paths intact.
|
|
303
|
+
throw localSkillError(`Expected a SKILL.md file at '${filePath}'.`);
|
|
289
304
|
}
|
|
290
305
|
if (opts.surfaceOnly === true) {
|
|
291
306
|
// Discovery wrappers are execution surfaces. Never create one for an
|
|
@@ -396,8 +411,10 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
396
411
|
.requiredOption("-m, --message <text>", "The improvement to discuss")
|
|
397
412
|
.action(async (target, opts) => {
|
|
398
413
|
const message = opts.message.trim();
|
|
414
|
+
// HQ-CLI-17: a blank `-m/--message` is caller input, not an hq-cli defect.
|
|
415
|
+
// Mark it expected so it is printed and skipped for Sentry.
|
|
399
416
|
if (!message)
|
|
400
|
-
throw
|
|
417
|
+
throw localSkillError("An improvement message is required.");
|
|
401
418
|
const parentOpts = skill.opts();
|
|
402
419
|
const resolvedRoot = path.resolve(parentOpts.hqRoot ?? hqRoot);
|
|
403
420
|
const companySlug = resolveCompanySlug(parentOpts.company, resolvedRoot);
|
|
@@ -152,6 +152,29 @@ export declare class QmdLlmDisabledError extends QmdExitError {
|
|
|
152
152
|
export declare class QmdModuleMissingError extends QmdExitError {
|
|
153
153
|
name: string;
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* qmd could not open its SQLite store because the store's DIRECTORY does not
|
|
157
|
+
* exist. @tobilu/qmd resolves the store as $INDEX_PATH (returned verbatim,
|
|
158
|
+
* before any mkdir) else ${XDG_CACHE_HOME||~/.cache}/qmd (behind a mkdir whose
|
|
159
|
+
* failure it swallows), then `new Database(path)` throws better-sqlite3's
|
|
160
|
+
* `TypeError: Cannot open database because the directory does not exist`. That
|
|
161
|
+
* missing directory is the caller's ENVIRONMENT — a pruned cache, a fresh
|
|
162
|
+
* machine, an INDEX_PATH into a deleted tree, or a directory hq could not create
|
|
163
|
+
* (permissions / a read-only mount) — never a bug HQ can fix in code.
|
|
164
|
+
*
|
|
165
|
+
* `storeDir` carries the directory hq resolved qmd would open its store in, and
|
|
166
|
+
* `ensureReason` the bounded errno phrase for why hq's own best-effort
|
|
167
|
+
* pre-create of that directory failed (undefined when the ensure succeeded or
|
|
168
|
+
* did not run). Both are hq-DERIVED — the resolved path plus a finite errno
|
|
169
|
+
* reason — so the boundary can name them in an input-free remedy without ever
|
|
170
|
+
* echoing caller argv or upstream text. HQ-CLI-16 (Sentry 7698235964).
|
|
171
|
+
*/
|
|
172
|
+
export declare class QmdStoreMissingError extends QmdExitError {
|
|
173
|
+
readonly storeDir?: string | undefined;
|
|
174
|
+
readonly ensureReason?: string | undefined;
|
|
175
|
+
name: string;
|
|
176
|
+
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, ensureReason?: string | undefined);
|
|
177
|
+
}
|
|
155
178
|
export type ResolveQmdBinOptions = {
|
|
156
179
|
env?: Record<string, string | undefined>;
|
|
157
180
|
isExecutable?: (candidate: string) => boolean;
|
|
@@ -203,6 +226,48 @@ export declare function packageLocalNodeEntry(): string | undefined;
|
|
|
203
226
|
* disappeared from `hq index status`. Same root cause as packageLocalBin.
|
|
204
227
|
*/
|
|
205
228
|
export declare function resolveQmdVersion(): string | undefined;
|
|
229
|
+
/**
|
|
230
|
+
* The outcome of the best-effort store-directory ensure, keyed by resolved
|
|
231
|
+
* directory so the mkdir happens at most once per distinct directory per process
|
|
232
|
+
* (idempotent, bounded — a process opens a tiny, fixed set of store dirs). Read
|
|
233
|
+
* by finishRunQmd to stamp the directory and the errno reason onto a
|
|
234
|
+
* QmdStoreMissingError, turning the condition that carried no cause in the
|
|
235
|
+
* reported event into a self-describing one.
|
|
236
|
+
*/
|
|
237
|
+
type QmdStoreEnsureOutcome = {
|
|
238
|
+
dir: string;
|
|
239
|
+
created: boolean;
|
|
240
|
+
reason?: string;
|
|
241
|
+
};
|
|
242
|
+
/** Test-only: read the most recent store-directory ensure outcome. */
|
|
243
|
+
export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | undefined;
|
|
244
|
+
/**
|
|
245
|
+
* Resolve the DIRECTORY @tobilu/qmd will open its SQLite store in, mirroring the
|
|
246
|
+
* pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
|
|
247
|
+
* qmdHomedir) against the SAME env the qmd child receives:
|
|
248
|
+
* - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
|
|
249
|
+
* so the store file's directory is the one that must exist);
|
|
250
|
+
* - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
|
|
251
|
+
* $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
|
|
252
|
+
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
253
|
+
* changes it turns CI red instead of silently restoring the noise.
|
|
254
|
+
*/
|
|
255
|
+
export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv): string;
|
|
256
|
+
export type EnsureQmdStoreDirOptions = {
|
|
257
|
+
/** mkdir seam (default `fs.mkdirSync(dir, { recursive: true })`); injected in tests. */
|
|
258
|
+
mkdir?: (dir: string) => void;
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Best-effort, once-per-directory self-provisioning of qmd's store directory
|
|
262
|
+
* BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
|
|
263
|
+
* machine — simply works instead of erroring. It creates ONLY that one directory
|
|
264
|
+
* (recursive), records the outcome (including a bounded errno reason on
|
|
265
|
+
* failure), and NEVER throws, retries, waits, or spawns. When the mkdir fails
|
|
266
|
+
* (permissions / read-only), qmd then still can't open its store, and the
|
|
267
|
+
* recorded reason is what makes the classified remedy self-describing. Modelled
|
|
268
|
+
* on the repairQmdNativeBindings best-effort contract in this file.
|
|
269
|
+
*/
|
|
270
|
+
export declare function ensureQmdStoreDir(env?: NodeJS.ProcessEnv, options?: EnsureQmdStoreDirOptions): void;
|
|
206
271
|
/** Reset per-process probe/repair memoisation. Test-only. */
|
|
207
272
|
export declare function __resetQmdProbeStateForTests(): void;
|
|
208
273
|
/** Probe a resolved invocation, memoised per invocation; records the failure
|
|
@@ -6,6 +6,7 @@ import * as path from 'node:path';
|
|
|
6
6
|
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
|
+
import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
|
|
9
10
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
10
11
|
import { planCommandSpawn } from '../../utils/windows-spawn.js';
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
@@ -131,6 +132,33 @@ export class QmdLlmDisabledError extends QmdExitError {
|
|
|
131
132
|
export class QmdModuleMissingError extends QmdExitError {
|
|
132
133
|
name = 'QmdModuleMissingError';
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* qmd could not open its SQLite store because the store's DIRECTORY does not
|
|
137
|
+
* exist. @tobilu/qmd resolves the store as $INDEX_PATH (returned verbatim,
|
|
138
|
+
* before any mkdir) else ${XDG_CACHE_HOME||~/.cache}/qmd (behind a mkdir whose
|
|
139
|
+
* failure it swallows), then `new Database(path)` throws better-sqlite3's
|
|
140
|
+
* `TypeError: Cannot open database because the directory does not exist`. That
|
|
141
|
+
* missing directory is the caller's ENVIRONMENT — a pruned cache, a fresh
|
|
142
|
+
* machine, an INDEX_PATH into a deleted tree, or a directory hq could not create
|
|
143
|
+
* (permissions / a read-only mount) — never a bug HQ can fix in code.
|
|
144
|
+
*
|
|
145
|
+
* `storeDir` carries the directory hq resolved qmd would open its store in, and
|
|
146
|
+
* `ensureReason` the bounded errno phrase for why hq's own best-effort
|
|
147
|
+
* pre-create of that directory failed (undefined when the ensure succeeded or
|
|
148
|
+
* did not run). Both are hq-DERIVED — the resolved path plus a finite errno
|
|
149
|
+
* reason — so the boundary can name them in an input-free remedy without ever
|
|
150
|
+
* echoing caller argv or upstream text. HQ-CLI-16 (Sentry 7698235964).
|
|
151
|
+
*/
|
|
152
|
+
export class QmdStoreMissingError extends QmdExitError {
|
|
153
|
+
storeDir;
|
|
154
|
+
ensureReason;
|
|
155
|
+
name = 'QmdStoreMissingError';
|
|
156
|
+
constructor(message, args, status, stdout, stderr, storeDir, ensureReason) {
|
|
157
|
+
super(message, args, status, stdout, stderr);
|
|
158
|
+
this.storeDir = storeDir;
|
|
159
|
+
this.ensureReason = ensureReason;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
134
162
|
function isExecutable(candidate) {
|
|
135
163
|
try {
|
|
136
164
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -265,6 +293,84 @@ const usableQmdCache = new Map();
|
|
|
265
293
|
const lastProbeFailure = new Map();
|
|
266
294
|
/** At most one native-binding repair attempt per process (bounded, no retries). */
|
|
267
295
|
let nativeBindingRepairAttempted = false;
|
|
296
|
+
const storeEnsureByDir = new Map();
|
|
297
|
+
/** The most recent ensure outcome (the directory the next qmd spawn will use). */
|
|
298
|
+
let lastQmdStoreEnsure;
|
|
299
|
+
/** Test-only: read the most recent store-directory ensure outcome. */
|
|
300
|
+
export function lastQmdStoreEnsureOutcome() {
|
|
301
|
+
return lastQmdStoreEnsure;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the DIRECTORY @tobilu/qmd will open its SQLite store in, mirroring the
|
|
305
|
+
* pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
|
|
306
|
+
* qmdHomedir) against the SAME env the qmd child receives:
|
|
307
|
+
* - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
|
|
308
|
+
* so the store file's directory is the one that must exist);
|
|
309
|
+
* - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
|
|
310
|
+
* $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
|
|
311
|
+
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
312
|
+
* changes it turns CI red instead of silently restoring the noise.
|
|
313
|
+
*/
|
|
314
|
+
export function resolveQmdStoreDir(env = process.env) {
|
|
315
|
+
const indexPath = env.INDEX_PATH;
|
|
316
|
+
if (indexPath)
|
|
317
|
+
return path.dirname(indexPath);
|
|
318
|
+
const home = env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
|
|
319
|
+
const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
320
|
+
return path.join(cacheDir, 'qmd');
|
|
321
|
+
}
|
|
322
|
+
/** Map a mkdir ErrnoException to a bounded, human errno reason (never free text). */
|
|
323
|
+
function ensureFailureReason(error) {
|
|
324
|
+
const code = error?.code;
|
|
325
|
+
switch (code) {
|
|
326
|
+
case 'EACCES':
|
|
327
|
+
case 'EPERM':
|
|
328
|
+
return 'permission denied';
|
|
329
|
+
case 'EROFS':
|
|
330
|
+
return 'the filesystem is read-only';
|
|
331
|
+
case 'ENOTDIR':
|
|
332
|
+
return 'a path component is not a directory';
|
|
333
|
+
case 'ENOENT':
|
|
334
|
+
return 'a parent path does not exist';
|
|
335
|
+
case 'ENOSPC':
|
|
336
|
+
return 'no space left on device';
|
|
337
|
+
default:
|
|
338
|
+
return typeof code === 'string' && code.length > 0 ? code : 'an unknown error';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Best-effort, once-per-directory self-provisioning of qmd's store directory
|
|
343
|
+
* BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
|
|
344
|
+
* machine — simply works instead of erroring. It creates ONLY that one directory
|
|
345
|
+
* (recursive), records the outcome (including a bounded errno reason on
|
|
346
|
+
* failure), and NEVER throws, retries, waits, or spawns. When the mkdir fails
|
|
347
|
+
* (permissions / read-only), qmd then still can't open its store, and the
|
|
348
|
+
* recorded reason is what makes the classified remedy self-describing. Modelled
|
|
349
|
+
* on the repairQmdNativeBindings best-effort contract in this file.
|
|
350
|
+
*/
|
|
351
|
+
export function ensureQmdStoreDir(env = process.env, options = {}) {
|
|
352
|
+
let dir;
|
|
353
|
+
try {
|
|
354
|
+
dir = resolveQmdStoreDir(env);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// Resolution itself must never break a qmd run.
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
let outcome = storeEnsureByDir.get(dir);
|
|
361
|
+
if (!outcome) {
|
|
362
|
+
const mkdir = options.mkdir ?? ((target) => { fs.mkdirSync(target, { recursive: true }); });
|
|
363
|
+
try {
|
|
364
|
+
mkdir(dir);
|
|
365
|
+
outcome = { dir, created: true };
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
outcome = { dir, created: false, reason: ensureFailureReason(error) };
|
|
369
|
+
}
|
|
370
|
+
storeEnsureByDir.set(dir, outcome);
|
|
371
|
+
}
|
|
372
|
+
lastQmdStoreEnsure = outcome;
|
|
373
|
+
}
|
|
268
374
|
/** How long a single usability probe or repair sub-step may run. */
|
|
269
375
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
270
376
|
const REPAIR_STEP_TIMEOUT_MS = 180_000;
|
|
@@ -275,6 +381,8 @@ export function __resetQmdProbeStateForTests() {
|
|
|
275
381
|
usableQmdCache.clear();
|
|
276
382
|
lastProbeFailure.clear();
|
|
277
383
|
nativeBindingRepairAttempted = false;
|
|
384
|
+
storeEnsureByDir.clear();
|
|
385
|
+
lastQmdStoreEnsure = undefined;
|
|
278
386
|
}
|
|
279
387
|
/** The qmd file identity of an invocation: the launcher for a node entry, else
|
|
280
388
|
* the command. Used to key probe failures and confine native-binding repair. */
|
|
@@ -823,6 +931,26 @@ function finishRunQmd(result, bin, args) {
|
|
|
823
931
|
if (isQmdModuleMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
824
932
|
throw new QmdModuleMissingError(`qmd ${subcommand} could not run: a module it needs was not found (the hq install tree looks incomplete)`, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
825
933
|
}
|
|
934
|
+
// qmd could not open its SQLite store because the store DIRECTORY does not
|
|
935
|
+
// exist (better-sqlite3: `TypeError: Cannot open database because the
|
|
936
|
+
// directory does not exist`). That missing directory is the caller's
|
|
937
|
+
// environment — a pruned cache, a fresh machine, an INDEX_PATH into a deleted
|
|
938
|
+
// tree, or a directory hq could not create (permissions / a read-only mount) —
|
|
939
|
+
// not an hq-cli defect. Type it so the boundary prints a self-describing
|
|
940
|
+
// remedy (naming the resolved store dir and the recorded errno reason) and
|
|
941
|
+
// skips capture, and so index-cmd's diagnostic command degrades rather than
|
|
942
|
+
// crashing. Read qmd's OWN captured streams only, never the synthesized
|
|
943
|
+
// message, so a user query containing that phrase can never trip it. Placed
|
|
944
|
+
// AFTER the LLM-disabled and module-missing checks (narrower typed signatures)
|
|
945
|
+
// and BEFORE the collection-missing regex: the wordings are disjoint (that
|
|
946
|
+
// regex needs `collection`/`qmd://` adjacent to a not-found token, which this
|
|
947
|
+
// stderr lacks), so no existing branch changes behaviour (HQ-CLI-16, Sentry
|
|
948
|
+
// 7698235964). The stamped storeDir/ensureReason come from the pre-spawn
|
|
949
|
+
// ensure that ran for this exact store directory.
|
|
950
|
+
if (isQmdStoreMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
951
|
+
const ensure = lastQmdStoreEnsure;
|
|
952
|
+
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
|
+
}
|
|
826
954
|
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
|
|
827
955
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
828
956
|
}
|
|
@@ -861,6 +989,13 @@ export function runQmd(args, options = {}) {
|
|
|
861
989
|
execPath: options.execPath,
|
|
862
990
|
spawn: options.spawn,
|
|
863
991
|
});
|
|
992
|
+
// Self-provision qmd's store directory before the spawn so the benign
|
|
993
|
+
// pruned-cache / fresh-machine case simply works, and record the outcome so a
|
|
994
|
+
// residual store-open failure (a directory hq could not create) can be
|
|
995
|
+
// classified with its cause instead of captured (HQ-CLI-16). Best-effort and
|
|
996
|
+
// idempotent; only the real spawn path needs it (the injected-runner path
|
|
997
|
+
// above never opens a store).
|
|
998
|
+
ensureQmdStoreDir(options.env ?? process.env);
|
|
864
999
|
const result = spawnQmd(invocation, args, {
|
|
865
1000
|
cwd: options.cwd,
|
|
866
1001
|
env: options.env ?? process.env,
|
package/dist/main.js
CHANGED
|
@@ -71,11 +71,14 @@ import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-erro
|
|
|
71
71
|
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
|
+
import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
|
|
74
75
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
75
76
|
import { isEpipe } from "./utils/epipe.js";
|
|
76
77
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
77
78
|
import { isAuthError } from "./utils/auth-error.js";
|
|
78
79
|
import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js";
|
|
80
|
+
import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
|
|
81
|
+
import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
|
|
79
82
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
80
83
|
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
|
|
81
84
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
@@ -413,6 +416,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
413
416
|
deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
|
|
414
417
|
deps.setExitCode(1);
|
|
415
418
|
}
|
|
419
|
+
else if (callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)) {
|
|
420
|
+
// HQ-CLI-15: the caller's command fell back to the IMPLICIT browser
|
|
421
|
+
// sign-in, but the loopback OAuth callback port (127.0.0.1:<port>) was
|
|
422
|
+
// already held by another process, so @indigoai-us/hq-cloud's browserLogin
|
|
423
|
+
// rejected with a raw `listen EADDRINUSE` on that fixed port. That is the
|
|
424
|
+
// caller's local machine state — a second `hq login` still waiting for its
|
|
425
|
+
// browser sign-in, or a stray process on the port — the user fixes by
|
|
426
|
+
// finishing/stopping the other login and retrying, not an hq-cli defect.
|
|
427
|
+
// Print the fixed actionable remedy and skip Sentry so one busy port
|
|
428
|
+
// doesn't file a permanent, unfixable "crash". Placed with the
|
|
429
|
+
// abandoned-login carve-out (both mean "your HQ session couldn't be
|
|
430
|
+
// established interactively"). The match is STRUCTURAL (errno code + the
|
|
431
|
+
// configured callback port), so an EADDRINUSE on any OTHER port stays a
|
|
432
|
+
// genuine fault and still captures below.
|
|
433
|
+
deps.stderr.write(`hq: ${callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}\n`);
|
|
434
|
+
deps.setExitCode(1);
|
|
435
|
+
}
|
|
416
436
|
else if (isPlanGateError(err)) {
|
|
417
437
|
// hq-pro's plan denials are expected product limits, never a CLI crash.
|
|
418
438
|
// The shared vault client has already decoded and typed the small safe
|
|
@@ -521,7 +541,22 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
521
541
|
const moduleMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
|
|
522
542
|
? null
|
|
523
543
|
: qmdModuleMissingMessage(err);
|
|
524
|
-
|
|
544
|
+
// A qmd store-directory-missing failure (better-sqlite3 could not open its
|
|
545
|
+
// SQLite store because the directory does not exist) is the caller's local
|
|
546
|
+
// filesystem/environment — a pruned cache, a fresh machine, an INDEX_PATH
|
|
547
|
+
// into a deleted tree, or a directory hq could not create — not an hq-cli
|
|
548
|
+
// defect. finishRunQmd types it QmdStoreMissingError, carrying the resolved
|
|
549
|
+
// store directory and the errno reason hq's pre-spawn ensure recorded;
|
|
550
|
+
// print that self-describing remedy and skip capture (HQ-CLI-16, Sentry
|
|
551
|
+
// 7698235964). Evaluated AFTER the native-binding / collection-missing /
|
|
552
|
+
// terminated / llm-disabled / module-missing checks (all narrower or
|
|
553
|
+
// sibling typed signatures) and BEFORE the environmental / transport /
|
|
554
|
+
// generic branches; the signatures are disjoint, so ordering changes no
|
|
555
|
+
// existing branch.
|
|
556
|
+
const storeMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
|
|
557
|
+
? null
|
|
558
|
+
: qmdStoreMissingMessage(err);
|
|
559
|
+
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
|
|
525
560
|
? null
|
|
526
561
|
: environmentalFsErrorMessage(err);
|
|
527
562
|
// A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
|
|
@@ -538,7 +573,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
538
573
|
// environmental-fs check, before network-transport — is pinned by tests.
|
|
539
574
|
// The `in-process-async-holder` reason is deliberately NOT suppressed here
|
|
540
575
|
// (see sync-state-lock-error.ts); it stays captured.
|
|
541
|
-
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
|
|
576
|
+
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || envMsg
|
|
542
577
|
? null
|
|
543
578
|
: syncStateLockMessage(err);
|
|
544
579
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
@@ -551,7 +586,14 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
551
586
|
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
552
587
|
// Ordered after the environmental check so a full disk keeps its exact
|
|
553
588
|
// existing message.
|
|
554
|
-
const transportMsg = qmdMsg ||
|
|
589
|
+
const transportMsg = qmdMsg ||
|
|
590
|
+
collectionMsg ||
|
|
591
|
+
terminatedMsg ||
|
|
592
|
+
llmDisabledMsg ||
|
|
593
|
+
moduleMissingMsg ||
|
|
594
|
+
storeMissingMsg ||
|
|
595
|
+
envMsg ||
|
|
596
|
+
lockMsg
|
|
555
597
|
? null
|
|
556
598
|
: networkTransportErrorMessage(err);
|
|
557
599
|
if (qmdMsg) {
|
|
@@ -569,6 +611,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
569
611
|
else if (moduleMissingMsg) {
|
|
570
612
|
deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
|
|
571
613
|
}
|
|
614
|
+
else if (storeMissingMsg) {
|
|
615
|
+
deps.stderr.write(`hq: ${storeMissingMsg}\n`);
|
|
616
|
+
}
|
|
572
617
|
else if (envMsg) {
|
|
573
618
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
574
619
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Fixed callback port for the loopback OAuth server when unset (mirrors DEFAULT_COGNITO). */
|
|
2
|
+
export declare const DEFAULT_CALLBACK_PORT = 8765;
|
|
3
|
+
/**
|
|
4
|
+
* The fixed, actionable remedy. Interpolates ONLY the numeric callback port (a
|
|
5
|
+
* bounded value drawn from config, never caller input or upstream error text),
|
|
6
|
+
* so this carve-out can never widen disclosure and needs no redaction pass.
|
|
7
|
+
*/
|
|
8
|
+
export declare function callbackPortBusyGuidance(port: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* The LOOSE predicate for a caller that just invoked browserLogin and is
|
|
11
|
+
* catching its own error (`hq auth login`/`hq login`): an EADDRINUSE by errno
|
|
12
|
+
* code OR message is the callback-port collision, because the callback server is
|
|
13
|
+
* the only listener that call could have opened. NOT for the shared boundary —
|
|
14
|
+
* use {@link callbackPortBusyMessage} there.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isCallbackPortBusy(err: unknown): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The STRUCTURAL classifier for the shared top-level boundary. Returns the fixed
|
|
19
|
+
* remedy ONLY when `err` is an EADDRINUSE whose `port` equals the configured
|
|
20
|
+
* callback `port`; otherwise returns `null`.
|
|
21
|
+
*
|
|
22
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
23
|
+
* capture. A null result means "handle as usual (capture to Sentry)" — including
|
|
24
|
+
* an EADDRINUSE on ANY other port, which is a different listener and a genuine
|
|
25
|
+
* fault that must stay reportable. Matched on the errno SHAPE only (code + port),
|
|
26
|
+
* never on free text, so no upstream/caller string can reach the printed line.
|
|
27
|
+
*/
|
|
28
|
+
export declare function callbackPortBusyMessage(err: unknown, port: number): string | null;
|
|
29
|
+
//# sourceMappingURL=callback-port-busy.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/utils/callback-port-busy.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a browser-login CALLBACK-PORT COLLISION — the loopback OAuth server
|
|
4
|
+
// @indigoai-us/hq-cloud's browserLogin binds (the fixed DEFAULT_COGNITO.port,
|
|
5
|
+
// 8765 unless HQ_COGNITO_CALLBACK_PORT overrides it) could not be bound because
|
|
6
|
+
// another process already holds it, so Node rejects with a raw ErrnoException
|
|
7
|
+
// (`listen EADDRINUSE …`, code EADDRINUSE, port 8765, syscall listen). That is
|
|
8
|
+
// the caller's LOCAL machine state — a second `hq login` still waiting for its
|
|
9
|
+
// browser sign-in, or a stray process on the port — NOT an hq-cli defect: the
|
|
10
|
+
// user fixes it by finishing/stopping the other login and retrying. So the
|
|
11
|
+
// top-level handler prints an actionable line and exits non-zero but SKIPS
|
|
12
|
+
// Sentry capture, mirroring the abandoned-browser-login (HQ-CLI-V), auth
|
|
13
|
+
// (HQ-CLI-8), company-selection (HQ-CLI-7), expected-user-error (HQ-CLI-6), and
|
|
14
|
+
// environmental-FS (HQ-CLI-2) carve-outs.
|
|
15
|
+
//
|
|
16
|
+
// HQ-CLI-15 (Sentry indigo-d0/hq-cli 7698014705): `hq integrations list
|
|
17
|
+
// --company <slug> --json` refreshed an expiring session, the refresh failed,
|
|
18
|
+
// and it fell back to the IMPLICIT browser sign-in on one of the ~100
|
|
19
|
+
// ensureCognitoToken/ensureCognitoIdToken call sites. The callback port was
|
|
20
|
+
// already held, so browserLogin rejected with a bare `listen EADDRINUSE:
|
|
21
|
+
// address already in use 127.0.0.1:8765`. That Node error carries no `expected`
|
|
22
|
+
// flag and is not an AuthError/CognitoAuthError/…, so every predicate in
|
|
23
|
+
// handleTopLevelError returned false and the collision was captured as a
|
|
24
|
+
// high-priority, unfixable "crash". `hq auth login` already classified the
|
|
25
|
+
// identical condition in a file-private helper; this module lifts that
|
|
26
|
+
// classification to the shared boundary so every implicit call site is covered
|
|
27
|
+
// from one source of truth.
|
|
28
|
+
//
|
|
29
|
+
// Two predicates, differing ONLY by how much context the caller already has:
|
|
30
|
+
// - isCallbackPortBusy(err): the LOOSE form for a call site that KNOWS it just
|
|
31
|
+
// invoked browserLogin (`hq auth login`/`hq login`). There the only listener
|
|
32
|
+
// in play is the callback server, so an EADDRINUSE — by code or message — is
|
|
33
|
+
// unambiguously the callback port.
|
|
34
|
+
// - callbackPortBusyMessage(err, port): the STRUCTURAL form for the shared
|
|
35
|
+
// boundary, which sees arbitrary errors from every call site. It matches
|
|
36
|
+
// ONLY when err.code === 'EADDRINUSE' AND err.port equals the configured
|
|
37
|
+
// callback port, so an EADDRINUSE raised by any OTHER listener stays
|
|
38
|
+
// reportable (the boundary must never swallow a genuine listener defect).
|
|
39
|
+
/** Fixed callback port for the loopback OAuth server when unset (mirrors DEFAULT_COGNITO). */
|
|
40
|
+
export const DEFAULT_CALLBACK_PORT = 8765;
|
|
41
|
+
/** Node's errno code for a `listen()` against an address already in use. */
|
|
42
|
+
const EADDRINUSE = "EADDRINUSE";
|
|
43
|
+
/**
|
|
44
|
+
* The fixed, actionable remedy. Interpolates ONLY the numeric callback port (a
|
|
45
|
+
* bounded value drawn from config, never caller input or upstream error text),
|
|
46
|
+
* so this carve-out can never widen disclosure and needs no redaction pass.
|
|
47
|
+
*/
|
|
48
|
+
export function callbackPortBusyGuidance(port) {
|
|
49
|
+
return (`The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
|
|
50
|
+
"Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
|
|
51
|
+
"or stop its terminal/process, then retry.");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The LOOSE predicate for a caller that just invoked browserLogin and is
|
|
55
|
+
* catching its own error (`hq auth login`/`hq login`): an EADDRINUSE by errno
|
|
56
|
+
* code OR message is the callback-port collision, because the callback server is
|
|
57
|
+
* the only listener that call could have opened. NOT for the shared boundary —
|
|
58
|
+
* use {@link callbackPortBusyMessage} there.
|
|
59
|
+
*/
|
|
60
|
+
export function isCallbackPortBusy(err) {
|
|
61
|
+
if (!err || typeof err !== "object")
|
|
62
|
+
return false;
|
|
63
|
+
const { code, message } = err;
|
|
64
|
+
return code === EADDRINUSE || message?.includes(EADDRINUSE) === true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The STRUCTURAL classifier for the shared top-level boundary. Returns the fixed
|
|
68
|
+
* remedy ONLY when `err` is an EADDRINUSE whose `port` equals the configured
|
|
69
|
+
* callback `port`; otherwise returns `null`.
|
|
70
|
+
*
|
|
71
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
72
|
+
* capture. A null result means "handle as usual (capture to Sentry)" — including
|
|
73
|
+
* an EADDRINUSE on ANY other port, which is a different listener and a genuine
|
|
74
|
+
* fault that must stay reportable. Matched on the errno SHAPE only (code + port),
|
|
75
|
+
* never on free text, so no upstream/caller string can reach the printed line.
|
|
76
|
+
*/
|
|
77
|
+
export function callbackPortBusyMessage(err, port) {
|
|
78
|
+
if (!err || typeof err !== "object")
|
|
79
|
+
return null;
|
|
80
|
+
const errno = err;
|
|
81
|
+
if (errno.code !== EADDRINUSE)
|
|
82
|
+
return null;
|
|
83
|
+
if (typeof errno.port !== "number" || errno.port !== port)
|
|
84
|
+
return null;
|
|
85
|
+
return callbackPortBusyGuidance(port);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=callback-port-busy.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `err` is a qmd store-directory-missing failure: better-sqlite3's
|
|
3
|
+
* fixed sentence appears in qmd's captured stderr/stdout. Accepts either the
|
|
4
|
+
* thrown error (reads its `stderr`/`stdout`) or a bare `{ stderr, stdout }`
|
|
5
|
+
* probe object. A true result means the caller should print the classified
|
|
6
|
+
* remedy, exit non-zero, and SKIP Sentry capture.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isQmdStoreMissingError(err: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* If `err` is a qmd store-directory-missing failure, return the actionable
|
|
11
|
+
* remedy; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
|
|
12
|
+
* qmdMissingCollectionMessage so the top-level handler can branch on it the same
|
|
13
|
+
* way: a non-null result means print-and-skip-Sentry, null means "handle as
|
|
14
|
+
* usual (capture to Sentry)". The store directory and errno reason are read
|
|
15
|
+
* STRUCTURALLY from the error's own hq-populated `storeDir`/`ensureReason`
|
|
16
|
+
* fields (absent on a bare probe object — then the message names neither), never
|
|
17
|
+
* from qmd's output.
|
|
18
|
+
*/
|
|
19
|
+
export declare function qmdStoreMissingMessage(err: unknown): string | null;
|
|
20
|
+
//# sourceMappingURL=qmd-store-missing-error.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/utils/qmd-store-missing-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a qmd failure caused by a MISSING STORE DIRECTORY — the directory
|
|
4
|
+
// qmd opens its SQLite index in does not exist — rather than an hq-cli code
|
|
5
|
+
// defect. This is the caller's LOCAL filesystem/environment (a pruned cache dir,
|
|
6
|
+
// a fresh machine, an INDEX_PATH pointing into a deleted tree, or a directory hq
|
|
7
|
+
// could not create because of permissions / a read-only mount), not a bug HQ can
|
|
8
|
+
// fix in code, so the CLI surfaces an actionable remedy and SKIPS Sentry
|
|
9
|
+
// capture. Sibling of qmd-native-binding-error.ts (HQ-CLI-J, unbuilt bindings),
|
|
10
|
+
// environmental-error.ts (HQ-CLI-2, full disk), and network-transport-error.ts
|
|
11
|
+
// (HQ-CLI-G, connectivity): a failure that is NOT an hq-cli defect is printed
|
|
12
|
+
// with an actionable message and never filed as a crash.
|
|
13
|
+
//
|
|
14
|
+
// HQ-CLI-16 (Sentry indigo-d0/hq-cli 7698235964): `hq index status` ran `qmd
|
|
15
|
+
// collection list`, and @tobilu/qmd's better-sqlite3 threw `TypeError: Cannot
|
|
16
|
+
// open database because the directory does not exist` the moment it opened its
|
|
17
|
+
// store — qmd's getDefaultDbPath returns $INDEX_PATH verbatim before any mkdir,
|
|
18
|
+
// else resolves ${XDG_CACHE_HOME||~/.cache}/qmd behind a mkdir whose failure it
|
|
19
|
+
// swallows. runQmd wrapped the exit-1 in a plain QmdExitError, index-cmd's catch
|
|
20
|
+
// re-threw it (narrowed to the sibling native-binding case only), and it reached
|
|
21
|
+
// the boundary's final else, which captured it — an unfixable, per-user "crash"
|
|
22
|
+
// whose title carries the caller's home path.
|
|
23
|
+
//
|
|
24
|
+
// Matching is deliberately narrow so it can neither be tripped by user input nor
|
|
25
|
+
// silence a real bug: better-sqlite3's FIXED sentence is required in qmd's OWN
|
|
26
|
+
// captured streams (stderr/stdout), NEVER the synthesized `message` — which
|
|
27
|
+
// echoes the caller's argv — so a search query that merely contains the phrase
|
|
28
|
+
// can never classify. Every other qmd store failure (a locked db, a corrupt
|
|
29
|
+
// store, an unknown exit) matches none of this and stays a reportable
|
|
30
|
+
// QmdExitError.
|
|
31
|
+
/** better-sqlite3's exact "directory does not exist" sentence — sufficient on its own. */
|
|
32
|
+
const STORE_DIR_MISSING = /Cannot open database because the directory does not exist/i;
|
|
33
|
+
/**
|
|
34
|
+
* qmd's OWN captured streams (stderr then stdout), joined. The synthesized
|
|
35
|
+
* `message` is deliberately NOT consulted: it embeds the caller's qmd arguments,
|
|
36
|
+
* so reading it would let a user query for this phrase trip the classifier.
|
|
37
|
+
*/
|
|
38
|
+
function capturedStreams(err) {
|
|
39
|
+
if (err === null || typeof err !== "object")
|
|
40
|
+
return "";
|
|
41
|
+
const record = err;
|
|
42
|
+
const stderr = typeof record.stderr === "string" ? record.stderr : "";
|
|
43
|
+
const stdout = typeof record.stdout === "string" ? record.stdout : "";
|
|
44
|
+
return `${stderr}\n${stdout}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True when `err` is a qmd store-directory-missing failure: better-sqlite3's
|
|
48
|
+
* fixed sentence appears in qmd's captured stderr/stdout. Accepts either the
|
|
49
|
+
* thrown error (reads its `stderr`/`stdout`) or a bare `{ stderr, stdout }`
|
|
50
|
+
* probe object. A true result means the caller should print the classified
|
|
51
|
+
* remedy, exit non-zero, and SKIP Sentry capture.
|
|
52
|
+
*/
|
|
53
|
+
export function isQmdStoreMissingError(err) {
|
|
54
|
+
return STORE_DIR_MISSING.test(capturedStreams(err));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The actionable remedy, naming the store directory that could not be opened and
|
|
58
|
+
* (when the store-directory ensure step recorded one) the errno reason it could
|
|
59
|
+
* not be created. Both are hq-DERIVED values — the resolved store path and a
|
|
60
|
+
* bounded errno phrase — never caller argv/query or upstream free text, so the
|
|
61
|
+
* line stays input-free. Deliberately does NOT point the user at `hq index sync`:
|
|
62
|
+
* reconciliation begins with the same `qmd collection list` and would hit the
|
|
63
|
+
* identical failure, a dead-end loop.
|
|
64
|
+
*/
|
|
65
|
+
function remedyMessage(storeDir, ensureReason) {
|
|
66
|
+
const where = storeDir
|
|
67
|
+
? `hq's local search store directory (${storeDir}) could not be opened`
|
|
68
|
+
: "hq's local search store directory could not be opened";
|
|
69
|
+
const because = ensureReason
|
|
70
|
+
? ` — hq could not create it: ${ensureReason}.`
|
|
71
|
+
: " — it does not exist.";
|
|
72
|
+
return (`${where}${because} This is your machine's filesystem, not an hq bug: ` +
|
|
73
|
+
"check that the directory (and its parent) exist and are writable, or point " +
|
|
74
|
+
"INDEX_PATH at a writable location, then run your command again.");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* If `err` is a qmd store-directory-missing failure, return the actionable
|
|
78
|
+
* remedy; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
|
|
79
|
+
* qmdMissingCollectionMessage so the top-level handler can branch on it the same
|
|
80
|
+
* way: a non-null result means print-and-skip-Sentry, null means "handle as
|
|
81
|
+
* usual (capture to Sentry)". The store directory and errno reason are read
|
|
82
|
+
* STRUCTURALLY from the error's own hq-populated `storeDir`/`ensureReason`
|
|
83
|
+
* fields (absent on a bare probe object — then the message names neither), never
|
|
84
|
+
* from qmd's output.
|
|
85
|
+
*/
|
|
86
|
+
export function qmdStoreMissingMessage(err) {
|
|
87
|
+
if (!isQmdStoreMissingError(err))
|
|
88
|
+
return null;
|
|
89
|
+
const record = err;
|
|
90
|
+
const storeDir = typeof record.storeDir === "string" && record.storeDir.length > 0 ? record.storeDir : null;
|
|
91
|
+
const ensureReason = typeof record.ensureReason === "string" && record.ensureReason.length > 0
|
|
92
|
+
? record.ensureReason
|
|
93
|
+
: null;
|
|
94
|
+
return remedyMessage(storeDir, ensureReason);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=qmd-store-missing-error.js.map
|