@indigoai-us/hq-cloud 6.13.1 → 6.13.3

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.
@@ -103,6 +103,12 @@ import * as path from "path";
103
103
  /** Process exit code used when an operation is refused because the lock is held. */
104
104
  export const OPERATION_LOCKED_EXIT = 17;
105
105
 
106
+ /**
107
+ * Process exit code used when an operation is refused because the lock's state
108
+ * directory is not writable (a permission-class fs error, not a live holder).
109
+ */
110
+ export const OPERATION_LOCK_UNWRITABLE_EXIT = 18;
111
+
106
112
  export interface LockInfo {
107
113
  pid: number;
108
114
  command: string;
@@ -128,6 +134,36 @@ export class OperationLockedError extends Error {
128
134
  }
129
135
  }
130
136
 
137
+ /**
138
+ * Thrown by `acquireOperationLock*` when the per-root lock CANNOT BE CREATED
139
+ * because its state directory is not writable — a permission-class fs error
140
+ * (`EPERM` / `EACCES` / `EROFS`) on the lock dir or its temp file, NOT a live
141
+ * holder. Distinct from {@link OperationLockedError}: nothing is holding the
142
+ * lock; this process simply cannot write there — e.g. `~/.hq` owned by another
143
+ * user (created by a past `sudo` run), a macOS privacy/security restriction
144
+ * (which surfaces as `EPERM: operation not permitted`), or a read-only volume.
145
+ * Callers turn this into a clear, actionable message + clean exit rather than a
146
+ * raw uncaught `fs` crash (HQ-CLI-2).
147
+ */
148
+ export class OperationLockUnwritableError extends Error {
149
+ constructor(
150
+ public readonly lockDir: string,
151
+ public readonly cause: NodeJS.ErrnoException,
152
+ ) {
153
+ super(
154
+ `Cannot create the HQ operation lock in "${lockDir}" ` +
155
+ `(${cause.code}: ${cause.message}). That directory is not writable, so ` +
156
+ `HQ can't guard this operation against a concurrent run. Likely causes: ` +
157
+ `it is owned by another user (e.g. created by a past "sudo" command), a ` +
158
+ `macOS privacy/security restriction, or a read-only volume. To fix: make ` +
159
+ `it writable (e.g. "sudo chown -R $(whoami) ~/.hq"), or point HQ at a ` +
160
+ `writable state directory with HQ_STATE_DIR=<dir>. To bypass the lock for ` +
161
+ `a single run (drops concurrency protection), set HQ_DISABLE_OP_LOCK=1.`,
162
+ );
163
+ this.name = "OperationLockUnwritableError";
164
+ }
165
+ }
166
+
131
167
  export interface LockHandle {
132
168
  /** Absolute path of the lock file. */
133
169
  readonly path: string;
@@ -363,10 +399,42 @@ function disabledHandle(hqRoot: string, command: string): LockHandle {
363
399
  return { ...NOOP_HANDLE_BASE, path: "", info };
364
400
  }
365
401
 
402
+ /**
403
+ * Permission-class fs error codes that mean the lock's state directory itself is
404
+ * unusable for THIS process (not a transient/EEXIST race). macOS surfaces the
405
+ * restricted-location case as `EPERM`; Linux as `EACCES`; a read-only mount as
406
+ * `EROFS`.
407
+ */
408
+ const UNWRITABLE_LOCK_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
409
+
410
+ /**
411
+ * If `err` is a permission-class fs error against the lock dir, rethrow it as an
412
+ * actionable {@link OperationLockUnwritableError}; otherwise rethrow it
413
+ * unchanged. Always throws (return type `never`).
414
+ *
415
+ * Exported for unit testing: ESM module namespaces can't be spied, so the
416
+ * classification decision is verified directly here rather than by mocking
417
+ * `fs.openSync` (see operation-lock.test.ts, HQ-CLI-2).
418
+ */
419
+ export function rethrowLockCreateError(err: unknown, lockDir: string): never {
420
+ const e = err as NodeJS.ErrnoException | null;
421
+ if (e && typeof e.code === "string" && UNWRITABLE_LOCK_CODES.has(e.code)) {
422
+ throw new OperationLockUnwritableError(lockDir, e);
423
+ }
424
+ throw err;
425
+ }
426
+
366
427
  /** Build the lock payload + ensure the locks dir exists. */
367
428
  function prepareLock(hqRoot: string, command: string): { p: string; info: LockInfo; payload: string } {
368
429
  const p = lockPathFor(hqRoot);
369
- fs.mkdirSync(path.dirname(p), { recursive: true });
430
+ const dir = path.dirname(p);
431
+ try {
432
+ fs.mkdirSync(dir, { recursive: true });
433
+ } catch (err) {
434
+ // The state dir (e.g. ~/.hq) can't be created — surface it as an actionable
435
+ // unwritable-lock error rather than a raw mkdir crash.
436
+ rethrowLockCreateError(err, dir);
437
+ }
370
438
  const info: LockInfo = {
371
439
  pid: process.pid,
372
440
  command,
@@ -381,7 +449,15 @@ function writeLockTemp(dir: string, payload: string): string {
381
449
  dir,
382
450
  `.operation-lock.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.tmp`,
383
451
  );
384
- const fd = fs.openSync(tmp, "wx", 0o600);
452
+ let fd: number;
453
+ try {
454
+ fd = fs.openSync(tmp, "wx", 0o600);
455
+ } catch (err) {
456
+ // The locks dir isn't writable (e.g. macOS EPERM / Linux EACCES on a
457
+ // root-owned or restricted ~/.hq) — surface an actionable error, not a raw
458
+ // `EPERM: operation not permitted, open` crash (HQ-CLI-2).
459
+ rethrowLockCreateError(err, dir);
460
+ }
385
461
  let closed = false;
386
462
  try {
387
463
  fs.writeSync(fd, payload);