@indigoai-us/hq-cli 5.345.148 → 5.345.149

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 (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -0
  3. package/dist/command-catalog.generated.d.ts +44 -8
  4. package/dist/command-catalog.generated.js +53 -8
  5. package/dist/command-help.js +2 -1
  6. package/dist/commands/access.d.ts +45 -2
  7. package/dist/commands/access.js +537 -223
  8. package/dist/commands/billing-usage.d.ts +33 -2
  9. package/dist/commands/billing-usage.js +75 -4
  10. package/dist/commands/bot.js +2 -2
  11. package/dist/commands/channels.d.ts +27 -0
  12. package/dist/commands/channels.js +108 -4
  13. package/dist/commands/core-refresh-vault-access-native.d.ts +16 -0
  14. package/dist/commands/core-refresh-vault-access-native.js +142 -9
  15. package/dist/commands/core.js +4 -1
  16. package/dist/commands/dm.js +9 -1
  17. package/dist/commands/install-global.d.ts +1 -8
  18. package/dist/commands/install-global.js +11 -27
  19. package/dist/commands/library-add.d.ts +31 -1
  20. package/dist/commands/library-add.js +69 -2
  21. package/dist/commands/library.js +37 -3
  22. package/dist/commands/reindex.js +19 -0
  23. package/dist/hook-engine-path.js +10 -3
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.js +70 -5
  26. package/dist/lazy-commands.js +2 -0
  27. package/dist/lib/billing/usage-cost.d.ts +31 -1
  28. package/dist/lib/billing/usage-cost.js +46 -0
  29. package/dist/lib/db/local.d.ts +2 -2
  30. package/dist/lib/db/local.js +2 -2
  31. package/dist/lib/db/sql.js +2 -2
  32. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  33. package/dist/lib/db/sqlite-binding.js +168 -0
  34. package/dist/lib/doctor/checks/anywhere.d.ts +12 -0
  35. package/dist/lib/doctor/checks/anywhere.js +63 -12
  36. package/dist/lib/doctor/checks/cli-path.d.ts +2 -1
  37. package/dist/lib/doctor/checks/cli-path.js +2 -1
  38. package/dist/lib/doctor/checks/hook-engine.js +2 -1
  39. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  40. package/dist/lib/hook-engine/reconcile.js +134 -0
  41. package/dist/lib/hook-engine/registration.d.ts +17 -0
  42. package/dist/lib/hook-engine/registration.js +50 -0
  43. package/dist/lib/install/claude-targets.d.ts +2 -1
  44. package/dist/lib/install/claude-targets.js +8 -3
  45. package/dist/lib/install/codex-targets.d.ts +3 -0
  46. package/dist/lib/install/codex-targets.js +15 -4
  47. package/dist/lib/install/hook-target.d.ts +9 -0
  48. package/dist/lib/install/hook-target.js +39 -0
  49. package/dist/lib/windows/console-encoding.d.ts +24 -0
  50. package/dist/lib/windows/console-encoding.js +83 -0
  51. package/dist/lib/windows/install-followup.d.ts +35 -0
  52. package/dist/lib/windows/install-followup.js +142 -0
  53. package/dist/lib/windows/user-path.d.ts +80 -3
  54. package/dist/lib/windows/user-path.js +187 -26
  55. package/dist/main.js +2 -1
  56. package/dist/unknown-command.d.ts +18 -0
  57. package/dist/unknown-command.js +88 -0
  58. package/dist/utils/cognito-session.d.ts +8 -0
  59. package/dist/utils/cognito-session.js +14 -2
  60. package/dist/utils/hook-trust.d.ts +9 -0
  61. package/dist/utils/hook-trust.js +30 -2
  62. package/dist/utils/qmd-native-binding-error.js +3 -1
  63. package/dist/utils/resolve-executable.d.ts +2 -0
  64. package/dist/utils/resolve-executable.js +6 -2
  65. package/package.json +8 -8
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `hq access <path-or-query>` — existence + ACL probe with a self-healing
2
+ * `hq access <path-or-query...>` — existence + ACL probe with a self-healing
3
3
  * ladder: resolve, list, probe ACL, then (with --fix) download / pin / repair
4
4
  * sync or request access from the grantor.
5
5
  *
@@ -85,6 +85,10 @@ function normalizeTarget(raw, slug) {
85
85
  const anchor = `companies/${slug}/`;
86
86
  if (t.startsWith(anchor))
87
87
  t = t.slice(anchor.length);
88
+ // `folder`, `folder/` and `folder/*` name the same folder for the ACL
89
+ // resolver (policy hq-authorization-path-canonicalization); use one form for
90
+ // listing and ACL lookups.
91
+ t = t.replace(/\/\*$/, "").replace(/\/+$/, "");
88
92
  return t;
89
93
  }
90
94
  function fileGrantCoversPath(grantPath, targetPath) {
@@ -123,41 +127,6 @@ function httpStatusOf(err) {
123
127
  }
124
128
  return undefined;
125
129
  }
126
- /**
127
- * List under `prefix`. A 403 from the list route (`/v1/files/list`) means the
128
- * caller cannot enumerate the prefix — surfaced as `{ denied: true }` so the
129
- * caller maps it to outcome `no-access` (exit 3) instead of a bare error.
130
- */
131
- async function listKeys(s3, bucket, prefix) {
132
- try {
133
- return { keys: await listKeysOrThrow(s3, bucket, prefix), denied: false };
134
- }
135
- catch (err) {
136
- if (httpStatusOf(err) === 403)
137
- return { keys: [], denied: true };
138
- throw err;
139
- }
140
- }
141
- async function listKeysOrThrow(s3, bucket, prefix) {
142
- const keys = [];
143
- let continuationToken;
144
- do {
145
- const resp = (await s3.send(new ListObjectsV2Command({
146
- Bucket: bucket,
147
- Prefix: prefix,
148
- ContinuationToken: continuationToken,
149
- })));
150
- for (const obj of resp.Contents ?? []) {
151
- if (!obj.Key)
152
- continue;
153
- if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
154
- continue;
155
- keys.push(obj.Key);
156
- }
157
- continuationToken = resp.NextContinuationToken ?? undefined;
158
- } while (continuationToken);
159
- return keys;
160
- }
161
130
  function treeIsEmpty(tree) {
162
131
  const noDirect = tree.directRow == null;
163
132
  const noInherited = !tree.inherited || tree.inherited.length === 0;
@@ -233,19 +202,15 @@ function grantorLabel(g) {
233
202
  const name = g.name ? `${g.name} ` : "";
234
203
  return `${name}${g.email}`.trim();
235
204
  }
236
- export async function runAccess(input) {
237
- const { deps, json } = input;
238
- const log = deps.log ?? ((line) => console.log(line));
239
- const stderr = deps.stderr ?? ((line) => console.error(line));
240
- const slug = deps.companySlug;
241
- const steps = [];
242
- const relative = normalizeTarget(input.target, slug);
243
- const anchored = (key) => `companies/${slug}/${key}`;
244
- const finishCtx = { hqRoot: deps.hqRoot, now: deps.now, stderr };
245
- let vendDenied = false;
205
+ /**
206
+ * One STS vend for the company. A 403 is a company-boundary denial, a 422
207
+ * write-scope truncation still allows presigned reads, a 404 is reported and
208
+ * tolerated; anything else is thrown.
209
+ */
210
+ async function vendForAccess(deps) {
246
211
  try {
247
212
  await deps.vaultClient.sts.vend({ companyUid: deps.companyUid });
248
- steps.push("vend: ok");
213
+ return { denied: false, step: "vend: ok" };
249
214
  }
250
215
  catch (err) {
251
216
  const status = httpStatusOf(err);
@@ -253,29 +218,98 @@ export async function runAccess(input) {
253
218
  ? err
254
219
  : undefined;
255
220
  if (status === 403) {
256
- vendDenied = true;
257
- steps.push("vend: 403 (no-access)");
221
+ return { denied: true, step: "vend: 403 (no-access)" };
258
222
  }
259
- else if ((status === 422 || vendError?.statusCode === 422) &&
223
+ if ((status === 422 || vendError?.statusCode === 422) &&
260
224
  vendError?.code === "POLICY_WRITE_SCOPE_TRUNCATED") {
261
225
  // This failure means the company-wide STS write policy could not fit.
262
226
  // Company reads use the separate ACL-filtered presign API, which remains
263
227
  // the authority for listing and materializing this particular object.
264
- steps.push("vend: write policy truncated; using presigned read");
265
- }
266
- else if (status === 404) {
267
- steps.push("vend: 404");
228
+ return { denied: false, step: "vend: write policy truncated; using presigned read" };
268
229
  }
269
- else {
270
- throw err;
230
+ if (status === 404) {
231
+ return { denied: false, step: "vend: 404" };
271
232
  }
233
+ throw err;
272
234
  }
273
- const entity = await deps.vaultClient.entity.findInMyNamespace("company", slug);
274
- const bucket = entity?.bucketName ?? `hq-vault-cmp-${slug}`;
275
- const s3 = deps.companyClient({ companyUid: deps.companyUid });
235
+ }
236
+ /**
237
+ * Mark a promise that runs ahead of its use. A rejection is not reported as
238
+ * unhandled; it still surfaces at the point the promise is awaited.
239
+ */
240
+ function startEarly(promise) {
241
+ promise.catch(() => undefined);
242
+ return promise;
243
+ }
244
+ function verdictDenied(verdict) {
245
+ return verdict.kind === "tree" ? aclDenied(verdict.tree) : false;
246
+ }
247
+ /** Bucket name for the company: the entity's bucket, else the conventional name. */
248
+ function bucketFor(entity, slug) {
249
+ return entity?.bucketName ?? `hq-vault-cmp-${slug}`;
250
+ }
251
+ /**
252
+ * One list request under `prefix`, never followed by a continuation. A 403
253
+ * from the list route (`/v1/files/list`) means the caller cannot enumerate
254
+ * the prefix, surfaced as `{ denied: true }` so the caller maps it to outcome
255
+ * `no-access` (exit 3) instead of a bare error.
256
+ */
257
+ async function listPage(s3, bucket, prefix) {
258
+ let resp;
259
+ try {
260
+ resp = (await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix })));
261
+ }
262
+ catch (err) {
263
+ if (httpStatusOf(err) === 403)
264
+ return { keys: [], truncated: false, denied: true };
265
+ throw err;
266
+ }
267
+ const keys = [];
268
+ for (const obj of resp.Contents ?? []) {
269
+ if (!obj.Key)
270
+ continue;
271
+ if (obj.Key.endsWith("/") && (obj.Size ?? 0) === 0)
272
+ continue;
273
+ keys.push(obj.Key);
274
+ }
275
+ return {
276
+ keys,
277
+ truncated: Boolean(resp.IsTruncated || resp.NextContinuationToken),
278
+ denied: false,
279
+ };
280
+ }
281
+ /**
282
+ * Fuzzy-match pool for a miss: one page under the closest parent folder that
283
+ * has keys, walking up from the deepest. A bare query with no folder part has
284
+ * only the bucket root as its parent, so it gets one root page. A denied
285
+ * parent stops the walk and is reported as denied.
286
+ */
287
+ async function listClosestParent(s3, bucket, relative) {
288
+ const segments = relative.split("/");
289
+ if (segments.length === 1)
290
+ return listPage(s3, bucket, "");
291
+ for (let depth = segments.length - 1; depth >= 1; depth -= 1) {
292
+ const parent = `${segments.slice(0, depth).join("/")}/`;
293
+ const page = await listPage(s3, bucket, parent);
294
+ if (page.denied || page.keys.length > 0)
295
+ return page;
296
+ }
297
+ return { keys: [], truncated: false, denied: false };
298
+ }
299
+ /**
300
+ * Existence + ACL for one target: exact key first, then a folder check, then a
301
+ * bounded fuzzy match. Every list call is a single page.
302
+ */
303
+ async function locateTarget(target, deps, ctx) {
304
+ const slug = deps.companySlug;
305
+ const steps = [ctx.vend.step];
306
+ const relative = normalizeTarget(target, slug);
307
+ const anchored = (key) => `companies/${slug}/${key}`;
308
+ const { s3, bucket } = ctx;
276
309
  let listDenied = false;
310
+ let directory = false;
277
311
  let key;
278
- if (vendDenied) {
312
+ if (ctx.vend.denied) {
279
313
  // The caller is denied at the company boundary. Do not use list results to
280
314
  // resolve or disclose matching keys, even if a separate listing succeeds.
281
315
  listDenied = true;
@@ -283,8 +317,24 @@ export async function runAccess(input) {
283
317
  steps.push("list: skipped after vend 403 (no-access)");
284
318
  }
285
319
  else {
286
- const prefixList = await listKeys(s3, bucket, relative);
287
- const exact = prefixList.keys.find((k) => k === relative);
320
+ const prefixList = await listPage(s3, bucket, relative);
321
+ // An exact key sorts first among the keys that start with it, so the
322
+ // first page always holds it when it exists.
323
+ const exact = prefixList.keys.includes(relative);
324
+ const folderPrefix = `${relative}/`;
325
+ let isFolder = !exact &&
326
+ relative !== "" &&
327
+ prefixList.keys.some((k) => k.startsWith(folderPrefix));
328
+ if (!prefixList.denied &&
329
+ !exact &&
330
+ !isFolder &&
331
+ relative !== "" &&
332
+ prefixList.truncated) {
333
+ // Siblings such as `name-other/` sort before `name/` and can fill the
334
+ // first page; ask for the folder itself.
335
+ const folderList = await listPage(s3, bucket, folderPrefix);
336
+ isFolder = !folderList.denied && folderList.keys.length > 0;
337
+ }
288
338
  if (prefixList.denied) {
289
339
  // The server refused to enumerate — the caller has no read on this
290
340
  // prefix. Keep the target as the key so the request rung can still name
@@ -294,45 +344,58 @@ export async function runAccess(input) {
294
344
  steps.push("list: 403 (no-access)");
295
345
  }
296
346
  else if (exact) {
297
- key = exact;
347
+ key = relative;
298
348
  steps.push("resolve: exact key");
299
349
  steps.push("list: 1 hit");
300
350
  }
351
+ else if (isFolder) {
352
+ key = relative;
353
+ directory = true;
354
+ steps.push("resolve: folder");
355
+ steps.push("list: 1 hit");
356
+ }
301
357
  else {
302
- const allList = await listKeys(s3, bucket, "");
303
358
  const q = relative.toLowerCase();
304
- // A root-list 403 only says the caller cannot enumerate the WHOLE bucket.
305
- // The prefix list above succeeded, so fuzzy-match within it first; the
306
- // root denial is the verdict only when the readable prefix has no match.
307
- const pool = allList.denied ? prefixList.keys : allList.keys;
359
+ // Keys under the target prefix already contain the query. Only when
360
+ // there are none, look one page under the closest existing parent.
361
+ const parentList = prefixList.keys.length > 0
362
+ ? { keys: [], truncated: false, denied: false }
363
+ : await listClosestParent(s3, bucket, relative);
364
+ const pool = [
365
+ ...new Set([...prefixList.keys, ...parentList.keys]),
366
+ ].sort();
308
367
  const fuzzy = pool.filter((k) => k.toLowerCase().includes(q));
309
- if (allList.denied && fuzzy.length === 0) {
368
+ if (parentList.denied && fuzzy.length === 0) {
310
369
  listDenied = true;
311
370
  key = relative;
312
371
  steps.push("list: 403 (no-access)");
313
372
  }
314
373
  else if (fuzzy.length === 0) {
315
374
  steps.push("list: 0 hits");
316
- const tree = await deps.fetchAclTree(relative);
317
- if (aclDenied(tree)) {
375
+ const verdict = await ctx.aclFor(relative);
376
+ if (verdictDenied(verdict)) {
318
377
  steps.push("acl: deny");
319
- const result = finish("no-access", {
320
- path: anchored(relative) || input.target,
378
+ return {
379
+ kind: "terminal",
380
+ outcome: "no-access",
381
+ fields: {
382
+ path: anchored(relative) || target,
383
+ company: slug,
384
+ exists: true,
385
+ steps,
386
+ },
387
+ };
388
+ }
389
+ return {
390
+ kind: "terminal",
391
+ outcome: "never-existed",
392
+ fields: {
393
+ path: anchored(relative) || target,
321
394
  company: slug,
322
- exists: true,
395
+ exists: false,
323
396
  steps,
324
- }, finishCtx);
325
- emit(input, result, log);
326
- return result;
327
- }
328
- const result = finish("never-existed", {
329
- path: anchored(relative) || input.target,
330
- company: slug,
331
- exists: false,
332
- steps,
333
- }, finishCtx);
334
- emit(input, result, log);
335
- return result;
397
+ },
398
+ };
336
399
  }
337
400
  else if (fuzzy.length === 1) {
338
401
  key = fuzzy[0];
@@ -343,42 +406,70 @@ export async function runAccess(input) {
343
406
  const candidates = fuzzy.map(anchored);
344
407
  steps.push(`list: ${fuzzy.length} hits`);
345
408
  let picked = null;
346
- if (deps.pick) {
409
+ if (ctx.allowPick && deps.pick) {
347
410
  picked = await deps.pick(candidates);
348
411
  }
349
412
  if (!picked) {
350
- const result = finish("ambiguous", {
351
- path: input.target,
352
- company: slug,
353
- exists: false,
354
- candidates,
355
- steps,
356
- }, finishCtx);
357
- emit(input, result, log);
358
- return result;
413
+ return {
414
+ kind: "terminal",
415
+ outcome: "ambiguous",
416
+ fields: {
417
+ path: target,
418
+ company: slug,
419
+ exists: false,
420
+ candidates,
421
+ steps,
422
+ },
423
+ };
359
424
  }
360
- const stripped = normalizeTarget(picked, slug);
361
- key = stripped;
425
+ key = normalizeTarget(picked, slug);
362
426
  steps.push("resolve: pick");
363
427
  }
364
428
  }
365
429
  }
366
430
  const resolvedKey = key;
367
- const resolvedPath = anchored(resolvedKey);
368
- const tree = await deps.fetchAclTree(resolvedKey);
431
+ const verdict = await ctx.aclFor(resolvedKey);
432
+ return {
433
+ kind: "found",
434
+ resolvedKey,
435
+ directory,
436
+ vendDenied: ctx.vend.denied,
437
+ listDenied,
438
+ verdict,
439
+ steps,
440
+ };
441
+ }
442
+ async function resolveRequester(requester) {
443
+ return typeof requester === "function" ? requester() : requester;
444
+ }
445
+ /**
446
+ * Disk check, self-heal and request rung for one located target, then the
447
+ * single telemetry record via `finish`.
448
+ */
449
+ async function completeTarget(input, deps, located) {
450
+ const { json } = input;
451
+ const log = deps.log ?? ((line) => console.log(line));
452
+ const stderr = deps.stderr ?? ((line) => console.error(line));
453
+ const slug = deps.companySlug;
454
+ const finishCtx = { hqRoot: deps.hqRoot, now: deps.now, stderr };
455
+ if (located.kind === "terminal") {
456
+ return finish(located.outcome, located.fields, finishCtx);
457
+ }
458
+ const { resolvedKey, directory, vendDenied, listDenied, verdict, steps } = located;
459
+ const resolvedPath = `companies/${slug}/${resolvedKey}`;
369
460
  let noAccess = vendDenied || listDenied;
370
- if (tree == null || treeIsEmpty(tree)) {
461
+ if (verdict.kind === "resolved") {
462
+ steps.push(`acl: ${verdict.permission}`);
463
+ }
464
+ else if (verdict.tree == null || treeIsEmpty(verdict.tree)) {
371
465
  steps.push("acl: no record (membership-distributed)");
372
466
  }
373
- else if (aclDenied(tree)) {
467
+ else if (aclDenied(verdict.tree)) {
374
468
  noAccess = true;
375
469
  steps.push("acl: deny");
376
470
  }
377
471
  else {
378
- steps.push(`acl: ${tree.effectivePermission ?? "ok"}`);
379
- }
380
- if (vendDenied || listDenied) {
381
- noAccess = true;
472
+ steps.push(`acl: ${verdict.tree.effectivePermission ?? "ok"}`);
382
473
  }
383
474
  const onDisk = fs.existsSync(path.join(deps.hqRoot, "companies", slug, resolvedKey));
384
475
  steps.push(onDisk ? "disk: present" : "disk: missing");
@@ -394,7 +485,11 @@ export async function runAccess(input) {
394
485
  }
395
486
  let fixed;
396
487
  let localPath;
397
- if (outcome === "not-synced" && input.fix === true && !noAccess) {
488
+ if (outcome === "not-synced" && input.fix === true && directory) {
489
+ // `get` fetches one object; a folder is pulled by sync.
490
+ steps.push("get: skipped (folder; run hq sync)");
491
+ }
492
+ else if (outcome === "not-synced" && input.fix === true && !noAccess) {
398
493
  const absLocal = path.join(deps.hqRoot, "companies", slug, resolvedKey);
399
494
  const getFn = deps.get ??
400
495
  ((anchoredPath) => runGet({
@@ -492,8 +587,9 @@ export async function runAccess(input) {
492
587
  steps.push("request: no dm transport");
493
588
  }
494
589
  else {
590
+ const grantorTree = verdict.kind === "tree" ? verdict.tree : await deps.fetchAclTree(resolvedKey);
495
591
  const members = await deps.listMembers();
496
- const resolved = resolveGrantor(tree, members);
592
+ const resolved = resolveGrantor(grantorTree, members);
497
593
  if (!resolved) {
498
594
  steps.push("request: no grantor found");
499
595
  requestNote = "No grantor found. Contact a company owner.";
@@ -508,8 +604,9 @@ export async function runAccess(input) {
508
604
  else
509
605
  log(grantorLine);
510
606
  const nowMs = (deps.now ?? Date.now)();
607
+ const requester = await resolveRequester(deps.requester);
511
608
  const recent = findRecentAccessRequest(deps.hqRoot, {
512
- requester: deps.requester.email,
609
+ requester: requester.email,
513
610
  prefix: resolvedKey,
514
611
  company: slug,
515
612
  grantor: resolved.email,
@@ -546,8 +643,8 @@ export async function runAccess(input) {
546
643
  }
547
644
  }
548
645
  if (approved && outcome === "no-access") {
549
- const message = `${deps.requester.email} is asking for read access to ${resolvedKey} in ${slug}.`;
550
- const prompt = `hq files share ${resolvedKey} --with ${deps.requester.email} --permission read --company ${slug}`;
646
+ const message = `${requester.email} is asking for read access to ${resolvedKey} in ${slug}.`;
647
+ const prompt = `hq files share ${resolvedKey} --with ${requester.email} --permission read --company ${slug}`;
551
648
  const sent = await deps.dm.send({
552
649
  recipient: resolved.email,
553
650
  message,
@@ -557,7 +654,7 @@ export async function runAccess(input) {
557
654
  requestSentAt = sentAt;
558
655
  try {
559
656
  recordAccessRequest(deps.hqRoot, {
560
- requester: deps.requester.email,
657
+ requester: requester.email,
561
658
  prefix: resolvedKey,
562
659
  company: slug,
563
660
  grantor: resolved.email,
@@ -591,31 +688,157 @@ export async function runAccess(input) {
591
688
  ...(alreadyAskedAt ? { alreadyAskedAt } : {}),
592
689
  ...(requestNote ? { requestNote } : {}),
593
690
  }, finishCtx);
691
+ return result;
692
+ }
693
+ export async function runAccess(input) {
694
+ const { deps } = input;
695
+ const log = deps.log ?? ((line) => console.log(line));
696
+ const slug = deps.companySlug;
697
+ const relative = normalizeTarget(input.target, slug);
698
+ // Vend, the bucket lookup, and the ACL record for the target as typed do not
699
+ // depend on each other; start them together.
700
+ const vendP = startEarly(vendForAccess(deps));
701
+ const entityP = startEarly(deps.vaultClient.entity.findInMyNamespace("company", slug));
702
+ // acl/resolve answers a granted permission in a fraction of the time
703
+ // acl/tree takes. The tree is only needed when resolve has no permission
704
+ // (deny or no record), so without resolve it starts early as before, and
705
+ // with resolve it is fetched on demand.
706
+ // A failed resolve call falls back to the tree rather than failing the check.
707
+ const resolvedP = deps.resolveAcl
708
+ ? deps.resolveAcl([relative]).catch(() => new Map())
709
+ : null;
710
+ const trees = new Map();
711
+ const treeFor = (key) => {
712
+ let p = trees.get(key);
713
+ if (!p) {
714
+ p = deps.fetchAclTree(key);
715
+ trees.set(key, p);
716
+ }
717
+ return p;
718
+ };
719
+ if (!resolvedP)
720
+ startEarly(treeFor(relative));
721
+ const vend = await vendP;
722
+ const entity = await entityP;
723
+ const ctx = {
724
+ vend,
725
+ bucket: bucketFor(entity, slug),
726
+ s3: deps.companyClient({ companyUid: deps.companyUid }),
727
+ aclFor: async (key) => {
728
+ if (resolvedP && key === relative) {
729
+ const permission = (await resolvedP).get(key);
730
+ if (permission)
731
+ return { kind: "resolved", permission };
732
+ }
733
+ return { kind: "tree", tree: await treeFor(key) };
734
+ },
735
+ allowPick: true,
736
+ };
737
+ const located = await locateTarget(input.target, deps, ctx);
738
+ const result = await completeTarget(input, deps, located);
594
739
  emit(input, result, log);
595
740
  return result;
596
741
  }
742
+ async function mapLimit(items, limit, fn) {
743
+ const out = new Array(items.length);
744
+ let next = 0;
745
+ const worker = async () => {
746
+ while (next < items.length) {
747
+ const i = next;
748
+ next += 1;
749
+ out[i] = await fn(items[i]);
750
+ }
751
+ };
752
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
753
+ return out;
754
+ }
755
+ /**
756
+ * Several targets in one company: one vend, one bucket lookup, and one
757
+ * acl/resolve call for all of them, then bounded-concurrency existence
758
+ * checks. Requests (DM rung) run one at a time in input order so prompts and
759
+ * grantor lines never interleave. Several fuzzy hits are reported as
760
+ * ambiguous; the interactive picker is single-path only.
761
+ */
762
+ export async function runAccessBatch(input) {
763
+ const { deps } = input;
764
+ const log = deps.log ?? ((line) => console.log(line));
765
+ const slug = deps.companySlug;
766
+ const relatives = input.targets.map((t) => normalizeTarget(t, slug));
767
+ const unique = [...new Set(relatives)];
768
+ const vendP = startEarly(vendForAccess(deps));
769
+ const entityP = startEarly(deps.vaultClient.entity.findInMyNamespace("company", slug));
770
+ const resolvedP = startEarly(deps.resolveAcl
771
+ ? deps.resolveAcl(unique).catch(() => new Map())
772
+ : Promise.resolve(new Map()));
773
+ const vend = await vendP;
774
+ const entity = await entityP;
775
+ const permissions = await resolvedP;
776
+ const trees = new Map();
777
+ const treeFor = (key) => {
778
+ let p = trees.get(key);
779
+ if (!p) {
780
+ p = deps.fetchAclTree(key);
781
+ trees.set(key, p);
782
+ }
783
+ return p;
784
+ };
785
+ const ctx = {
786
+ vend,
787
+ bucket: bucketFor(entity, slug),
788
+ s3: deps.companyClient({ companyUid: deps.companyUid }),
789
+ aclFor: async (key) => {
790
+ const permission = permissions.get(key);
791
+ // A null permission can mean an explicit deny or no ACL record at all;
792
+ // only the tree tells those apart, so it decides as it does for a
793
+ // single path.
794
+ if (permission)
795
+ return { kind: "resolved", permission };
796
+ return { kind: "tree", tree: await treeFor(key) };
797
+ },
798
+ allowPick: false,
799
+ };
800
+ const located = await mapLimit(input.targets, input.concurrency ?? 4, (target) => locateTarget(target, deps, ctx));
801
+ const results = [];
802
+ for (let i = 0; i < input.targets.length; i += 1) {
803
+ const target = input.targets[i];
804
+ if (!input.json)
805
+ log(`==> ${target} <==`);
806
+ const result = await completeTarget(input, deps, located[i]);
807
+ if (!input.json)
808
+ log(formatAccessOutcome(result));
809
+ results.push({ target, result });
810
+ }
811
+ if (input.json) {
812
+ log(JSON.stringify(results.map(({ target, result }) => ({ target, ...jsonPayload(result) })), null, 2));
813
+ }
814
+ const exitCode = results.reduce((max, { result }) => (result.exitCode > max ? result.exitCode : max), 0);
815
+ return { results, exitCode };
816
+ }
817
+ function jsonPayload(result) {
818
+ const payload = {
819
+ outcome: result.outcome,
820
+ path: result.path,
821
+ company: result.company,
822
+ ...(result.exists !== undefined ? { exists: result.exists } : {}),
823
+ steps: result.steps,
824
+ };
825
+ if (result.grantor)
826
+ payload.grantor = result.grantor;
827
+ if (result.candidates)
828
+ payload.candidates = result.candidates;
829
+ if (result.fixed)
830
+ payload.fixed = result.fixed;
831
+ if (result.localPath)
832
+ payload.localPath = result.localPath;
833
+ if (result.requestSentAt)
834
+ payload.requestSentAt = result.requestSentAt;
835
+ if (result.alreadyAskedAt)
836
+ payload.alreadyAskedAt = result.alreadyAskedAt;
837
+ return payload;
838
+ }
597
839
  function emit(input, result, log) {
598
840
  if (input.json) {
599
- const payload = {
600
- outcome: result.outcome,
601
- path: result.path,
602
- company: result.company,
603
- ...(result.exists !== undefined ? { exists: result.exists } : {}),
604
- steps: result.steps,
605
- };
606
- if (result.grantor)
607
- payload.grantor = result.grantor;
608
- if (result.candidates)
609
- payload.candidates = result.candidates;
610
- if (result.fixed)
611
- payload.fixed = result.fixed;
612
- if (result.localPath)
613
- payload.localPath = result.localPath;
614
- if (result.requestSentAt)
615
- payload.requestSentAt = result.requestSentAt;
616
- if (result.alreadyAskedAt)
617
- payload.alreadyAskedAt = result.alreadyAskedAt;
618
- log(JSON.stringify(payload, null, 2));
841
+ log(JSON.stringify(jsonPayload(result), null, 2));
619
842
  }
620
843
  else {
621
844
  log(formatAccessOutcome(result));
@@ -674,11 +897,12 @@ function treeFromBody(body) {
674
897
  }
675
898
  export function registerAccessCommand(program) {
676
899
  program
677
- .command("access <path-or-query>")
678
- .description("Tell whether a vault file exists and whether you can read it. Never a bare not-found. Exit codes: 0 local/not-synced, 2 never-existed, 3 no-access, 4 ambiguous.")
900
+ .command("access [vault-path...]")
901
+ .description("Tell whether a vault file exists and whether you can read it. Never a bare not-found. Takes one or more paths or queries. Exit codes: 0 local/not-synced, 2 never-existed, 3 no-access, 4 ambiguous.")
679
902
  .option("--company <slug>", "Company slug")
680
903
  .option("--as <person>", "Check a person's access (email, prs_, or agt_ uid; self or owner/admin)")
681
- .option("--json", "Print a single JSON object")
904
+ .option("--json", "Print a single JSON object (an array for several paths)")
905
+ .option("--stdin", "Read more paths from stdin, one per line")
682
906
  .option("--no-fix", "Diagnose only: do not fetch, pin, or repair sync")
683
907
  .option("--yes", "Answer the one send-confirmation yes (hosts must pass this explicitly)")
684
908
  .option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
@@ -688,17 +912,34 @@ Exit codes:
688
912
  2 never-existed (the path was never created)
689
913
  3 no-access (the file exists but you cannot read it)
690
914
  4 ambiguous (several keys matched; pick one)
915
+
916
+ With several paths, each gets its own result block and the exit code is the
917
+ highest one across the paths.
691
918
  `)
692
- .action(async (target, opts) => {
919
+ .action(async (paths, opts) => {
693
920
  try {
921
+ const targets = [...paths];
922
+ if (opts.stdin)
923
+ targets.push(...parseTargetLines(await readStdin()));
924
+ if (targets.length === 0) {
925
+ throw new Error("Pass at least one path, or --stdin with one path per line.");
926
+ }
694
927
  if (opts.as) {
695
- const result = await runAccessForPerson(target, opts.as, opts);
928
+ if (targets.length !== 1) {
929
+ throw new Error("--as checks one path at a time.");
930
+ }
931
+ const result = await runAccessForPerson(targets[0], opts.as, opts);
696
932
  console.log(opts.json ? JSON.stringify(result) : formatAccessForPerson(result));
697
933
  process.exitCode = result.exitCode;
698
934
  return;
699
935
  }
700
- const result = await runAccessForPath(target, opts);
701
- process.exitCode = result.exitCode;
936
+ if (targets.length === 1 && !opts.stdin) {
937
+ const result = await runAccessForPath(targets[0], opts);
938
+ process.exitCode = result.exitCode;
939
+ return;
940
+ }
941
+ const batch = await runAccessForPaths(targets, opts);
942
+ process.exitCode = batch.exitCode;
702
943
  }
703
944
  catch (err) {
704
945
  const message = err instanceof Error ? err.message : String(err);
@@ -707,6 +948,20 @@ Exit codes:
707
948
  }
708
949
  });
709
950
  }
951
+ /** One target per non-blank line; surrounding whitespace is ignored. */
952
+ export function parseTargetLines(text) {
953
+ return text
954
+ .split(/\r?\n/)
955
+ .map((line) => line.trim())
956
+ .filter((line) => line.length > 0);
957
+ }
958
+ async function readStdin() {
959
+ const chunks = [];
960
+ for await (const chunk of process.stdin) {
961
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
962
+ }
963
+ return Buffer.concat(chunks).toString("utf-8");
964
+ }
710
965
  /**
711
966
  * Resolve the company `hq access` runs against: explicit `company` (the
712
967
  * ladder forwards the slug stamped on a 403), else the slug anchored in the
@@ -798,15 +1053,14 @@ export async function runAccessForPerson(rawPath, personUid, opts) {
798
1053
  export function formatAccessForPerson(result) {
799
1054
  return `${result.personUid} has ${result.permission ?? "no access"} access to ${result.path}.`;
800
1055
  }
801
- export async function runAccessForPath(path, opts) {
802
- const target = path;
803
- const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
804
- const slug = resolveAccessCompany(target, opts);
805
- if (!slug) {
806
- throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
807
- }
1056
+ /** Live deps for one company: vault session, ACL routes, lazy caller lookup. */
1057
+ async function buildLiveAccessDeps(slug, hqRoot) {
808
1058
  const { token, client } = await resolveBrowseSession();
809
1059
  const companyUid = await getCompanyUid(token, slug);
1060
+ const aclError = async (res, route) => {
1061
+ const body = (await res.json().catch(() => ({})));
1062
+ return new Error(body.message ?? body.error ?? `${route} failed (${res.status})`);
1063
+ };
810
1064
  const fetchAclTree = async (prefix) => {
811
1065
  const res = await vaultApiFetch({
812
1066
  token,
@@ -815,41 +1069,67 @@ export async function runAccessForPath(path, opts) {
815
1069
  });
816
1070
  if (res.status === 404)
817
1071
  return null;
818
- if (!res.ok) {
819
- const body = (await res.json().catch(() => ({})));
820
- throw new Error(body.message ?? body.error ?? `acl/tree failed (${res.status})`);
821
- }
1072
+ if (!res.ok)
1073
+ throw await aclError(res, "acl/tree");
822
1074
  const tree = (await res.json());
823
1075
  return treeFromBody(tree);
824
1076
  };
825
- const listMembers = async () => {
826
- const rows = await listActiveMembers(token, companyUid);
827
- return rows
1077
+ const resolveAcl = async (targets) => {
1078
+ const res = await vaultApiFetch({
1079
+ token,
1080
+ path: `/files/${encodeURIComponent(companyUid)}/acl/resolve`,
1081
+ method: "POST",
1082
+ body: { targets },
1083
+ });
1084
+ if (!res.ok)
1085
+ throw await aclError(res, "acl/resolve");
1086
+ const data = (await res.json());
1087
+ const out = new Map();
1088
+ for (const row of data.results ?? []) {
1089
+ if (typeof row.target !== "string")
1090
+ continue;
1091
+ out.set(row.target, typeof row.permission === "string" ? row.permission : null);
1092
+ }
1093
+ return out;
1094
+ };
1095
+ // Members and the caller are needed only to send an access request, so
1096
+ // they load on first use and at most once.
1097
+ let membersP;
1098
+ const listMembers = () => {
1099
+ membersP ??= listActiveMembers(token, companyUid).then((rows) => rows
828
1100
  .filter((m) => ["owner", "admin", "member", "guest"].includes(m.role))
829
1101
  .map((m) => ({
830
1102
  personUid: m.personUid,
831
1103
  personEmail: m.personEmail ?? "",
832
1104
  personName: m.personName,
833
1105
  role: m.role,
834
- }));
1106
+ })));
1107
+ return membersP;
1108
+ };
1109
+ let requesterP;
1110
+ const requester = () => {
1111
+ requesterP ??= (async () => {
1112
+ const members = await listMembers();
1113
+ const caller = await resolveCallerUid(token);
1114
+ const me = members.find((m) => m.personUid === caller.uid);
1115
+ // An agent has no mailbox and no person entity. `hq files share --with`
1116
+ // accepts its agt_* uid, so the access request names the agent by uid.
1117
+ let requesterEmail = caller.kind === "agent" ? caller.uid : me?.personEmail;
1118
+ const requesterName = me?.personName;
1119
+ if (!requesterEmail) {
1120
+ const claims = peekIdToken(token);
1121
+ const emailClaim = claims.email;
1122
+ if (typeof emailClaim === "string" && emailClaim.length > 0) {
1123
+ requesterEmail = emailClaim;
1124
+ }
1125
+ }
1126
+ if (!requesterEmail) {
1127
+ throw new Error("Could not resolve your email; pass --company and ensure you are an active member");
1128
+ }
1129
+ return { email: requesterEmail, name: requesterName };
1130
+ })();
1131
+ return requesterP;
835
1132
  };
836
- const members = await listMembers();
837
- const caller = await resolveCallerUid(token);
838
- const me = members.find((m) => m.personUid === caller.uid);
839
- // An agent has no mailbox and no person entity. `hq files share --with`
840
- // accepts its agt_* uid, so the access request names the agent by uid.
841
- let requesterEmail = caller.kind === "agent" ? caller.uid : me?.personEmail;
842
- const requesterName = me?.personName;
843
- if (!requesterEmail) {
844
- const claims = peekIdToken(token);
845
- const emailClaim = claims.email;
846
- if (typeof emailClaim === "string" && emailClaim.length > 0) {
847
- requesterEmail = emailClaim;
848
- }
849
- }
850
- if (!requesterEmail) {
851
- throw new Error("Could not resolve your email; pass --company and ensure you are an active member");
852
- }
853
1133
  const dm = {
854
1134
  async send(msg) {
855
1135
  const body = buildDmBody({
@@ -889,65 +1169,99 @@ export async function runAccessForPath(path, opts) {
889
1169
  return t === "y" || t === "yes";
890
1170
  }
891
1171
  : undefined;
892
- const result = await runAccess({
893
- target,
894
- fix: opts?.fix !== false,
895
- json: Boolean(opts?.json),
896
- yes: Boolean(opts?.yes),
897
- deps: {
1172
+ return {
1173
+ hqRoot,
1174
+ companySlug: slug,
1175
+ companyUid,
1176
+ vaultClient: client,
1177
+ companyClient: makeCompanyPresignFactory(token, slug),
1178
+ region: "us-east-1",
1179
+ fetchAclTree,
1180
+ listMembers,
1181
+ requester,
1182
+ resolveAcl,
1183
+ dm,
1184
+ confirm,
1185
+ now: Date.now,
1186
+ get: (anchoredPath) => runGet({
1187
+ path: anchoredPath,
898
1188
  hqRoot,
899
1189
  companySlug: slug,
900
- companyUid,
901
1190
  vaultClient: client,
902
1191
  companyClient: makeCompanyPresignFactory(token, slug),
903
1192
  region: "us-east-1",
904
- fetchAclTree,
905
- listMembers,
906
- requester: { email: requesterEmail, name: requesterName },
907
- dm,
908
- confirm,
909
- now: Date.now,
910
- pick: promptPick,
911
- get: (anchoredPath) => runGet({
912
- path: anchoredPath,
1193
+ }),
1194
+ syncStatus: async () => {
1195
+ const journals = listJournals();
1196
+ if (journals.length === 0)
1197
+ return "no sync journal yet";
1198
+ let files = 0;
1199
+ for (const j of journals) {
1200
+ const entries = Object.entries(j.journal.files ?? {});
1201
+ files += entries.filter(([, e]) => !e.removedAt).length;
1202
+ }
1203
+ return `${journals.length} journals, ${files} files tracked`;
1204
+ },
1205
+ syncDoctor: async (apply) => {
1206
+ const doctorResult = await syncDoctor({
1207
+ entity: "local",
1208
+ vaultConfig: {},
913
1209
  hqRoot,
914
- companySlug: slug,
915
- vaultClient: client,
916
- companyClient: makeCompanyPresignFactory(token, slug),
917
- region: "us-east-1",
918
- }),
919
- syncStatus: async () => {
920
- const journals = listJournals();
921
- if (journals.length === 0)
922
- return "no sync journal yet";
923
- let files = 0;
924
- for (const j of journals) {
925
- const entries = Object.entries(j.journal.files ?? {});
926
- files += entries.filter(([, e]) => !e.removedAt).length;
927
- }
928
- return `${journals.length} journals, ${files} files tracked`;
929
- },
930
- syncDoctor: async (apply) => {
931
- const doctorResult = await syncDoctor({
932
- entity: "local",
933
- vaultConfig: {},
934
- hqRoot,
935
- yes: apply,
936
- reconcileConflicts: true,
937
- });
938
- const twins = doctorResult.conflictTwins?.plan?.length ?? 0;
939
- const bulkRefused = Boolean(doctorResult.plan?.bulkRefused);
940
- return {
941
- twins,
942
- summary: `${twins} conflict twins`,
943
- bulkRefused,
944
- breakerMessage: bulkRefused
945
- ? "bulk-asymmetry circuit-breaker tripped — refusing the delete leg"
946
- : undefined,
947
- };
948
- },
1210
+ yes: apply,
1211
+ reconcileConflicts: true,
1212
+ });
1213
+ const twins = doctorResult.conflictTwins?.plan?.length ?? 0;
1214
+ const bulkRefused = Boolean(doctorResult.plan?.bulkRefused);
1215
+ return {
1216
+ twins,
1217
+ summary: `${twins} conflict twins`,
1218
+ bulkRefused,
1219
+ breakerMessage: bulkRefused
1220
+ ? "bulk-asymmetry circuit-breaker tripped — refusing the delete leg"
1221
+ : undefined,
1222
+ };
949
1223
  },
1224
+ };
1225
+ }
1226
+ /**
1227
+ * Company for a set of targets. Every target must resolve to the same company:
1228
+ * one vend and one ACL call cover the whole batch.
1229
+ */
1230
+ function resolveBatchCompany(targets, opts) {
1231
+ const slugs = new Set(targets.map((t) => resolveAccessCompany(t, opts)));
1232
+ if (slugs.has(undefined)) {
1233
+ throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
1234
+ }
1235
+ if (slugs.size > 1) {
1236
+ throw new Error(`Paths span several companies (${[...slugs].join(", ")}); run hq access once per company or pass --company <slug>.`);
1237
+ }
1238
+ return [...slugs][0];
1239
+ }
1240
+ export async function runAccessForPath(path, opts) {
1241
+ const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
1242
+ const slug = resolveAccessCompany(path, opts);
1243
+ if (!slug) {
1244
+ throw new Error("Pass --company <slug>, an anchored companies/<slug>/... path, or set activeCompany in .hq/config.json.");
1245
+ }
1246
+ const deps = await buildLiveAccessDeps(slug, hqRoot);
1247
+ return runAccess({
1248
+ target: path,
1249
+ fix: opts?.fix !== false,
1250
+ json: Boolean(opts?.json),
1251
+ yes: Boolean(opts?.yes),
1252
+ deps: { ...deps, pick: promptPick },
1253
+ });
1254
+ }
1255
+ export async function runAccessForPaths(paths, opts) {
1256
+ const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
1257
+ const slug = resolveBatchCompany(paths, opts);
1258
+ const deps = await buildLiveAccessDeps(slug, hqRoot);
1259
+ return runAccessBatch({
1260
+ targets: paths,
1261
+ fix: opts?.fix !== false,
1262
+ json: Boolean(opts?.json),
1263
+ yes: Boolean(opts?.yes),
1264
+ deps,
950
1265
  });
951
- return result;
952
1266
  }
953
1267
  //# sourceMappingURL=access.js.map