@indigoai-us/hq-cli 5.345.169 → 5.345.171

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 (140) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
  3. package/dist/command-catalog.generated.d.ts +181 -9
  4. package/dist/command-catalog.generated.js +224 -10
  5. package/dist/commands/access.d.ts +32 -1
  6. package/dist/commands/access.js +119 -5
  7. package/dist/commands/agent-kit.d.ts +1 -1
  8. package/dist/commands/agent-kit.js +3 -1
  9. package/dist/commands/agents.js +2 -1
  10. package/dist/commands/bot.d.ts +3 -0
  11. package/dist/commands/bot.js +141 -14
  12. package/dist/commands/cloud.d.ts +78 -1
  13. package/dist/commands/cloud.js +274 -9
  14. package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
  15. package/dist/commands/core-company-index-scope-flag.js +9 -2
  16. package/dist/commands/core-delegate-w1b.js +5 -2
  17. package/dist/commands/core-dm-bind-native.js +19 -4
  18. package/dist/commands/core-rebuild-index-fast.js +24 -6
  19. package/dist/commands/core.js +18 -6
  20. package/dist/commands/dm.d.ts +17 -3
  21. package/dist/commands/dm.js +121 -26
  22. package/dist/commands/feedback-triage.d.ts +43 -1
  23. package/dist/commands/feedback-triage.js +123 -23
  24. package/dist/commands/feedback.d.ts +40 -1
  25. package/dist/commands/feedback.js +184 -4
  26. package/dist/commands/files.d.ts +42 -0
  27. package/dist/commands/files.js +223 -30
  28. package/dist/commands/integrations-api.d.ts +4 -0
  29. package/dist/commands/integrations-api.js +18 -2
  30. package/dist/commands/integrations-connect.d.ts +10 -3
  31. package/dist/commands/integrations-connect.js +70 -6
  32. package/dist/commands/integrations-core.d.ts +23 -0
  33. package/dist/commands/integrations-core.js +100 -0
  34. package/dist/commands/integrations-manage.js +8 -1
  35. package/dist/commands/integrations.js +49 -5
  36. package/dist/commands/lanes-loop.d.ts +20 -0
  37. package/dist/commands/lanes-loop.js +69 -11
  38. package/dist/commands/lanes.js +9 -1
  39. package/dist/commands/mesh.js +6 -0
  40. package/dist/commands/rescue.d.ts +9 -0
  41. package/dist/commands/rescue.js +31 -0
  42. package/dist/commands/run.js +115 -40
  43. package/dist/commands/scan.d.ts +5 -1
  44. package/dist/commands/scan.js +16 -4
  45. package/dist/commands/secrets.js +50 -9
  46. package/dist/commands/sync-manifest.d.ts +16 -0
  47. package/dist/commands/sync-manifest.js +17 -1
  48. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
  49. package/dist/hq-cloud-sync-runner/package.json +1 -1
  50. package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
  51. package/dist/lib/agent-kit/inbox-state.js +41 -20
  52. package/dist/lib/agent-kit/kit-config.d.ts +2 -0
  53. package/dist/lib/agent-kit/kit-config.js +6 -0
  54. package/dist/lib/agent-kit/mcp/tools.js +12 -5
  55. package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
  56. package/dist/lib/agent-kit/run/heartbeat.js +6 -3
  57. package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
  58. package/dist/lib/agent-kit/run/inbox.js +42 -10
  59. package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
  60. package/dist/lib/agent-kit/run/request-timeout.js +51 -0
  61. package/dist/lib/bot/api.d.ts +1 -1
  62. package/dist/lib/bot/config.d.ts +10 -0
  63. package/dist/lib/bot/config.js +17 -1
  64. package/dist/lib/bot/decisions.d.ts +62 -0
  65. package/dist/lib/bot/decisions.js +228 -0
  66. package/dist/lib/bot/fleet-local.d.ts +127 -0
  67. package/dist/lib/bot/fleet-local.js +638 -0
  68. package/dist/lib/bot/index.d.ts +2 -0
  69. package/dist/lib/bot/index.js +2 -0
  70. package/dist/lib/bot/local-config.d.ts +3 -2
  71. package/dist/lib/bot/local-config.js +5 -2
  72. package/dist/lib/bot/promote.d.ts +10 -0
  73. package/dist/lib/bot/promote.js +3 -2
  74. package/dist/lib/bot/run.d.ts +7 -0
  75. package/dist/lib/bot/run.js +104 -1
  76. package/dist/lib/bot/runtime/index.js +8 -4
  77. package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
  78. package/dist/lib/bot/runtime/scratch-dir.js +41 -0
  79. package/dist/lib/caller-principal.d.ts +4 -0
  80. package/dist/lib/caller-principal.js +40 -0
  81. package/dist/lib/capacity/collectors/grok.js +8 -2
  82. package/dist/lib/cli-flag-keys.d.ts +2 -0
  83. package/dist/lib/cli-flag-keys.js +2 -0
  84. package/dist/lib/daemon/catalog.d.ts +4 -0
  85. package/dist/lib/daemon/catalog.js +63 -29
  86. package/dist/lib/daemon/config.d.ts +7 -0
  87. package/dist/lib/daemon/config.js +32 -0
  88. package/dist/lib/daemon/run.d.ts +3 -2
  89. package/dist/lib/daemon/run.js +23 -3
  90. package/dist/lib/daemon/supervisor.d.ts +1 -0
  91. package/dist/lib/daemon/supervisor.js +4 -0
  92. package/dist/lib/daemon/sync-core-canary.d.ts +12 -0
  93. package/dist/lib/daemon/sync-core-canary.js +61 -0
  94. package/dist/lib/doctor/checks/release-links.d.ts +62 -1
  95. package/dist/lib/doctor/checks/release-links.js +188 -2
  96. package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
  97. package/dist/lib/doctor/checks/sync-health.js +54 -1
  98. package/dist/lib/doctor/fix/apply.d.ts +26 -4
  99. package/dist/lib/doctor/fix/apply.js +149 -11
  100. package/dist/lib/doctor/fix/remediation.d.ts +8 -1
  101. package/dist/lib/doctor/fix/remediation.js +22 -0
  102. package/dist/lib/doctor/types.d.ts +1 -1
  103. package/dist/lib/doctor/types.js +1 -0
  104. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  105. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  106. package/dist/lib/index-render/index.d.ts +9 -0
  107. package/dist/lib/index-render/index.js +65 -5
  108. package/dist/lib/index-render/session-scope.d.ts +69 -0
  109. package/dist/lib/index-render/session-scope.js +279 -0
  110. package/dist/lib/lanes/admission.js +7 -20
  111. package/dist/lib/lanes/reconcile.js +13 -15
  112. package/dist/lib/lanes/stale-lock.d.ts +50 -0
  113. package/dist/lib/lanes/stale-lock.js +105 -0
  114. package/dist/lib/lanes/store.js +11 -76
  115. package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
  116. package/dist/lib/notify-recipient.d.ts +10 -4
  117. package/dist/lib/notify-recipient.js +16 -4
  118. package/dist/lib/secret-input-link.d.ts +42 -0
  119. package/dist/lib/secret-input-link.js +93 -0
  120. package/dist/lib/secret-scan/range.d.ts +57 -0
  121. package/dist/lib/secret-scan/range.js +266 -0
  122. package/dist/lib/secret-scan/repo-config.d.ts +18 -3
  123. package/dist/lib/secret-scan/repo-config.js +135 -2
  124. package/dist/lib/work-context/organize.d.ts +7 -0
  125. package/dist/lib/work-context/organize.js +21 -1
  126. package/dist/run/script-bound-schema.d.ts +27 -0
  127. package/dist/run/script-bound-schema.js +97 -0
  128. package/dist/secrets-env-action.d.ts +2 -0
  129. package/dist/secrets-env-action.js +7 -2
  130. package/dist/sentry-before-send.js +1 -0
  131. package/dist/utils/cognito-session.d.ts +3 -30
  132. package/dist/utils/cognito-session.js +2 -100
  133. package/dist/utils/machine-principal-state.d.ts +38 -0
  134. package/dist/utils/machine-principal-state.js +110 -0
  135. package/dist/utils/safe-link.js +9 -0
  136. package/dist/utils/script-bound-exec.d.ts +158 -0
  137. package/dist/utils/script-bound-exec.js +568 -0
  138. package/dist/utils/sync-watchdog.d.ts +92 -0
  139. package/dist/utils/sync-watchdog.js +250 -0
  140. package/package.json +8 -8
@@ -9,6 +9,7 @@ import { FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MIN_USEFUL_BYTES, collectF
9
9
  import { uploadLogBundle } from "../utils/feedback-log-bundle.js";
10
10
  import { resolveProcessFlagGate } from "../lib/flag-registry.js";
11
11
  import { cachedLatestVersion } from "../utils/version-check.js";
12
+ import { decodeJwtPayload } from "../utils/person-token-cache.js";
12
13
  import { registerFeedbackShowCommand } from "./feedback-show.js";
13
14
  import { registerFeedbackTriageCommands } from "./feedback-triage.js";
14
15
  export const FEEDBACK_LATEST_VERSION_FLAG = "feedback.latest-version";
@@ -223,6 +224,7 @@ export async function submitFeedback(opts) {
223
224
  export const PUBLIC_TICKET_STATES = [
224
225
  "triaged",
225
226
  "in-progress",
227
+ "stalled",
226
228
  "shipped",
227
229
  "verified",
228
230
  "resolved",
@@ -277,6 +279,129 @@ export async function fetchFeedbackLink(opts) {
277
279
  }
278
280
  return url;
279
281
  }
282
+ const PR_URL_RE = /^https:\/\/[^\s/]+\/[^\s]+$/;
283
+ /** Max `--lane` length; matches hq-pro LANE_ID_MAX_CHARS. */
284
+ const LANE_ID_MAX_CHARS = 200;
285
+ /** Same rule as hq-pro normalizeLaneId: non-empty, at most 200 chars, no control characters. */
286
+ function isValidLaneId(lane) {
287
+ if (lane.length === 0 || lane.length > LANE_ID_MAX_CHARS)
288
+ return false;
289
+ for (let i = 0; i < lane.length; i += 1) {
290
+ const code = lane.charCodeAt(i);
291
+ if (code < 0x20 || code === 0x7f)
292
+ return false;
293
+ }
294
+ return true;
295
+ }
296
+ /** Accept `in_progress` as an alias of the public `in-progress` state. */
297
+ export function normalizePublicState(raw) {
298
+ const value = raw.trim().toLowerCase();
299
+ return value === "in_progress" ? "in-progress" : value;
300
+ }
301
+ /** Validate --pr/--lane for a state; in-progress needs at least one, other states take none. */
302
+ export function buildWorkLink(state, opts) {
303
+ const pr = opts.pr?.trim();
304
+ const lane = opts.lane?.trim();
305
+ if (state !== "in-progress") {
306
+ if (pr !== undefined || lane !== undefined) {
307
+ throw new Error("--pr and --lane only apply to --state in-progress.");
308
+ }
309
+ return null;
310
+ }
311
+ if (!pr && !lane) {
312
+ throw new Error("--state in-progress needs --pr <url> or --lane <id>: a ticket is in progress only while it has an open PR or a live lane. Claiming a ticket does not need a state; use hq feedback claim.");
313
+ }
314
+ const link = {};
315
+ if (pr) {
316
+ if (!PR_URL_RE.test(pr))
317
+ throw new Error(`Invalid --pr '${opts.pr}': expected an https:// pull request URL.`);
318
+ link.prUrl = pr;
319
+ }
320
+ if (lane) {
321
+ if (!isValidLaneId(lane)) {
322
+ throw new Error(`Invalid --lane '${opts.lane}': expected a lane id of at most ${LANE_ID_MAX_CHARS} characters with no control characters.`);
323
+ }
324
+ link.laneId = lane;
325
+ }
326
+ return link;
327
+ }
328
+ function asRecord(value) {
329
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
330
+ }
331
+ function storedLinkMatches(row, link) {
332
+ if (!row)
333
+ return false;
334
+ return ((link.prUrl === undefined || row.prUrl === link.prUrl) &&
335
+ (link.laneId === undefined || row.laneId === link.laneId));
336
+ }
337
+ /** Read a feedback row with the staff GET. Returns null when it cannot be read. */
338
+ export async function fetchFeedbackRow(opts) {
339
+ try {
340
+ const res = await vaultApiFetch({ token: opts.token, path: `/v1/feedback/${opts.id}`, method: "GET" });
341
+ if (!res.ok)
342
+ return null;
343
+ const data = asRecord(await res.json().catch(() => null));
344
+ return asRecord(data?.item);
345
+ }
346
+ catch {
347
+ return null;
348
+ }
349
+ }
350
+ function describeRowState(row) {
351
+ return typeof row?.status === "string"
352
+ ? `The ticket's current state is '${row.status}'.`
353
+ : "The ticket's current state could not be read; check it with hq feedback list.";
354
+ }
355
+ /** Re-read the row and describe its actual state, for error messages. */
356
+ async function currentStateNote(id, token) {
357
+ return describeRowState(await fetchFeedbackRow({ id, token }));
358
+ }
359
+ const OLD_SERVER_HINT = "The server may not support work links yet.";
360
+ /** Delays between read-back attempts: 3 reads over about 1s. */
361
+ export const LINK_READBACK_DELAYS_MS = [300, 700];
362
+ /** Overridable in tests so retries do not wait on real timers. */
363
+ export const linkReadbackTimer = {
364
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
365
+ };
366
+ function sleep(ms) {
367
+ return linkReadbackTimer.sleep(ms);
368
+ }
369
+ /**
370
+ * Store the PR or lane link before any state change, then confirm it was
371
+ * saved by reading the row back. The PATCH response does not echo prUrl or
372
+ * laneId, and an older server ignores unknown fields, so only the GET shows
373
+ * the link is stored. A link-only PATCH can itself change the row (hq-pro
374
+ * resumes a stalled row), so errors report the row's actual state.
375
+ */
376
+ export async function storeFeedbackWorkLink(opts) {
377
+ const res = await vaultApiFetch({
378
+ token: opts.token,
379
+ path: `/v1/feedback/${opts.id}`,
380
+ method: "PATCH",
381
+ body: { ...opts.link },
382
+ });
383
+ if (!res.ok) {
384
+ const message = await readFeedbackError(res);
385
+ throw new Error(`The server rejected the work link for ${opts.id}: ${message}. ${OLD_SERVER_HINT} ${await currentStateNote(opts.id, opts.token)}`);
386
+ }
387
+ // The GET resolves through an eventually consistent index, so a read right
388
+ // after the PATCH can return the old row. Retry briefly before deciding.
389
+ let row = null;
390
+ let readOk = false;
391
+ for (let attempt = 0; attempt < LINK_READBACK_DELAYS_MS.length + 1; attempt += 1) {
392
+ if (attempt > 0)
393
+ await sleep(LINK_READBACK_DELAYS_MS[attempt - 1]);
394
+ row = await fetchFeedbackRow({ id: opts.id, token: opts.token });
395
+ if (row)
396
+ readOk = true;
397
+ if (storedLinkMatches(row, opts.link))
398
+ return;
399
+ }
400
+ if (!readOk) {
401
+ throw new Error(`Could not read ${opts.id} back to confirm the work link; the link may have been saved. The state was not posted; rerun the same command to retry.`);
402
+ }
403
+ throw new Error(`The server did not store the work link for ${opts.id}. ${OLD_SERVER_HINT} ${describeRowState(row)}`);
404
+ }
280
405
  /** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
281
406
  export async function postFeedbackUpdate(opts) {
282
407
  const res = await vaultApiFetch({
@@ -286,7 +411,11 @@ export async function postFeedbackUpdate(opts) {
286
411
  body: { publicUpdate: { state: opts.state, note: opts.note } },
287
412
  });
288
413
  if (!res.ok) {
289
- throw new Error(`Failed to update ${opts.id}: ${await readFeedbackError(res)}`);
414
+ const message = await readFeedbackError(res);
415
+ const hint = opts.state === "stalled" && (res.status === 400 || res.status === 422)
416
+ ? ` The server may not support the stalled state yet. ${await currentStateNote(opts.id, opts.token)}`
417
+ : "";
418
+ throw new Error(`Failed to update ${opts.id}: ${message}${hint}`);
290
419
  }
291
420
  const data = (await res.json());
292
421
  return {
@@ -367,6 +496,41 @@ function registerAssignSubcommand(feedbackCmd) {
367
496
  }
368
497
  });
369
498
  }
499
+ function registerClaimSubcommand(feedbackCmd) {
500
+ feedbackCmd
501
+ .command("claim")
502
+ .description("Assign a ticket to yourself (sets the assignee only, never the status)")
503
+ .argument("<id>", "Feedback id (feedback_<uuid>)")
504
+ .option("--json", "Print { id, assignee } as JSON")
505
+ .action(async (id, opts) => {
506
+ try {
507
+ assertFeedbackId(id);
508
+ const token = await resolveStaffToken();
509
+ const email = emailFromStaffToken(token);
510
+ if (!email) {
511
+ throw new Error("hq feedback claim needs a person login with an email (hq login). Use hq feedback assign <id> <person> instead.");
512
+ }
513
+ assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
514
+ await assignFeedback({ id, token, assignee: email });
515
+ if (opts.json) {
516
+ console.log(JSON.stringify({ id, assignee: email }));
517
+ }
518
+ else {
519
+ console.log(`Claimed: ${id} -> ${email}`);
520
+ console.log("Set --state in-progress with --pr <url> or --lane <id> once work is linked.");
521
+ }
522
+ }
523
+ catch (err) {
524
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
525
+ process.exit(1);
526
+ }
527
+ });
528
+ }
529
+ function emailFromStaffToken(token) {
530
+ const claims = decodeJwtPayload(token);
531
+ const email = claims?.email;
532
+ return typeof email === "string" && email.includes("@") ? email.toLowerCase() : null;
533
+ }
370
534
  function registerLinkSubcommand(feedbackCmd) {
371
535
  feedbackCmd
372
536
  .command("link")
@@ -393,12 +557,16 @@ function registerUpdateSubcommand(feedbackCmd) {
393
557
  .argument("<id>", "Feedback id (feedback_<uuid>)")
394
558
  .requiredOption("--state <state>", `One of: ${PUBLIC_TICKET_STATES.join(", ")}`)
395
559
  .requiredOption("--note <text>", "What the customer should read (plain text, up to 2000 characters)")
560
+ .option("--pr <url>", "Open pull request for this ticket (required with --state in-progress unless --lane is given)")
561
+ .option("--lane <id>", "Live lane working this ticket (required with --state in-progress unless --pr is given)")
396
562
  .action(async (id, opts) => {
397
563
  try {
398
564
  assertFeedbackId(id);
399
- if (!PUBLIC_TICKET_STATES.includes(opts.state)) {
565
+ const state = normalizePublicState(opts.state);
566
+ if (!PUBLIC_TICKET_STATES.includes(state)) {
400
567
  throw new Error(`Invalid --state '${opts.state}': expected one of ${PUBLIC_TICKET_STATES.join(", ")}.`);
401
568
  }
569
+ const link = buildWorkLink(state, opts);
402
570
  const note = opts.note.trim();
403
571
  if (note.length === 0)
404
572
  throw new Error("--note must not be empty.");
@@ -406,13 +574,24 @@ function registerUpdateSubcommand(feedbackCmd) {
406
574
  throw new Error("--note must be at most 2000 characters.");
407
575
  const token = await resolveStaffToken();
408
576
  assertVaultApiRequestSupported({ token, path: `/v1/feedback/${id}` });
577
+ if (link)
578
+ await storeFeedbackWorkLink({ id, token, link });
409
579
  const result = await postFeedbackUpdate({
410
580
  id,
411
581
  token,
412
- state: opts.state,
582
+ state: state,
413
583
  note,
584
+ }).catch((err) => {
585
+ if (!link)
586
+ throw err;
587
+ const message = err instanceof Error ? err.message : String(err);
588
+ throw new Error(`${message} The work link was saved; rerun the same command to set the state.`);
414
589
  });
415
- console.log(`Updated: ${id} -> ${opts.state}`);
590
+ console.log(`Updated: ${id} -> ${state}`);
591
+ if (link?.prUrl)
592
+ console.log(`PR: ${link.prUrl}`);
593
+ if (link?.laneId)
594
+ console.log(`Lane: ${link.laneId}`);
416
595
  if (result.publicUrl)
417
596
  console.log(`Track: ${result.publicUrl}`);
418
597
  if (result.slack === "failed") {
@@ -530,6 +709,7 @@ export function registerFeedbackCommand(program) {
530
709
  registerUpdateSubcommand(feedbackCmd);
531
710
  registerFeedbackShowCommand(feedbackCmd);
532
711
  registerAssignSubcommand(feedbackCmd);
712
+ registerClaimSubcommand(feedbackCmd);
533
713
  registerFeedbackTriageCommands(feedbackCmd);
534
714
  }
535
715
  //# sourceMappingURL=feedback.js.map
@@ -66,6 +66,48 @@ export declare class ShareSessionHttpError extends Error {
66
66
  */
67
67
  export declare function formatShareSessionError(err: ShareSessionHttpError): string;
68
68
  export declare function registerFilesCommand(program: Command): Command;
69
+ /**
70
+ * True for a prefix that might name a folder without saying so: no trailing
71
+ * `/` (private folder), no `/*` (shared folder glob), and not the `*`
72
+ * wildcard, and a last segment with no extension (a name with an extension is
73
+ * taken as the file it names). The vault ACL treats such a path as an exact
74
+ * object key, so
75
+ * `hq files share knowledge --with x` used to grant an object named
76
+ * "knowledge" and nothing under `knowledge/` (DEV-5173: members were blocked
77
+ * under a folder their admin had "shared"). The name alone does not decide
78
+ * whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
79
+ * the vault.
80
+ */
81
+ export declare function looksLikeBareFolderPrefix(prefix: string): boolean;
82
+ /**
83
+ * Refuse a bare prefix that would become a malformed grant if it were
84
+ * rewritten to a folder glob: a `*` inside a segment (`**`, `know*`) or an
85
+ * empty segment (`/knowledge`, `a//b`). Returns the error text, or null.
86
+ */
87
+ export declare function malformedBareSharePrefix(prefix: string): string | null;
88
+ export type BareSharePrefixResolution = {
89
+ kind: "unchanged";
90
+ prefix: string;
91
+ } | {
92
+ kind: "folder";
93
+ prefix: string;
94
+ folderGlob: string;
95
+ } | {
96
+ kind: "refused";
97
+ prefix: string;
98
+ message: string;
99
+ };
100
+ /**
101
+ * Decide what a bare prefix means by looking it up in the vault. Rewrite `X`
102
+ * to `X/*` only when `X/` has children and no object `X` exists. Keep `X` when
103
+ * only the object exists. Refuse when both exist, when neither does, or when
104
+ * the lookup fails; the caller exits non-zero and points to --exact or `X/*`.
105
+ */
106
+ export declare function resolveBareSharePrefix(params: {
107
+ token: string;
108
+ companyUid: string;
109
+ prefix: string;
110
+ }): Promise<BareSharePrefixResolution>;
69
111
  /** Server response shape for POST /v1/files/delete. */
70
112
  export interface FilesDeleteResponse {
71
113
  prefix: string;
@@ -196,6 +196,7 @@ export function registerFilesCommand(program) {
196
196
  .description("Share file paths. foo/ is a private folder (children stay creator-only); quote the shared-folder glob 'foo/*' so the shell passes it literally. Without --with: mint a share-session URL and open it in the browser. With --with: grant access directly to a person, group, or @all.")
197
197
  .option("--with <principal>", "Email address, group id, company Outpost id (out_...), or '@all' to share with every active company member")
198
198
  .option("--permission <level>", "Permission level (only with --with): read | write")
199
+ .option("--exact", "With --with: grant exactly the object named by <prefix>, even when it looks like a folder (by default a bare folder name such as 'knowledge' is granted as the folder glob 'knowledge/*').")
199
200
  .option("--full", "Grant access to the ENTIRE vault (the '*' wildcard prefix) — no need to quote a glob. Requires --with; defaults to write permission.")
200
201
  .option("--expires <duration>", "Token expiry duration for share-session URL (e.g. 15m, 1h, 24h). Default 15m. Max 24h.")
201
202
  .option("--no-open", "Print the share-session URL but do not launch the browser")
@@ -208,6 +209,14 @@ export function registerFilesCommand(program) {
208
209
  console.error(chalk.red("--mention requires --channel <name>."));
209
210
  process.exit(1);
210
211
  }
212
+ if (opts.exact && opts.with === undefined && opts.channel === undefined) {
213
+ console.error(chalk.red("--exact applies only to a direct grant and requires --with <principal> (or --channel with --mention)."));
214
+ process.exit(1);
215
+ }
216
+ if (opts.exact && opts.full) {
217
+ console.error(chalk.red("--exact cannot be combined with --full."));
218
+ process.exit(1);
219
+ }
211
220
  const companySlug = files.opts().company;
212
221
  const channelTarget = opts.channel !== undefined
213
222
  ? await resolveShareChannelTarget({
@@ -258,6 +267,7 @@ export function registerFilesCommand(program) {
258
267
  principal: opts.with,
259
268
  permission: opts.permission,
260
269
  companySlug,
270
+ ...(opts.exact ? { exact: true } : {}),
261
271
  ...channelTarget,
262
272
  });
263
273
  return;
@@ -278,6 +288,7 @@ export function registerFilesCommand(program) {
278
288
  principal: uid,
279
289
  permission,
280
290
  companySlug,
291
+ ...(opts.exact ? { exact: true } : {}),
281
292
  ...(index === 0
282
293
  ? {
283
294
  channelId: channelTarget.channelId,
@@ -302,14 +313,22 @@ export function registerFilesCommand(program) {
302
313
  });
303
314
  files
304
315
  .command("unshare <prefix>")
305
- .description("Remove a named file access grant, or remove an empty ACL row or grantee-less entries without --with.")
316
+ .description("Remove a named file access grant, or remove an empty ACL row or grantee-less entries without --with. " +
317
+ "With --with, a bare name such as 'knowledge' first removes the exact 'knowledge' grant (rows from older " +
318
+ "`hq files share` versions) and, when none matches, the folder-glob 'knowledge/*' grant. Without --with, " +
319
+ "the prefix is used exactly as typed: pass 'knowledge/*' (quoted) to clean up the folder-glob row.")
306
320
  .option("--with <principal>", "Person uid, agent uid, company Outpost id (out_...), email address, group id, or '@all' to remove the grant")
321
+ .option("--exact", "With --with: remove the grant on exactly the object named by <prefix>. By default a bare folder name such as 'knowledge' resolves to the folder glob 'knowledge/*', the same way `hq files share` resolves it.")
307
322
  .action(async (prefix, opts) => {
308
323
  try {
324
+ if (opts.exact && !opts.with) {
325
+ console.error(chalk.red("--exact requires --with <principal>."));
326
+ process.exit(1);
327
+ }
309
328
  // Unshare MUTATES access with no preview. Anchored content paths remain
310
329
  // refused; the exact company-root ACL row is the sole exception and
311
330
  // resolves its anchor against the selected company before revoking.
312
- const canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
331
+ let canonicalPrefix = normalizeCompanyFilePrefix(prefix, {
313
332
  mutating: true,
314
333
  allowCompanyRootAclRow: true,
315
334
  ...(files.opts().company !== undefined
@@ -385,40 +404,66 @@ export function registerFilesCommand(program) {
385
404
  : `${cleanupMessage || `Removed empty ACL pattern '${canonicalPrefix}'. `}It now follows its parent folder's access, so anyone who can see that parent can see this folder.`));
386
405
  return;
387
406
  }
388
- const res = await vaultApiFetch({
389
- token,
390
- path: `/files/${encodeURIComponent(companyUid)}/acl/revoke`,
391
- method: "POST",
392
- body: { prefix: canonicalPrefix, granteeType, granteeId },
393
- });
394
- if (!res.ok) {
407
+ // A bare name may carry an exact-key row from older `hq files share`
408
+ // versions (DEV-5173) or the folder-glob row the current share
409
+ // creates. Try the exact row first, then `X/*` unless the vault says
410
+ // X is only a file. A revoke only reduces access, so the vault lookup
411
+ // never blocks it: a deleted path or a failed lookup still tries both.
412
+ const bare = !opts.exact &&
413
+ companyRootSlug === undefined &&
414
+ looksLikeBareFolderPrefix(canonicalPrefix) &&
415
+ malformedBareSharePrefix(canonicalPrefix) === null;
416
+ const candidates = [canonicalPrefix];
417
+ const tried = [];
418
+ let aclMissingEverywhere = true;
419
+ for (let index = 0; index < candidates.length; index++) {
420
+ const candidate = candidates[index];
421
+ tried.push(candidate);
422
+ const res = await vaultApiFetch({
423
+ token,
424
+ path: `/files/${encodeURIComponent(companyUid)}/acl/revoke`,
425
+ method: "POST",
426
+ body: { prefix: candidate, granteeType, granteeId },
427
+ });
428
+ if (res.ok) {
429
+ console.log(chalk.green(`Removed grant for ${principalLabel} on '${displayAclPattern(candidate)}'`));
430
+ if (bare && candidate === canonicalPrefix) {
431
+ console.log(chalk.dim(`This removed the exact '${candidate}' grant. A grant for ${principalLabel} on ` +
432
+ `'${candidate}/*', if one exists, remains; remove it with ` +
433
+ `hq files unshare '${candidate}/*' --with ${principal}.`));
434
+ }
435
+ return;
436
+ }
395
437
  const body = await res.json().catch(() => ({}));
438
+ if (res.status === 404) {
439
+ // A 404 means ZERO rows were removed — never report a green success
440
+ // that implies a revoke happened. ACL_NOT_FOUND means the prefix
441
+ // has no ACL at all; GRANT_NOT_FOUND (or an older server with no
442
+ // code) means the ACL exists but no grant matched this principal.
443
+ if (body.code !== "ACL_NOT_FOUND")
444
+ aclMissingEverywhere = false;
445
+ if (bare && candidate === canonicalPrefix) {
446
+ const resolution = await resolveBareSharePrefix({
447
+ token,
448
+ companyUid,
449
+ prefix: canonicalPrefix,
450
+ });
451
+ // Fall back to the folder glob unless the vault confirms X is a
452
+ // file with nothing under it.
453
+ if (resolution.kind !== "unchanged")
454
+ candidates.push(`${canonicalPrefix}/*`);
455
+ }
456
+ continue;
457
+ }
396
458
  if (res.status === 401) {
397
459
  console.error(chalk.red("Not authenticated — please run `hq login`"));
398
460
  }
399
461
  else if (res.status === 403) {
400
462
  console.error(chalk.red("Not authorized to modify this file prefix's ACL"));
401
- await offerAccessLadder(canonicalPrefix, {
463
+ await offerAccessLadder(candidate, {
402
464
  company: files.opts().company,
403
465
  });
404
466
  }
405
- else if (res.status === 404) {
406
- // A 404 means ZERO rows were removed — never report a green success
407
- // that implies a revoke happened. Distinguish the two causes:
408
- // ACL_NOT_FOUND — the prefix has no ACL at all (nothing was ever
409
- // shared here): genuinely, benignly absent.
410
- // GRANT_NOT_FOUND (or an older server with no code) — the ACL
411
- // exists but no grant matched this principal:
412
- // surface it as a warning so a typo'd/stale
413
- // principal isn't mistaken for a real removal.
414
- if (body.code === "ACL_NOT_FOUND") {
415
- console.log(chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`));
416
- }
417
- else {
418
- console.warn(chalk.yellow(`No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`));
419
- }
420
- return;
421
- }
422
467
  else if (res.status >= 500) {
423
468
  console.error(chalk.red(`Server error: ${body.error ?? res.statusText}`));
424
469
  }
@@ -427,7 +472,18 @@ export function registerFilesCommand(program) {
427
472
  }
428
473
  process.exit(1);
429
474
  }
430
- console.log(chalk.green(`Removed grant for ${principalLabel} on '${displayAclPattern(canonicalPrefix)}'`));
475
+ if (tried.length === 1) {
476
+ if (aclMissingEverywhere) {
477
+ console.log(chalk.dim(`Nothing to remove — '${canonicalPrefix}' has no sharing grants.`));
478
+ }
479
+ else {
480
+ console.warn(chalk.yellow(`No matching grant for ${principalLabel} on '${canonicalPrefix}' — nothing was removed.`));
481
+ }
482
+ return;
483
+ }
484
+ console.warn(chalk.yellow(`No matching grant for ${principalLabel} on ${tried.map((row) => `'${displayAclPattern(row)}'`).join(" or ")} ` +
485
+ `— nothing was removed. Check the grant with hq files acl, then re-run with --exact for the exact ` +
486
+ `'${canonicalPrefix}' row or pass '${canonicalPrefix}/*' (quoted) for the folder glob.`));
431
487
  }
432
488
  catch (err) {
433
489
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -811,14 +867,130 @@ async function resolveMentionPersonUid(mention, companySlug) {
811
867
  console.error(chalk.red(`Could not resolve --mention '${trimmed}' to a person.`));
812
868
  process.exit(1);
813
869
  }
870
+ /**
871
+ * True for a prefix that might name a folder without saying so: no trailing
872
+ * `/` (private folder), no `/*` (shared folder glob), and not the `*`
873
+ * wildcard, and a last segment with no extension (a name with an extension is
874
+ * taken as the file it names). The vault ACL treats such a path as an exact
875
+ * object key, so
876
+ * `hq files share knowledge --with x` used to grant an object named
877
+ * "knowledge" and nothing under `knowledge/` (DEV-5173: members were blocked
878
+ * under a folder their admin had "shared"). The name alone does not decide
879
+ * whether it is a folder (`Makefile`, `bin/hq`); resolveBareSharePrefix asks
880
+ * the vault.
881
+ */
882
+ export function looksLikeBareFolderPrefix(prefix) {
883
+ if (prefix === "*" || prefix.endsWith("/") || prefix.endsWith("/*"))
884
+ return false;
885
+ const last = prefix.slice(prefix.lastIndexOf("/") + 1);
886
+ return last.length > 0 && !last.includes(".");
887
+ }
888
+ /**
889
+ * Refuse a bare prefix that would become a malformed grant if it were
890
+ * rewritten to a folder glob: a `*` inside a segment (`**`, `know*`) or an
891
+ * empty segment (`/knowledge`, `a//b`). Returns the error text, or null.
892
+ */
893
+ export function malformedBareSharePrefix(prefix) {
894
+ if (!looksLikeBareFolderPrefix(prefix))
895
+ return null;
896
+ if (prefix.includes("*")) {
897
+ return (`'${prefix}' has a '*' inside a path segment. Only a whole trailing '/*' segment ` +
898
+ `(for example 'knowledge/*') or '*' alone (--full) is a glob.`);
899
+ }
900
+ if (prefix.split("/").some((segment) => segment.length === 0)) {
901
+ return `'${prefix}' has an empty path segment. Pass a company-relative path such as 'knowledge'.`;
902
+ }
903
+ return null;
904
+ }
905
+ /**
906
+ * Decide what a bare prefix means by looking it up in the vault. Rewrite `X`
907
+ * to `X/*` only when `X/` has children and no object `X` exists. Keep `X` when
908
+ * only the object exists. Refuse when both exist, when neither does, or when
909
+ * the lookup fails; the caller exits non-zero and points to --exact or `X/*`.
910
+ */
911
+ export async function resolveBareSharePrefix(params) {
912
+ const { prefix } = params;
913
+ if (!looksLikeBareFolderPrefix(prefix))
914
+ return { kind: "unchanged", prefix };
915
+ const malformed = malformedBareSharePrefix(prefix);
916
+ if (malformed)
917
+ return { kind: "refused", prefix, message: malformed };
918
+ const folderGlob = `${prefix}/*`;
919
+ const choose = `Re-run with --exact to use exactly the object '${prefix}', or pass '${folderGlob}' ` +
920
+ `(quoted) to cover everything under the folder.`;
921
+ const list = async (listPrefix) => {
922
+ const res = await vaultApiFetch({
923
+ token: params.token,
924
+ path: "/v1/files/list",
925
+ query: { company: params.companyUid, prefix: listPrefix },
926
+ });
927
+ if (!res.ok)
928
+ throw new Error(`files list failed (${res.status})`);
929
+ const body = (await res.json());
930
+ return Array.isArray(body.objects) ? body.objects : [];
931
+ };
932
+ let objectExists;
933
+ let folderHasChildren;
934
+ try {
935
+ // The object `X` sorts first among keys that start with `X`, so the first
936
+ // page holds it when it exists. Children are listed separately so sibling
937
+ // keys such as `X-old.md` cannot push them off the page.
938
+ objectExists = (await list(prefix)).some((object) => object.key === prefix);
939
+ folderHasChildren = (await list(`${prefix}/`)).length > 0;
940
+ }
941
+ catch (error) {
942
+ const reason = error instanceof Error ? error.message : String(error);
943
+ return {
944
+ kind: "refused",
945
+ prefix,
946
+ message: `Could not check whether '${prefix}' is a file or a folder (${reason}). ${choose}`,
947
+ };
948
+ }
949
+ if (folderHasChildren && !objectExists)
950
+ return { kind: "folder", prefix, folderGlob };
951
+ if (objectExists && !folderHasChildren)
952
+ return { kind: "unchanged", prefix };
953
+ return {
954
+ kind: "refused",
955
+ prefix,
956
+ message: objectExists
957
+ ? `'${prefix}' is both a file and a folder in the vault. ${choose}`
958
+ : `Nothing exists at '${prefix}' or under '${prefix}/' in the vault, so it is unclear ` +
959
+ `whether it is a file or a folder. ${choose}`,
960
+ };
961
+ }
962
+ /** Resolve a bare prefix for a mutating command: print the note, or exit 1 on refusal. */
963
+ async function resolveBareSharePrefixOrExit(params) {
964
+ if (params.exact)
965
+ return params.prefix;
966
+ const resolution = await resolveBareSharePrefix(params);
967
+ if (resolution.kind === "refused") {
968
+ console.error(chalk.red(resolution.message));
969
+ process.exit(1);
970
+ }
971
+ if (resolution.kind === "folder") {
972
+ console.error(chalk.yellow(`Note: '${resolution.prefix}' is a folder in the vault, so this uses the folder glob ` +
973
+ `'${resolution.folderGlob}' (everything under it). Pass --exact to use the exact ` +
974
+ `object key '${resolution.prefix}' instead.`));
975
+ return resolution.folderGlob;
976
+ }
977
+ return resolution.prefix;
978
+ }
814
979
  async function runDirectGrant(params) {
815
- const canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
980
+ let canonicalPrefix = normalizeCompanyFilePrefix(params.prefix, {
816
981
  mutating: true,
817
982
  rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
818
983
  ...(params.companySlug !== undefined
819
984
  ? { companySlug: params.companySlug }
820
985
  : {}),
821
986
  });
987
+ if (!params.exact) {
988
+ const malformed = malformedBareSharePrefix(canonicalPrefix);
989
+ if (malformed) {
990
+ console.error(chalk.red(malformed));
991
+ process.exit(1);
992
+ }
993
+ }
822
994
  if (!params.permission) {
823
995
  console.error(chalk.red("--permission is required when --with is set (read | write)"));
824
996
  process.exit(1);
@@ -864,6 +1036,14 @@ async function runDirectGrant(params) {
864
1036
  const principalLabel = isAll ? "everyone in the company" : granteeId;
865
1037
  const token = (await resolveVaultCredential()).token;
866
1038
  const companyUid = await getCompanyUid(token, params.companySlug);
1039
+ if (!params.fullVault) {
1040
+ canonicalPrefix = await resolveBareSharePrefixOrExit({
1041
+ token,
1042
+ companyUid,
1043
+ prefix: canonicalPrefix,
1044
+ exact: params.exact,
1045
+ });
1046
+ }
867
1047
  let res = await vaultApiFetch({
868
1048
  token,
869
1049
  path: `/files/${encodeURIComponent(companyUid)}/acl/grant`,
@@ -966,13 +1146,20 @@ async function runDirectGrant(params) {
966
1146
  async function runShareSession(params) {
967
1147
  // Validate every path before minting a session; an anchored path must never
968
1148
  // silently become a share of the corresponding company-relative objects.
969
- const normalizedPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
1149
+ const canonicalPaths = params.paths.map((prefix) => normalizeCompanyFilePrefix(prefix, {
970
1150
  mutating: true,
971
1151
  rootAdvice: "Use --full --with <principal> to share the entire vault, or pass a bounded path.",
972
1152
  ...(params.companySlug !== undefined
973
1153
  ? { companySlug: params.companySlug }
974
1154
  : {}),
975
1155
  }));
1156
+ for (const prefix of canonicalPaths) {
1157
+ const malformed = malformedBareSharePrefix(prefix);
1158
+ if (malformed) {
1159
+ console.error(chalk.red(malformed));
1160
+ process.exit(1);
1161
+ }
1162
+ }
976
1163
  let expiresInMs;
977
1164
  if (params.expires !== undefined) {
978
1165
  const parsed = parseDuration(params.expires);
@@ -988,6 +1175,12 @@ async function runShareSession(params) {
988
1175
  }
989
1176
  const token = (await resolveVaultCredential()).token;
990
1177
  const companyUid = await getCompanyUid(token, params.companySlug);
1178
+ // A bare folder name in a share session would create the same exact-key row
1179
+ // that blocked members in DEV-5173, so resolve each path like a direct grant.
1180
+ const normalizedPaths = [];
1181
+ for (const prefix of canonicalPaths) {
1182
+ normalizedPaths.push(await resolveBareSharePrefixOrExit({ token, companyUid, prefix }));
1183
+ }
991
1184
  let session;
992
1185
  try {
993
1186
  session = await mintShareSession({