@indigoai-us/hq-cli 5.115.1 → 5.115.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +10 -87
  3. package/dist/commands/bot.js +1 -1
  4. package/dist/commands/index-cmd.d.ts +8 -1
  5. package/dist/commands/index-cmd.js +15 -5
  6. package/dist/commands/onboard-identity-guard.d.ts +1 -1
  7. package/dist/commands/onboard.js +1 -1
  8. package/dist/lib/bot/prompt.d.ts +8 -1
  9. package/dist/lib/bot/prompt.js +10 -2
  10. package/dist/lib/bot/run.js +1 -1
  11. package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +4 -1
  12. package/dist/lib/core-utils/qmd-reindex-after-sync.js +11 -2
  13. package/dist/lib/onboarding/checkpoint.d.ts +13 -0
  14. package/dist/lib/onboarding/checkpoint.js +44 -0
  15. package/dist/lib/onboarding/cli/onboard.d.ts +53 -0
  16. package/dist/lib/onboarding/cli/onboard.js +135 -0
  17. package/dist/lib/onboarding/cli/prompts.d.ts +28 -0
  18. package/dist/lib/onboarding/cli/prompts.js +83 -0
  19. package/dist/lib/onboarding/errors.d.ts +33 -0
  20. package/dist/lib/onboarding/errors.js +59 -0
  21. package/dist/lib/onboarding/index.d.ts +17 -0
  22. package/dist/lib/onboarding/index.js +16 -0
  23. package/dist/lib/onboarding/orchestrator.d.ts +47 -0
  24. package/dist/lib/onboarding/orchestrator.js +569 -0
  25. package/dist/lib/onboarding/types.d.ts +79 -0
  26. package/dist/lib/onboarding/types.js +8 -0
  27. package/dist/lib/search-index/background.js +2 -200
  28. package/dist/lib/search-index/embed-lock.d.ts +38 -0
  29. package/dist/lib/search-index/embed-lock.js +286 -0
  30. package/dist/lib/search-index/index.d.ts +1 -0
  31. package/dist/lib/search-index/index.js +4 -1
  32. package/package.json +1 -2
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import { Sentry } from '../../sentry.js';
5
5
  import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
6
6
  import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-bytes.js';
7
- const LOCK_NAME = 'qmd-reindex-bg.lock';
7
+ import { acquireEmbedLock as acquireLock, installEmbedLockCleanup, lockPath, lockRoot, lockState, releaseEmbedLock as releaseLock, } from './embed-lock.js';
8
8
  const COMPLETE_NAME = 'qmd-reindex-bg.completed';
9
9
  function errnoInfo(error) {
10
10
  const e = error;
@@ -145,12 +145,6 @@ export function isHostedAgent(env = process.env) {
145
145
  || fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.service')
146
146
  || fs.existsSync('/var/lib/hq-agent');
147
147
  }
148
- function lockRoot(home) {
149
- return path.join(home, '.hq', 'locks');
150
- }
151
- function lockPath(home) {
152
- return path.join(lockRoot(home), LOCK_NAME);
153
- }
154
148
  function completionPath(home) {
155
149
  return path.join(lockRoot(home), COMPLETE_NAME);
156
150
  }
@@ -165,16 +159,6 @@ function parseFields(file) {
165
159
  return undefined;
166
160
  }
167
161
  }
168
- function readOwner(directory) {
169
- const fields = parseFields(path.join(directory, 'owner'));
170
- if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
171
- return undefined;
172
- return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
173
- }
174
- function graceSeconds(dependencies) {
175
- const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
176
- return value && /^\d+$/.test(value) ? Number(value) : 5;
177
- }
178
162
  function dedupeSeconds(dependencies) {
179
163
  const value = dependencies.env.QMD_HANDOFF_DEDUPE_SEC;
180
164
  return value && /^\d+$/.test(value) ? Number(value) : 90;
@@ -197,187 +181,7 @@ function writeCompletion(home, dependencies) {
197
181
  // Completion is a best-effort dedupe hint, never a worker failure.
198
182
  }
199
183
  }
200
- function isOwnerlessLockWithinGrace(directory, dependencies) {
201
- try {
202
- const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
203
- return age < graceSeconds(dependencies);
204
- }
205
- catch {
206
- return true;
207
- }
208
- }
209
- function lockState(directory, dependencies) {
210
- if (!fs.existsSync(directory))
211
- return 'free';
212
- const owner = readOwner(directory);
213
- if (owner)
214
- return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
215
- return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
216
- }
217
- function generation(directory) {
218
- const owner = readOwner(directory);
219
- if (owner)
220
- return owner.nonce;
221
- if (!fs.existsSync(directory))
222
- return undefined;
223
- return 'empty';
224
- }
225
- function removeDirectoryIfEmpty(directory) {
226
- try {
227
- fs.rmdirSync(directory);
228
- }
229
- catch { /* another claimant owns it, or it is already gone */ }
230
- }
231
- function claimWithinGrace(directory, dependencies) {
232
- try {
233
- const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
234
- return age < Math.max(5, graceSeconds(dependencies));
235
- }
236
- catch {
237
- return true;
238
- }
239
- }
240
- function acquireClaim(home, observedGeneration, dependencies) {
241
- const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
242
- for (let attempt = 0; attempt < 2; attempt += 1) {
243
- try {
244
- fs.mkdirSync(claim);
245
- }
246
- catch {
247
- if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
248
- return undefined;
249
- continue;
250
- }
251
- const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
252
- try {
253
- fs.mkdirSync(claimant);
254
- // Test-only deterministic failure inject (unset in production) — shell
255
- // parity with the script's claimant write_owner_record injection.
256
- if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
257
- throw new Error('forced claimant write failure');
258
- fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
259
- return { claim, claimant };
260
- }
261
- catch {
262
- fs.rmSync(claimant, { recursive: true, force: true });
263
- removeDirectoryIfEmpty(claim);
264
- return undefined;
265
- }
266
- }
267
- return undefined;
268
- }
269
- /**
270
- * Reclaim only exact dead claimant names, then remove the claim directory if
271
- * empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
272
- * recreated claim.G with a new marker makes rmdir fail and wins the race.
273
- */
274
- function recoverAbandonedClaim(claim, dependencies) {
275
- let entries;
276
- try {
277
- entries = fs.readdirSync(claim, { withFileTypes: true });
278
- }
279
- catch {
280
- return true;
281
- }
282
- for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
283
- const marker = path.join(claim, entry.name);
284
- const fields = parseFields(path.join(marker, 'owner'));
285
- const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
286
- if (pid !== undefined && dependencies.isProcessAlive(pid))
287
- return false;
288
- if (pid === undefined && claimWithinGrace(claim, dependencies))
289
- return false;
290
- const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
291
- try {
292
- fs.renameSync(marker, abandoned);
293
- fs.rmSync(abandoned, { recursive: true, force: true });
294
- }
295
- catch {
296
- return false;
297
- }
298
- }
299
- if (entries.length === 0 && claimWithinGrace(claim, dependencies))
300
- return false;
301
- removeDirectoryIfEmpty(claim);
302
- return !fs.existsSync(claim);
303
- }
304
- function createAndPublishLock(home, dependencies) {
305
- const directory = lockPath(home);
306
- try {
307
- fs.mkdirSync(directory);
308
- }
309
- catch {
310
- return false;
311
- }
312
- const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
313
- try {
314
- fs.mkdirSync(marker);
315
- }
316
- catch {
317
- removeDirectoryIfEmpty(directory);
318
- return false;
319
- }
320
- const nonce = `${dependencies.pid}.${dependencies.random()}`;
321
- const ownerFile = path.join(directory, 'owner');
322
- const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
323
- try {
324
- // Test-only deterministic failure inject (unset in production) — shell
325
- // parity: the script's write_owner_record honored the same variable.
326
- if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
327
- throw new Error('forced owner write failure');
328
- fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
329
- fs.renameSync(temporary, ownerFile);
330
- dependencies.afterOwnerPublish?.(ownerFile);
331
- const verified = readOwner(directory);
332
- if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
333
- throw new Error('owner publish verification failed');
334
- fs.rmSync(marker, { recursive: true, force: true });
335
- return true;
336
- }
337
- catch {
338
- // The marker identifies only the directory this process created. Do not
339
- // recursively remove the fixed lock path after a failed publication.
340
- fs.rmSync(marker, { recursive: true, force: true });
341
- removeDirectoryIfEmpty(directory);
342
- return false;
343
- }
344
- }
345
- function claimAndReclaim(home, dependencies) {
346
- const directory = lockPath(home);
347
- const observedGeneration = generation(directory);
348
- if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
349
- return false;
350
- const acquired = acquireClaim(home, observedGeneration, dependencies);
351
- if (!acquired)
352
- return false;
353
- try {
354
- // Generation fencing: only move exactly the stale generation we observed.
355
- if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
356
- return false;
357
- const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
358
- fs.renameSync(directory, abandoned);
359
- fs.rmSync(abandoned, { recursive: true, force: true });
360
- return createAndPublishLock(home, dependencies);
361
- }
362
- catch {
363
- return false;
364
- }
365
- finally {
366
- fs.rmSync(acquired.claimant, { recursive: true, force: true });
367
- removeDirectoryIfEmpty(acquired.claim);
368
- }
369
- }
370
- function acquireLock(home, dependencies) {
371
- fs.mkdirSync(lockRoot(home), { recursive: true });
372
- return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies);
373
- }
374
- function releaseLock(home, dependencies) {
375
- const directory = lockPath(home);
376
- if (readOwner(directory)?.pid === dependencies.pid)
377
- fs.rmSync(directory, { recursive: true, force: true });
378
- }
379
184
  export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
380
- processEvents.once('exit', cleanup);
381
185
  // Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
382
186
  // catchable cleanup. SIGINT/SIGTERM/SIGHUP are registered here (the shell
383
187
  // worker trapped `INT TERM HUP`; a detached worker's controlling terminal
@@ -385,9 +189,7 @@ export function installWorkerCleanup(cleanup, processEvents = process, exit = ()
385
189
  // process without releasing the lock) and the CLI's normal process exit then
386
190
  // runs the same idempotent owner release. Exiting prevents a synchronous
387
191
  // pipeline from continuing after it has released ownership.
388
- processEvents.once('SIGINT', () => { cleanup(); exit(0); });
389
- processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
390
- processEvents.once('SIGHUP', () => { cleanup(); exit(0); });
192
+ installEmbedLockCleanup(cleanup, processEvents, exit);
391
193
  }
392
194
  function workerLogPath(env) {
393
195
  return env.QMD_REINDEX_LOG
@@ -0,0 +1,38 @@
1
+ export type EmbedLockDependencies = {
2
+ env: NodeJS.ProcessEnv;
3
+ now: () => number;
4
+ pid: number;
5
+ random: () => string;
6
+ isProcessAlive: (pid: number) => boolean;
7
+ /** Test seam for simulating a competing owner replacing the atomic record. */
8
+ afterOwnerPublish?: (ownerFile: string) => void;
9
+ };
10
+ export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
11
+ removeListener?: NodeJS.Process['removeListener'];
12
+ };
13
+ export type EmbedLockRunResult = 'ran' | 'busy' | 'unavailable';
14
+ /**
15
+ * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
16
+ * another tool that does not call this module) cannot participate, because qmd
17
+ * is a third-party package and does not know about HQ's lock protocol.
18
+ */
19
+ export declare const EMBED_LOCK_NAME = "qmd-reindex-bg.lock";
20
+ export declare function defaultEmbedLockDependencies(env?: NodeJS.ProcessEnv): EmbedLockDependencies;
21
+ export declare function lockRoot(home: string): string;
22
+ export declare function lockPath(home: string): string;
23
+ export declare function lockState(directory: string, dependencies: EmbedLockDependencies): 'held' | 'stale' | 'free';
24
+ export declare function acquireEmbedLock(home: string, dependencies: EmbedLockDependencies): boolean;
25
+ export declare function releaseEmbedLock(home: string, dependencies: EmbedLockDependencies): void;
26
+ /** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
27
+ export declare function installEmbedLockCleanup(cleanup: () => void, processEvents?: EmbedLockProcessEvents, exit?: (code: number) => void): () => void;
28
+ /**
29
+ * Run one synchronous embed while owning the shared lock.
30
+ *
31
+ * This deliberately does not install signal handlers. `run` is a synchronous
32
+ * qmd child, so JavaScript cannot execute a cleanup handler until it returns;
33
+ * registering SIGINT/SIGTERM would therefore suppress Node's immediate default
34
+ * termination. An interrupted owner is safely reclaimed through the lock's
35
+ * existing staleness and owner-grace protocol.
36
+ */
37
+ export declare function runWithEmbedLock(home: string, dependencies: EmbedLockDependencies, run: () => void): EmbedLockRunResult;
38
+ //# sourceMappingURL=embed-lock.d.ts.map
@@ -0,0 +1,286 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ /**
4
+ * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
5
+ * another tool that does not call this module) cannot participate, because qmd
6
+ * is a third-party package and does not know about HQ's lock protocol.
7
+ */
8
+ export const EMBED_LOCK_NAME = 'qmd-reindex-bg.lock';
9
+ export function defaultEmbedLockDependencies(env = process.env) {
10
+ return {
11
+ env,
12
+ now: () => Math.floor(Date.now() / 1_000),
13
+ pid: process.pid,
14
+ random: () => Math.random().toString(36).slice(2),
15
+ isProcessAlive: (pid) => {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ },
24
+ };
25
+ }
26
+ export function lockRoot(home) {
27
+ return path.join(home, '.hq', 'locks');
28
+ }
29
+ export function lockPath(home) {
30
+ return path.join(lockRoot(home), EMBED_LOCK_NAME);
31
+ }
32
+ function parseFields(file) {
33
+ try {
34
+ return Object.fromEntries(fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
35
+ const index = line.indexOf('=');
36
+ return index === -1 ? [] : [[line.slice(0, index), line.slice(index + 1)]];
37
+ }));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ function readOwner(directory) {
44
+ const fields = parseFields(path.join(directory, 'owner'));
45
+ if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
46
+ return undefined;
47
+ return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
48
+ }
49
+ function graceSeconds(dependencies) {
50
+ const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
51
+ return value && /^\d+$/.test(value) ? Number(value) : 5;
52
+ }
53
+ function isOwnerlessLockWithinGrace(directory, dependencies) {
54
+ try {
55
+ const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
56
+ return age < graceSeconds(dependencies);
57
+ }
58
+ catch {
59
+ return true;
60
+ }
61
+ }
62
+ export function lockState(directory, dependencies) {
63
+ if (!fs.existsSync(directory))
64
+ return 'free';
65
+ const owner = readOwner(directory);
66
+ if (owner)
67
+ return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
68
+ return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
69
+ }
70
+ function generation(directory) {
71
+ const owner = readOwner(directory);
72
+ if (owner)
73
+ return owner.nonce;
74
+ if (!fs.existsSync(directory))
75
+ return undefined;
76
+ return 'empty';
77
+ }
78
+ function removeDirectoryIfEmpty(directory) {
79
+ try {
80
+ fs.rmdirSync(directory);
81
+ }
82
+ catch { /* another claimant owns it, or it is already gone */ }
83
+ }
84
+ function claimWithinGrace(directory, dependencies) {
85
+ try {
86
+ const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
87
+ return age < Math.max(5, graceSeconds(dependencies));
88
+ }
89
+ catch {
90
+ return true;
91
+ }
92
+ }
93
+ function acquireClaim(home, observedGeneration, dependencies) {
94
+ const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
95
+ for (let attempt = 0; attempt < 2; attempt += 1) {
96
+ try {
97
+ fs.mkdirSync(claim);
98
+ }
99
+ catch {
100
+ if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
101
+ return undefined;
102
+ continue;
103
+ }
104
+ const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
105
+ try {
106
+ fs.mkdirSync(claimant);
107
+ // Test-only deterministic failure inject (unset in production) — shell
108
+ // parity with the script's claimant write_owner_record injection.
109
+ if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
110
+ throw new Error('forced claimant write failure');
111
+ fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
112
+ return { claim, claimant };
113
+ }
114
+ catch {
115
+ fs.rmSync(claimant, { recursive: true, force: true });
116
+ removeDirectoryIfEmpty(claim);
117
+ return undefined;
118
+ }
119
+ }
120
+ return undefined;
121
+ }
122
+ /**
123
+ * Reclaim only exact dead claimant names, then remove the claim directory if
124
+ * empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
125
+ * recreated claim.G with a new marker makes rmdir fail and wins the race.
126
+ *
127
+ * A process killed after the identity-safe rename and before its rmSync leaves
128
+ * a `*.stale-claim.*` directory behind. That name is intentionally forensic:
129
+ * it is no longer an active claim and records the interrupted reclaim for the
130
+ * feedback bundle, so no later acquirer should sweep it by a fixed path.
131
+ */
132
+ function recoverAbandonedClaim(claim, dependencies) {
133
+ let entries;
134
+ try {
135
+ entries = fs.readdirSync(claim, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return true;
139
+ }
140
+ for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
141
+ const marker = path.join(claim, entry.name);
142
+ const fields = parseFields(path.join(marker, 'owner'));
143
+ const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
144
+ if (pid !== undefined && dependencies.isProcessAlive(pid))
145
+ return false;
146
+ if (pid === undefined && claimWithinGrace(claim, dependencies))
147
+ return false;
148
+ const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
149
+ try {
150
+ fs.renameSync(marker, abandoned);
151
+ fs.rmSync(abandoned, { recursive: true, force: true });
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ if (entries.length === 0 && claimWithinGrace(claim, dependencies))
158
+ return false;
159
+ removeDirectoryIfEmpty(claim);
160
+ return !fs.existsSync(claim);
161
+ }
162
+ function createAndPublishLock(home, dependencies) {
163
+ const directory = lockPath(home);
164
+ try {
165
+ fs.mkdirSync(directory);
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
171
+ try {
172
+ fs.mkdirSync(marker);
173
+ }
174
+ catch {
175
+ removeDirectoryIfEmpty(directory);
176
+ return false;
177
+ }
178
+ const nonce = `${dependencies.pid}.${dependencies.random()}`;
179
+ const ownerFile = path.join(directory, 'owner');
180
+ const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
181
+ try {
182
+ // Test-only deterministic failure inject (unset in production) — shell
183
+ // parity: the script's write_owner_record honored the same variable.
184
+ if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
185
+ throw new Error('forced owner write failure');
186
+ fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
187
+ fs.renameSync(temporary, ownerFile);
188
+ dependencies.afterOwnerPublish?.(ownerFile);
189
+ const verified = readOwner(directory);
190
+ if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
191
+ throw new Error('owner publish verification failed');
192
+ fs.rmSync(marker, { recursive: true, force: true });
193
+ return true;
194
+ }
195
+ catch {
196
+ // The marker identifies only the directory this process created. Do not
197
+ // recursively remove the fixed lock path after a failed publication.
198
+ fs.rmSync(marker, { recursive: true, force: true });
199
+ removeDirectoryIfEmpty(directory);
200
+ return false;
201
+ }
202
+ }
203
+ function claimAndReclaim(home, dependencies) {
204
+ const directory = lockPath(home);
205
+ const observedGeneration = generation(directory);
206
+ if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
207
+ return false;
208
+ const acquired = acquireClaim(home, observedGeneration, dependencies);
209
+ if (!acquired)
210
+ return false;
211
+ try {
212
+ // Generation fencing: only move exactly the stale generation we observed.
213
+ if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
214
+ return false;
215
+ const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
216
+ fs.renameSync(directory, abandoned);
217
+ fs.rmSync(abandoned, { recursive: true, force: true });
218
+ return createAndPublishLock(home, dependencies);
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ finally {
224
+ fs.rmSync(acquired.claimant, { recursive: true, force: true });
225
+ removeDirectoryIfEmpty(acquired.claim);
226
+ }
227
+ }
228
+ function tryAcquireEmbedLock(home, dependencies) {
229
+ try {
230
+ fs.mkdirSync(lockRoot(home), { recursive: true });
231
+ fs.accessSync(lockRoot(home), fs.constants.W_OK | fs.constants.X_OK);
232
+ }
233
+ catch {
234
+ return 'unavailable';
235
+ }
236
+ return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies)
237
+ ? 'acquired'
238
+ : 'busy';
239
+ }
240
+ export function acquireEmbedLock(home, dependencies) {
241
+ return tryAcquireEmbedLock(home, dependencies) === 'acquired';
242
+ }
243
+ export function releaseEmbedLock(home, dependencies) {
244
+ const directory = lockPath(home);
245
+ if (readOwner(directory)?.pid === dependencies.pid)
246
+ fs.rmSync(directory, { recursive: true, force: true });
247
+ }
248
+ /** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
249
+ export function installEmbedLockCleanup(cleanup, processEvents = process, exit = (code) => process.exit(code)) {
250
+ const onExit = cleanup;
251
+ const onSignal = () => { cleanup(); exit(0); };
252
+ processEvents.once('exit', onExit);
253
+ processEvents.once('SIGINT', onSignal);
254
+ processEvents.once('SIGTERM', onSignal);
255
+ processEvents.once('SIGHUP', onSignal);
256
+ return () => {
257
+ processEvents.removeListener?.('exit', onExit);
258
+ processEvents.removeListener?.('SIGINT', onSignal);
259
+ processEvents.removeListener?.('SIGTERM', onSignal);
260
+ processEvents.removeListener?.('SIGHUP', onSignal);
261
+ };
262
+ }
263
+ /**
264
+ * Run one synchronous embed while owning the shared lock.
265
+ *
266
+ * This deliberately does not install signal handlers. `run` is a synchronous
267
+ * qmd child, so JavaScript cannot execute a cleanup handler until it returns;
268
+ * registering SIGINT/SIGTERM would therefore suppress Node's immediate default
269
+ * termination. An interrupted owner is safely reclaimed through the lock's
270
+ * existing staleness and owner-grace protocol.
271
+ */
272
+ export function runWithEmbedLock(home, dependencies, run) {
273
+ const acquired = tryAcquireEmbedLock(home, dependencies);
274
+ if (acquired === 'unavailable')
275
+ return 'unavailable';
276
+ if (acquired === 'busy')
277
+ return 'busy';
278
+ try {
279
+ run();
280
+ return 'ran';
281
+ }
282
+ finally {
283
+ releaseEmbedLock(home, dependencies);
284
+ }
285
+ }
286
+ //# sourceMappingURL=embed-lock.js.map
@@ -375,6 +375,7 @@ export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | und
375
375
  * An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
376
376
  * changes it turns CI red instead of silently restoring the noise.
377
377
  */
378
+ export declare function resolveQmdHome(env?: NodeJS.ProcessEnv): string;
378
379
  export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv, cwd?: string): string;
379
380
  export type EnsureQmdStoreDirOptions = {
380
381
  /**
@@ -428,13 +428,16 @@ export function lastQmdStoreEnsureOutcome() {
428
428
  * An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
429
429
  * changes it turns CI red instead of silently restoring the noise.
430
430
  */
431
+ export function resolveQmdHome(env = process.env) {
432
+ return env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
433
+ }
431
434
  export function resolveQmdStoreDir(env = process.env, cwd = process.cwd()) {
432
435
  const indexPath = env.INDEX_PATH;
433
436
  // path.resolve leaves an absolute INDEX_PATH untouched and anchors a relative
434
437
  // one to the child cwd, so the probed/stamped directory is the one qmd uses.
435
438
  if (indexPath)
436
439
  return path.dirname(path.resolve(cwd, indexPath));
437
- const home = env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
440
+ const home = resolveQmdHome(env);
438
441
  const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
439
442
  return path.join(cacheDir, 'qmd');
440
443
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.115.1",
3
+ "version": "5.115.2",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -36,7 +36,6 @@
36
36
  "@aws-sdk/client-s3": "^3.1049.0",
37
37
  "@indigoai-us/hq-cloud": "~6.16.41",
38
38
  "@indigoai-us/hq-flags-client": "^0.1.2",
39
- "@indigoai-us/hq-onboarding": "^0.1.0",
40
39
  "@sentry/node": "^10.49.0",
41
40
  "@tobilu/qmd": "2.5.3",
42
41
  "ajv": "^8.20.0",