@indigoai-us/hq-cli 5.85.3 → 5.87.0
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 +21 -0
- package/assets/scaffold/core/scripts/lint-shared-worker-skills.sh +143 -0
- package/assets/scaffold/core/scripts/share-worker-skill.sh +178 -0
- package/dist/commands/core.d.ts +15 -0
- package/dist/commands/core.js +46 -0
- package/dist/commands/index-cmd.d.ts +20 -0
- package/dist/commands/index-cmd.js +110 -0
- package/dist/commands/search.d.ts +12 -0
- package/dist/commands/search.js +48 -0
- package/dist/lib/search-index/background.d.ts +39 -0
- package/dist/lib/search-index/background.js +382 -0
- package/dist/lib/search-index/index.d.ts +58 -0
- package/dist/lib/search-index/index.js +197 -0
- package/dist/main.js +6 -0
- package/package.json +2 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
|
|
5
|
+
const LOCK_NAME = 'qmd-reindex-bg.lock';
|
|
6
|
+
const COMPLETE_NAME = 'qmd-reindex-bg.completed';
|
|
7
|
+
function defaultSpawnWorker({ logPath }) {
|
|
8
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
9
|
+
const log = fs.openSync(logPath, 'a');
|
|
10
|
+
const entry = process.argv[1];
|
|
11
|
+
if (!entry)
|
|
12
|
+
throw new Error('Cannot determine hq CLI entrypoint for background worker');
|
|
13
|
+
const child = spawn(process.execPath, [entry, 'index', 'background', '--worker', '--log', logPath], {
|
|
14
|
+
detached: true,
|
|
15
|
+
stdio: ['ignore', log, log],
|
|
16
|
+
});
|
|
17
|
+
fs.closeSync(log);
|
|
18
|
+
child.unref();
|
|
19
|
+
if (!child.pid)
|
|
20
|
+
throw new Error('Unable to start qmd background worker');
|
|
21
|
+
return child.pid;
|
|
22
|
+
}
|
|
23
|
+
function alive(pid) {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Defaults used by the CLI; tests supply every nondeterministic dependency. */
|
|
33
|
+
export function defaultBackgroundDependencies(hqRoot) {
|
|
34
|
+
return {
|
|
35
|
+
env: process.env,
|
|
36
|
+
hqRoot,
|
|
37
|
+
now: () => Math.floor(Date.now() / 1_000),
|
|
38
|
+
pid: process.pid,
|
|
39
|
+
random: () => Math.random().toString(36).slice(2),
|
|
40
|
+
isProcessAlive: alive,
|
|
41
|
+
resolveQmdBin: defaultResolveQmdBin,
|
|
42
|
+
reconcileCollections: defaultReconcileCollections,
|
|
43
|
+
runQmd: defaultRunQmd,
|
|
44
|
+
spawnWorker: defaultSpawnWorker,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Match the shell forwarder's hosted-agent markers before looking up qmd. */
|
|
48
|
+
export function isHostedAgent(env = process.env) {
|
|
49
|
+
if (env.HQ_QMD_REINDEX_MODE === 'skip-agent' || env.HQ_QMD_REINDEX_MODE === 'skip')
|
|
50
|
+
return true;
|
|
51
|
+
if (env.HQ_AGENT_BOX && env.HQ_AGENT_BOX !== '0')
|
|
52
|
+
return true;
|
|
53
|
+
try {
|
|
54
|
+
fs.accessSync('/usr/local/bin/hq-agent-qmd-index', fs.constants.X_OK);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch { /* probe next marker */ }
|
|
58
|
+
try {
|
|
59
|
+
fs.accessSync('/usr/local/lib/hq-agent/qmd-index-user', fs.constants.X_OK);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch { /* probe next marker */ }
|
|
63
|
+
return fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.timer')
|
|
64
|
+
|| fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.service')
|
|
65
|
+
|| fs.existsSync('/var/lib/hq-agent');
|
|
66
|
+
}
|
|
67
|
+
function lockRoot(home) {
|
|
68
|
+
return path.join(home, '.hq', 'locks');
|
|
69
|
+
}
|
|
70
|
+
function lockPath(home) {
|
|
71
|
+
return path.join(lockRoot(home), LOCK_NAME);
|
|
72
|
+
}
|
|
73
|
+
function completionPath(home) {
|
|
74
|
+
return path.join(lockRoot(home), COMPLETE_NAME);
|
|
75
|
+
}
|
|
76
|
+
function parseFields(file) {
|
|
77
|
+
try {
|
|
78
|
+
return Object.fromEntries(fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
|
|
79
|
+
const index = line.indexOf('=');
|
|
80
|
+
return index === -1 ? [] : [[line.slice(0, index), line.slice(index + 1)]];
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function readOwner(directory) {
|
|
88
|
+
const fields = parseFields(path.join(directory, 'owner'));
|
|
89
|
+
if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
|
|
90
|
+
return undefined;
|
|
91
|
+
return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
|
|
92
|
+
}
|
|
93
|
+
function graceSeconds(dependencies) {
|
|
94
|
+
const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
|
|
95
|
+
return value && /^\d+$/.test(value) ? Number(value) : 5;
|
|
96
|
+
}
|
|
97
|
+
function dedupeSeconds(dependencies) {
|
|
98
|
+
const value = dependencies.env.QMD_HANDOFF_DEDUPE_SEC;
|
|
99
|
+
return value && /^\d+$/.test(value) ? Number(value) : 90;
|
|
100
|
+
}
|
|
101
|
+
function isRecentCompletion(home, dependencies) {
|
|
102
|
+
const dedupe = dedupeSeconds(dependencies);
|
|
103
|
+
if (dedupe === 0)
|
|
104
|
+
return false;
|
|
105
|
+
const fields = parseFields(completionPath(home));
|
|
106
|
+
if (!fields || !/^\d+$/.test(fields.ts ?? ''))
|
|
107
|
+
return false;
|
|
108
|
+
const age = dependencies.now() - Number(fields.ts);
|
|
109
|
+
return age >= 0 && age < dedupe;
|
|
110
|
+
}
|
|
111
|
+
function writeCompletion(home, dependencies) {
|
|
112
|
+
try {
|
|
113
|
+
fs.writeFileSync(completionPath(home), `ts=${dependencies.now()}\npid=${dependencies.pid}\nmode=raw\n`);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Completion is a best-effort dedupe hint, never a worker failure.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function isOwnerlessLockWithinGrace(directory, dependencies) {
|
|
120
|
+
try {
|
|
121
|
+
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
122
|
+
return age < graceSeconds(dependencies);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function lockState(directory, dependencies) {
|
|
129
|
+
if (!fs.existsSync(directory))
|
|
130
|
+
return 'free';
|
|
131
|
+
const owner = readOwner(directory);
|
|
132
|
+
if (owner)
|
|
133
|
+
return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
|
|
134
|
+
return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
|
|
135
|
+
}
|
|
136
|
+
function generation(directory) {
|
|
137
|
+
const owner = readOwner(directory);
|
|
138
|
+
if (owner)
|
|
139
|
+
return owner.nonce;
|
|
140
|
+
if (!fs.existsSync(directory))
|
|
141
|
+
return undefined;
|
|
142
|
+
return 'empty';
|
|
143
|
+
}
|
|
144
|
+
function removeDirectoryIfEmpty(directory) {
|
|
145
|
+
try {
|
|
146
|
+
fs.rmdirSync(directory);
|
|
147
|
+
}
|
|
148
|
+
catch { /* another claimant owns it, or it is already gone */ }
|
|
149
|
+
}
|
|
150
|
+
function claimWithinGrace(directory, dependencies) {
|
|
151
|
+
try {
|
|
152
|
+
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
153
|
+
return age < Math.max(5, graceSeconds(dependencies));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function acquireClaim(home, observedGeneration, dependencies) {
|
|
160
|
+
const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
|
|
161
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
162
|
+
try {
|
|
163
|
+
fs.mkdirSync(claim);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
|
|
167
|
+
return undefined;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(claimant);
|
|
173
|
+
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
174
|
+
return { claim, claimant };
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
fs.rmSync(claimant, { recursive: true, force: true });
|
|
178
|
+
removeDirectoryIfEmpty(claim);
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reclaim only exact dead claimant names, then remove the claim directory if
|
|
186
|
+
* empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
|
|
187
|
+
* recreated claim.G with a new marker makes rmdir fail and wins the race.
|
|
188
|
+
*/
|
|
189
|
+
function recoverAbandonedClaim(claim, dependencies) {
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(claim, { withFileTypes: true });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
|
|
198
|
+
const marker = path.join(claim, entry.name);
|
|
199
|
+
const fields = parseFields(path.join(marker, 'owner'));
|
|
200
|
+
const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
|
|
201
|
+
if (pid !== undefined && dependencies.isProcessAlive(pid))
|
|
202
|
+
return false;
|
|
203
|
+
if (pid === undefined && claimWithinGrace(claim, dependencies))
|
|
204
|
+
return false;
|
|
205
|
+
const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
|
|
206
|
+
try {
|
|
207
|
+
fs.renameSync(marker, abandoned);
|
|
208
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (entries.length === 0 && claimWithinGrace(claim, dependencies))
|
|
215
|
+
return false;
|
|
216
|
+
removeDirectoryIfEmpty(claim);
|
|
217
|
+
return !fs.existsSync(claim);
|
|
218
|
+
}
|
|
219
|
+
function createAndPublishLock(home, dependencies) {
|
|
220
|
+
const directory = lockPath(home);
|
|
221
|
+
try {
|
|
222
|
+
fs.mkdirSync(directory);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
|
|
228
|
+
try {
|
|
229
|
+
fs.mkdirSync(marker);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
removeDirectoryIfEmpty(directory);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
const nonce = `${dependencies.pid}.${dependencies.random()}`;
|
|
236
|
+
const ownerFile = path.join(directory, 'owner');
|
|
237
|
+
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
238
|
+
try {
|
|
239
|
+
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
240
|
+
fs.renameSync(temporary, ownerFile);
|
|
241
|
+
dependencies.afterOwnerPublish?.(ownerFile);
|
|
242
|
+
const verified = readOwner(directory);
|
|
243
|
+
if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
|
|
244
|
+
throw new Error('owner publish verification failed');
|
|
245
|
+
fs.rmSync(marker, { recursive: true, force: true });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// The marker identifies only the directory this process created. Do not
|
|
250
|
+
// recursively remove the fixed lock path after a failed publication.
|
|
251
|
+
fs.rmSync(marker, { recursive: true, force: true });
|
|
252
|
+
removeDirectoryIfEmpty(directory);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function claimAndReclaim(home, dependencies) {
|
|
257
|
+
const directory = lockPath(home);
|
|
258
|
+
const observedGeneration = generation(directory);
|
|
259
|
+
if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
260
|
+
return false;
|
|
261
|
+
const acquired = acquireClaim(home, observedGeneration, dependencies);
|
|
262
|
+
if (!acquired)
|
|
263
|
+
return false;
|
|
264
|
+
try {
|
|
265
|
+
// Generation fencing: only move exactly the stale generation we observed.
|
|
266
|
+
if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
267
|
+
return false;
|
|
268
|
+
const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
|
|
269
|
+
fs.renameSync(directory, abandoned);
|
|
270
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
271
|
+
return createAndPublishLock(home, dependencies);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
fs.rmSync(acquired.claimant, { recursive: true, force: true });
|
|
278
|
+
removeDirectoryIfEmpty(acquired.claim);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function acquireLock(home, dependencies) {
|
|
282
|
+
fs.mkdirSync(lockRoot(home), { recursive: true });
|
|
283
|
+
return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies);
|
|
284
|
+
}
|
|
285
|
+
function releaseLock(home, dependencies) {
|
|
286
|
+
const directory = lockPath(home);
|
|
287
|
+
if (readOwner(directory)?.pid === dependencies.pid)
|
|
288
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
|
|
291
|
+
processEvents.once('exit', cleanup);
|
|
292
|
+
// Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
|
|
293
|
+
// catchable cleanup. SIGINT/SIGTERM are registered here and the CLI's normal
|
|
294
|
+
// process exit then runs the same idempotent owner release. Exiting prevents
|
|
295
|
+
// a synchronous pipeline from continuing after it has released ownership.
|
|
296
|
+
processEvents.once('SIGINT', () => { cleanup(); exit(0); });
|
|
297
|
+
processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
|
|
298
|
+
}
|
|
299
|
+
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
300
|
+
export function runBackgroundLauncher(dependencies) {
|
|
301
|
+
if (isHostedAgent(dependencies.env))
|
|
302
|
+
return { state: 'skipped-agent' };
|
|
303
|
+
const home = dependencies.env.HOME;
|
|
304
|
+
if (!home)
|
|
305
|
+
return { state: 'quiet' };
|
|
306
|
+
try {
|
|
307
|
+
dependencies.resolveQmdBin();
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return { state: 'skipped' };
|
|
311
|
+
}
|
|
312
|
+
const logPath = dependencies.env.QMD_REINDEX_LOG
|
|
313
|
+
?? dependencies.env.QMD_HANDOFF_LOG
|
|
314
|
+
?? path.join(dependencies.env.HANDOFF_LOG_DIR ?? '/tmp', 'qmd-handoff.log');
|
|
315
|
+
return { state: 'launched', pid: dependencies.spawnWorker({ logPath }) };
|
|
316
|
+
}
|
|
317
|
+
/** Run the single-flight cleanup → update → embed pipeline in a worker only. */
|
|
318
|
+
export function runBackgroundWorker(dependencies) {
|
|
319
|
+
if (isHostedAgent(dependencies.env))
|
|
320
|
+
return { state: 'skipped-agent' };
|
|
321
|
+
const home = dependencies.env.HOME;
|
|
322
|
+
if (!home)
|
|
323
|
+
return { state: 'quiet' };
|
|
324
|
+
try {
|
|
325
|
+
dependencies.resolveQmdBin();
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return { state: 'skipped' };
|
|
329
|
+
}
|
|
330
|
+
if (isRecentCompletion(home, dependencies) || !acquireLock(home, dependencies))
|
|
331
|
+
return { state: 'busy' };
|
|
332
|
+
let released = false;
|
|
333
|
+
const cleanup = () => {
|
|
334
|
+
if (released)
|
|
335
|
+
return;
|
|
336
|
+
released = true;
|
|
337
|
+
releaseLock(home, dependencies);
|
|
338
|
+
};
|
|
339
|
+
installWorkerCleanup(cleanup, process, (code) => process.exit(code));
|
|
340
|
+
try {
|
|
341
|
+
if (isRecentCompletion(home, dependencies))
|
|
342
|
+
return { state: 'busy' };
|
|
343
|
+
// Collection reconciliation is the existing #306 seam; cleanup/update/embed
|
|
344
|
+
// retain their shell-script ordering after that policy setup.
|
|
345
|
+
try {
|
|
346
|
+
dependencies.reconcileCollections(dependencies.hqRoot);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// The shell worker has no collection-registration step. Keep this #306
|
|
350
|
+
// integration best-effort so it cannot suppress a later index update.
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
dependencies.runQmd(['cleanup'], { cwd: dependencies.hqRoot });
|
|
354
|
+
}
|
|
355
|
+
catch { /* cleanup is intentionally best-effort */ }
|
|
356
|
+
try {
|
|
357
|
+
dependencies.runQmd(['update'], { cwd: dependencies.hqRoot });
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return { state: 'update-failed' };
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
dependencies.runQmd(['embed'], { cwd: dependencies.hqRoot });
|
|
364
|
+
}
|
|
365
|
+
catch { /* a completed embed attempt still permits the completion stamp */ }
|
|
366
|
+
writeCompletion(home, dependencies);
|
|
367
|
+
return { state: 'completed' };
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
cleanup();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
/** Report the background lock and latest successful completion for `hq index status`. */
|
|
374
|
+
export function backgroundStatus(dependencies) {
|
|
375
|
+
const home = dependencies.env.HOME;
|
|
376
|
+
if (!home)
|
|
377
|
+
return { lock: 'free' };
|
|
378
|
+
const fields = parseFields(completionPath(home));
|
|
379
|
+
const completedAt = fields && /^\d+$/.test(fields.ts ?? '') ? Number(fields.ts) : undefined;
|
|
380
|
+
return { lock: lockState(lockPath(home), dependencies), ...(completedAt === undefined ? {} : { completedAt }) };
|
|
381
|
+
}
|
|
382
|
+
//# sourceMappingURL=background.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type QmdProcessResult = {
|
|
2
|
+
status: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
error?: Error;
|
|
6
|
+
};
|
|
7
|
+
export type QmdProcessRunner = (bin: string, args: string[], options: {
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}) => QmdProcessResult;
|
|
11
|
+
export declare class QmdBinaryMissingError extends Error {
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
export declare class QmdExitError extends Error {
|
|
15
|
+
readonly args: string[];
|
|
16
|
+
readonly status: number | null;
|
|
17
|
+
readonly stdout: string;
|
|
18
|
+
readonly stderr: string;
|
|
19
|
+
name: string;
|
|
20
|
+
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string);
|
|
21
|
+
}
|
|
22
|
+
export declare class QmdCollectionMissingError extends QmdExitError {
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
export type ResolveQmdBinOptions = {
|
|
26
|
+
env?: Record<string, string | undefined>;
|
|
27
|
+
isExecutable?: (candidate: string) => boolean;
|
|
28
|
+
packageBin?: () => string | undefined;
|
|
29
|
+
pathBin?: () => string | undefined;
|
|
30
|
+
};
|
|
31
|
+
/** Return the pinned package version when qmd is supplied by this CLI. */
|
|
32
|
+
export declare function resolveQmdVersion(): string | undefined;
|
|
33
|
+
/** Resolve qmd without relying on a globally installed copy. */
|
|
34
|
+
export declare function resolveQmdBin(options?: ResolveQmdBinOptions): string;
|
|
35
|
+
export type RunQmdOptions = {
|
|
36
|
+
bin?: string;
|
|
37
|
+
cwd?: string;
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
runner?: QmdProcessRunner;
|
|
40
|
+
};
|
|
41
|
+
/** Run qmd with captured output and typed failures. */
|
|
42
|
+
export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
|
|
43
|
+
export type SearchCollection = {
|
|
44
|
+
name: string;
|
|
45
|
+
path: string;
|
|
46
|
+
mask: string;
|
|
47
|
+
context: string;
|
|
48
|
+
};
|
|
49
|
+
/** Derive the local qmd collection policy for one HQ tree. */
|
|
50
|
+
export declare function deriveCollections(hqRoot: string): SearchCollection[];
|
|
51
|
+
export type ReconcileCollectionsOptions = {
|
|
52
|
+
bin?: string;
|
|
53
|
+
runner?: QmdProcessRunner;
|
|
54
|
+
};
|
|
55
|
+
/** Register expected collections that qmd does not yet know about. */
|
|
56
|
+
export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
|
|
57
|
+
export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
|
|
58
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
export class QmdBinaryMissingError extends Error {
|
|
7
|
+
name = 'QmdBinaryMissingError';
|
|
8
|
+
}
|
|
9
|
+
export class QmdExitError extends Error {
|
|
10
|
+
args;
|
|
11
|
+
status;
|
|
12
|
+
stdout;
|
|
13
|
+
stderr;
|
|
14
|
+
name = 'QmdExitError';
|
|
15
|
+
constructor(message, args, status, stdout, stderr) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.args = args;
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.stdout = stdout;
|
|
20
|
+
this.stderr = stderr;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class QmdCollectionMissingError extends QmdExitError {
|
|
24
|
+
name = 'QmdCollectionMissingError';
|
|
25
|
+
}
|
|
26
|
+
function isExecutable(candidate) {
|
|
27
|
+
try {
|
|
28
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function packageLocalBin() {
|
|
36
|
+
try {
|
|
37
|
+
const packageJson = require.resolve('@tobilu/qmd/package.json');
|
|
38
|
+
return path.join(path.dirname(packageJson), 'qmd');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Return the pinned package version when qmd is supplied by this CLI. */
|
|
45
|
+
export function resolveQmdVersion() {
|
|
46
|
+
try {
|
|
47
|
+
const packageJson = require('@tobilu/qmd/package.json');
|
|
48
|
+
return typeof packageJson.version === 'string' ? packageJson.version : undefined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function pathBin() {
|
|
55
|
+
const paths = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
56
|
+
const names = process.platform === 'win32' ? ['qmd.exe', 'qmd.cmd', 'qmd'] : ['qmd'];
|
|
57
|
+
for (const directory of paths) {
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const candidate = path.join(directory, name);
|
|
60
|
+
if (isExecutable(candidate))
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Resolve qmd without relying on a globally installed copy. */
|
|
67
|
+
export function resolveQmdBin(options = {}) {
|
|
68
|
+
const env = options.env ?? process.env;
|
|
69
|
+
const executable = options.isExecutable ?? isExecutable;
|
|
70
|
+
const probes = [];
|
|
71
|
+
const override = env.HQ_QMD_BIN;
|
|
72
|
+
if (override) {
|
|
73
|
+
if (executable(override))
|
|
74
|
+
return override;
|
|
75
|
+
probes.push(`HQ_QMD_BIN (${override})`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
probes.push('HQ_QMD_BIN (not set)');
|
|
79
|
+
}
|
|
80
|
+
const installed = (options.packageBin ?? packageLocalBin)();
|
|
81
|
+
if (installed && executable(installed))
|
|
82
|
+
return installed;
|
|
83
|
+
probes.push(`package-local @tobilu/qmd (${installed ?? 'not found'})`);
|
|
84
|
+
const onPath = (options.pathBin ?? pathBin)();
|
|
85
|
+
if (onPath && executable(onPath))
|
|
86
|
+
return onPath;
|
|
87
|
+
probes.push(`qmd on PATH (${onPath ?? 'not found'})`);
|
|
88
|
+
throw new QmdBinaryMissingError(`Unable to resolve qmd. Probed ${probes.join('; ')}. Install @tobilu/qmd or set HQ_QMD_BIN to an executable qmd binary.`);
|
|
89
|
+
}
|
|
90
|
+
function defaultRunner(bin, args, options) {
|
|
91
|
+
const result = spawnSync(bin, args, { cwd: options.cwd, env: options.env, encoding: 'utf8' });
|
|
92
|
+
return {
|
|
93
|
+
status: result.status,
|
|
94
|
+
stdout: result.stdout ?? '',
|
|
95
|
+
stderr: result.stderr ?? '',
|
|
96
|
+
error: result.error,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Run qmd with captured output and typed failures. */
|
|
100
|
+
export function runQmd(args, options = {}) {
|
|
101
|
+
const bin = options.bin ?? resolveQmdBin({ env: options.env });
|
|
102
|
+
const result = (options.runner ?? defaultRunner)(bin, args, { cwd: options.cwd, env: options.env });
|
|
103
|
+
if (result.error) {
|
|
104
|
+
throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${result.error.message}`);
|
|
105
|
+
}
|
|
106
|
+
if (result.status === 0)
|
|
107
|
+
return result;
|
|
108
|
+
const detail = result.stderr || result.stdout || 'qmd returned no diagnostic output';
|
|
109
|
+
const message = `qmd ${args.join(' ')} exited with ${result.status ?? 'an unknown status'}: ${detail}`;
|
|
110
|
+
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
|
|
111
|
+
throw new QmdCollectionMissingError(message, args, result.status, result.stdout, result.stderr);
|
|
112
|
+
}
|
|
113
|
+
throw new QmdExitError(message, args, result.status, result.stdout, result.stderr);
|
|
114
|
+
}
|
|
115
|
+
function containsIndexedMarkdown(directory) {
|
|
116
|
+
if (!fs.existsSync(directory))
|
|
117
|
+
return false;
|
|
118
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
119
|
+
const child = path.join(directory, entry.name);
|
|
120
|
+
if (entry.isDirectory() && containsIndexedMarkdown(child))
|
|
121
|
+
return true;
|
|
122
|
+
if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'INDEX.md')
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
function containsProjectSource(directory) {
|
|
128
|
+
if (!fs.existsSync(directory))
|
|
129
|
+
return false;
|
|
130
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
131
|
+
const child = path.join(directory, entry.name);
|
|
132
|
+
if (entry.isDirectory() && containsProjectSource(child))
|
|
133
|
+
return true;
|
|
134
|
+
if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json')))
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
/** Derive the local qmd collection policy for one HQ tree. */
|
|
140
|
+
export function deriveCollections(hqRoot) {
|
|
141
|
+
const root = path.resolve(hqRoot);
|
|
142
|
+
const companiesDir = path.join(root, 'companies');
|
|
143
|
+
const companies = fs.existsSync(companiesDir)
|
|
144
|
+
? fs.readdirSync(companiesDir, { withFileTypes: true })
|
|
145
|
+
.filter((entry) => entry.isDirectory())
|
|
146
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
147
|
+
: [];
|
|
148
|
+
const collections = [];
|
|
149
|
+
for (const entry of companies) {
|
|
150
|
+
const knowledge = path.join(companiesDir, entry.name, 'knowledge');
|
|
151
|
+
if (!containsIndexedMarkdown(knowledge))
|
|
152
|
+
continue;
|
|
153
|
+
collections.push({
|
|
154
|
+
name: entry.name,
|
|
155
|
+
path: knowledge,
|
|
156
|
+
mask: '**/*.md',
|
|
157
|
+
context: `Knowledge base for ${entry.name}.`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
for (const entry of companies) {
|
|
161
|
+
const projects = path.join(companiesDir, entry.name, 'projects');
|
|
162
|
+
if (!containsProjectSource(projects))
|
|
163
|
+
continue;
|
|
164
|
+
collections.push({
|
|
165
|
+
name: `${entry.name}-projects`,
|
|
166
|
+
path: projects,
|
|
167
|
+
mask: '**/*.{md,json}',
|
|
168
|
+
context: `Project PRDs and documentation for ${entry.name}.`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const personalKnowledge = path.join(root, 'personal', 'knowledge');
|
|
172
|
+
if (containsIndexedMarkdown(personalKnowledge)) {
|
|
173
|
+
collections.push({
|
|
174
|
+
name: 'personal-knowledge',
|
|
175
|
+
path: personalKnowledge,
|
|
176
|
+
mask: '**/*.md',
|
|
177
|
+
context: 'Personal knowledge base (owner overlay).',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return collections;
|
|
181
|
+
}
|
|
182
|
+
/** Register expected collections that qmd does not yet know about. */
|
|
183
|
+
export function reconcileCollections(hqRoot, options = {}) {
|
|
184
|
+
const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
|
|
185
|
+
const registered = listRegisteredCollections(hqRoot, runOptions);
|
|
186
|
+
const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
|
|
187
|
+
for (const collection of missing) {
|
|
188
|
+
runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
|
|
189
|
+
runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
|
|
190
|
+
}
|
|
191
|
+
return missing;
|
|
192
|
+
}
|
|
193
|
+
export function listRegisteredCollections(hqRoot, options = {}) {
|
|
194
|
+
const result = runQmd(['collection', 'list'], { ...options, cwd: options.cwd ?? hqRoot });
|
|
195
|
+
return new Set([...result.stdout.matchAll(/qmd:\/\/([^/\s]+)/g)].map((match) => match[1]));
|
|
196
|
+
}
|
|
197
|
+
//# sourceMappingURL=index.js.map
|
package/dist/main.js
CHANGED
|
@@ -57,6 +57,8 @@ import { registerOutpostsCommand } from "./commands/outposts.js";
|
|
|
57
57
|
import { registerBillingCommand } from "./commands/billing.js";
|
|
58
58
|
import { registerDbCommand } from "./commands/db.js";
|
|
59
59
|
import { registerCoreCommands } from "./commands/core.js";
|
|
60
|
+
import { registerSearchCommand } from "./commands/search.js";
|
|
61
|
+
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
60
62
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
61
63
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
62
64
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
@@ -228,6 +230,10 @@ registerBillingCommand(program);
|
|
|
228
230
|
// the source-root entries are maintainer tools that must never touch a live
|
|
229
231
|
// install. Registered from a manifest in the module, not wired per script here.
|
|
230
232
|
registerCoreCommands(program);
|
|
233
|
+
// Local qmd search and index management. Kept distinct from `hq reindex`,
|
|
234
|
+
// which converges scaffold-owned files and hooks rather than search data.
|
|
235
|
+
registerSearchCommand(program);
|
|
236
|
+
registerIndexCommand(program);
|
|
231
237
|
program.hook("preAction", async () => {
|
|
232
238
|
await emitCliSessionStarted();
|
|
233
239
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.87.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"@indigoai-us/hq-cloud": "^6.14.45",
|
|
33
33
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
34
34
|
"@sentry/node": "^10.49.0",
|
|
35
|
+
"@tobilu/qmd": "1.0.7",
|
|
35
36
|
"better-sqlite3": "^12.11.1",
|
|
36
37
|
"chalk": "^5.3.0",
|
|
37
38
|
"commander": "^12.1.0",
|