@indigoai-us/hq-cli 5.345.156 → 5.345.160

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 (188) hide show
  1. package/CHANGELOG.md +255 -0
  2. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +110 -2
  3. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +101 -5
  4. package/assets/scaffold/core/scripts/worktree.sh +4 -0
  5. package/dist/command-catalog.generated.d.ts +535 -10
  6. package/dist/command-catalog.generated.js +678 -31
  7. package/dist/command-registration-plan.d.ts +18 -0
  8. package/dist/command-registration-plan.js +4 -0
  9. package/dist/commands/_patterns.d.ts +17 -0
  10. package/dist/commands/_patterns.js +22 -0
  11. package/dist/commands/access.d.ts +6 -0
  12. package/dist/commands/access.js +18 -1
  13. package/dist/commands/acl-lock.d.ts +41 -0
  14. package/dist/commands/acl-lock.js +28 -0
  15. package/dist/commands/agent-enroll.d.ts +2 -0
  16. package/dist/commands/agent-enroll.js +9 -4
  17. package/dist/commands/agent-kit.js +8 -6
  18. package/dist/commands/agents.js +5 -11
  19. package/dist/commands/billing-sponsor.d.ts +4 -4
  20. package/dist/commands/billing-sponsor.js +11 -11
  21. package/dist/commands/billing-usage.d.ts +22 -8
  22. package/dist/commands/billing-usage.js +57 -22
  23. package/dist/commands/billing.d.ts +7 -7
  24. package/dist/commands/billing.js +13 -13
  25. package/dist/commands/bot.js +7 -4
  26. package/dist/commands/channel-suggest.d.ts +56 -0
  27. package/dist/commands/channel-suggest.js +170 -0
  28. package/dist/commands/channels.js +7 -1
  29. package/dist/commands/cloud-provision.js +2 -2
  30. package/dist/commands/cloud.d.ts +45 -0
  31. package/dist/commands/cloud.js +157 -17
  32. package/dist/commands/company.js +4 -3
  33. package/dist/commands/core-checkpoint-native.d.ts +18 -0
  34. package/dist/commands/core-checkpoint-native.js +67 -2
  35. package/dist/commands/core-pipeline-driver-native.d.ts +12 -1
  36. package/dist/commands/core-pipeline-driver-native.js +131 -5
  37. package/dist/commands/core.js +12 -0
  38. package/dist/commands/crm.js +83 -6
  39. package/dist/commands/db-remote-deps.js +12 -1
  40. package/dist/commands/deploy-access.d.ts +33 -0
  41. package/dist/commands/deploy-access.js +322 -0
  42. package/dist/commands/dm-attach.d.ts +106 -0
  43. package/dist/commands/dm-attach.js +184 -0
  44. package/dist/commands/dm.d.ts +37 -0
  45. package/dist/commands/dm.js +172 -10
  46. package/dist/commands/feedback-show.d.ts +40 -0
  47. package/dist/commands/feedback-show.js +261 -0
  48. package/dist/commands/feedback.d.ts +21 -0
  49. package/dist/commands/feedback.js +152 -2
  50. package/dist/commands/files.d.ts +24 -0
  51. package/dist/commands/files.js +71 -8
  52. package/dist/commands/integrations-connect.js +7 -3
  53. package/dist/commands/lanes-accounts.js +16 -1
  54. package/dist/commands/lanes-loop.js +14 -4
  55. package/dist/commands/lanes-message.js +6 -4
  56. package/dist/commands/lanes-resume.js +36 -13
  57. package/dist/commands/lanes-shared.d.ts +5 -0
  58. package/dist/commands/lanes-shared.js +13 -3
  59. package/dist/commands/lanes-watch.js +1 -0
  60. package/dist/commands/lanes.d.ts +20 -1
  61. package/dist/commands/lanes.js +201 -51
  62. package/dist/commands/meetings.d.ts +4 -4
  63. package/dist/commands/meetings.js +17 -8
  64. package/dist/commands/mesh.js +149 -1
  65. package/dist/commands/monitor.d.ts +1 -0
  66. package/dist/commands/monitor.js +4 -1
  67. package/dist/commands/repo.d.ts +41 -0
  68. package/dist/commands/repo.js +331 -0
  69. package/dist/commands/scan.d.ts +3 -1
  70. package/dist/commands/scan.js +7 -3
  71. package/dist/commands/secrets.d.ts +21 -0
  72. package/dist/commands/secrets.js +114 -14
  73. package/dist/commands/signals.js +17 -5
  74. package/dist/commands/skill.js +6 -6
  75. package/dist/commands/staff/listings.d.ts +41 -0
  76. package/dist/commands/staff/listings.js +160 -0
  77. package/dist/commands/staff/reset-test-account.d.ts +3 -2
  78. package/dist/commands/staff/reset-test-account.js +4 -3
  79. package/dist/commands/whoami.js +1 -1
  80. package/dist/hook-engine-path.js +13 -2
  81. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3392 -1243
  82. package/dist/hq-cloud-sync-runner/package.json +1 -1
  83. package/dist/lib/agent-kit/run/inbox.js +2 -1
  84. package/dist/lib/agent-kit/wake-secrets.d.ts +7 -0
  85. package/dist/lib/agent-kit/wake-secrets.js +32 -0
  86. package/dist/lib/agent-kit/wake.d.ts +4 -0
  87. package/dist/lib/agent-kit/wake.js +49 -3
  88. package/dist/lib/billing/plan-limit-refusals.d.ts +13 -3
  89. package/dist/lib/billing/plan-limit-refusals.js +26 -3
  90. package/dist/lib/billing/plan-limit-upgrade-url.d.ts +8 -0
  91. package/dist/lib/billing/plan-limit-upgrade-url.js +18 -0
  92. package/dist/lib/billing/plan-lock.d.ts +22 -6
  93. package/dist/lib/billing/plan-lock.js +58 -30
  94. package/dist/lib/billing/usage-cost.d.ts +24 -2
  95. package/dist/lib/billing/usage-cost.js +103 -3
  96. package/dist/lib/billing/usage-only.d.ts +37 -1
  97. package/dist/lib/billing/usage-only.js +89 -4
  98. package/dist/lib/billing/usage-pause.d.ts +27 -0
  99. package/dist/lib/billing/usage-pause.js +50 -0
  100. package/dist/lib/bot/inflight.d.ts +7 -1
  101. package/dist/lib/bot/inflight.js +10 -2
  102. package/dist/lib/bot/prompt.js +3 -0
  103. package/dist/lib/bot/run.js +72 -20
  104. package/dist/lib/bot/session.d.ts +14 -0
  105. package/dist/lib/bot/session.js +70 -0
  106. package/dist/lib/capacity/collectors/claude-oauth.d.ts +6 -0
  107. package/dist/lib/capacity/collectors/claude-oauth.js +12 -0
  108. package/dist/lib/capacity/lane-login.d.ts +6 -0
  109. package/dist/lib/capacity/lane-login.js +5 -0
  110. package/dist/lib/core-utils/worktree.js +2 -0
  111. package/dist/lib/daemon/exit-cause.d.ts +43 -0
  112. package/dist/lib/daemon/exit-cause.js +88 -0
  113. package/dist/lib/daemon/handoff.d.ts +2 -0
  114. package/dist/lib/daemon/handoff.js +6 -1
  115. package/dist/lib/daemon/hq-child-bootstrap.mjs +20 -3
  116. package/dist/lib/daemon/paths.d.ts +2 -0
  117. package/dist/lib/daemon/paths.js +1 -0
  118. package/dist/lib/daemon/run.js +13 -0
  119. package/dist/lib/daemon/supervisor.d.ts +54 -1
  120. package/dist/lib/daemon/supervisor.js +297 -21
  121. package/dist/lib/daemon/unit-holds.d.ts +22 -0
  122. package/dist/lib/daemon/unit-holds.js +86 -0
  123. package/dist/lib/db/control-plane.d.ts +5 -0
  124. package/dist/lib/db/control-plane.js +10 -0
  125. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  126. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  127. package/dist/lib/hook-engine/resolve.d.ts +14 -0
  128. package/dist/lib/hook-engine/resolve.js +55 -1
  129. package/dist/lib/index-render/company-knowledge.js +1 -3
  130. package/dist/lib/index-render/projects.js +1 -3
  131. package/dist/lib/lanes/account-company.d.ts +28 -0
  132. package/dist/lib/lanes/account-company.js +53 -0
  133. package/dist/lib/lanes/accounts.d.ts +22 -3
  134. package/dist/lib/lanes/accounts.js +38 -2
  135. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  136. package/dist/lib/lanes/claude-balance.js +6 -1
  137. package/dist/lib/lanes/claude-credential.d.ts +78 -0
  138. package/dist/lib/lanes/claude-credential.js +163 -0
  139. package/dist/lib/lanes/compaction.d.ts +2 -0
  140. package/dist/lib/lanes/compaction.js +9 -0
  141. package/dist/lib/lanes/config.js +6 -0
  142. package/dist/lib/lanes/envelope-repair-dispatch.js +2 -1
  143. package/dist/lib/lanes/hooks-install.js +2 -1
  144. package/dist/lib/lanes/host-capacity.d.ts +2 -0
  145. package/dist/lib/lanes/host-capacity.js +1 -0
  146. package/dist/lib/lanes/keep-alive.d.ts +1 -1
  147. package/dist/lib/lanes/keep-alive.js +3 -1
  148. package/dist/lib/lanes/links.js +10 -4
  149. package/dist/lib/lanes/loop.js +16 -1
  150. package/dist/lib/lanes/node-binary.d.ts +17 -0
  151. package/dist/lib/lanes/node-binary.js +30 -0
  152. package/dist/lib/lanes/outpost-dispatch.d.ts +2 -1
  153. package/dist/lib/lanes/outpost-dispatch.js +1 -0
  154. package/dist/lib/lanes/reconcile.js +4 -0
  155. package/dist/lib/lanes/remote-control-lane.js +2 -0
  156. package/dist/lib/lanes/spawn.d.ts +3 -0
  157. package/dist/lib/lanes/spawn.js +119 -9
  158. package/dist/lib/lanes/store.d.ts +6 -0
  159. package/dist/lib/lanes/store.js +107 -18
  160. package/dist/lib/lanes/types.d.ts +2 -0
  161. package/dist/lib/lanes/watch.js +28 -5
  162. package/dist/lib/lanes/watcher-main.js +4 -3
  163. package/dist/lib/lanes/watcher.d.ts +4 -0
  164. package/dist/lib/lanes/watcher.js +110 -21
  165. package/dist/lib/mesh/api.d.ts +32 -0
  166. package/dist/lib/mesh/api.js +75 -0
  167. package/dist/lib/plan-limit-nag.d.ts +0 -16
  168. package/dist/lib/plan-limit-nag.js +17 -18
  169. package/dist/lib/secret-scan/detect.js +9 -0
  170. package/dist/lib/secret-scan/repo-config.d.ts +41 -0
  171. package/dist/lib/secret-scan/repo-config.js +237 -0
  172. package/dist/lib/secret-scan/rules.d.ts +7 -1
  173. package/dist/lib/secret-scan/rules.js +3 -1
  174. package/dist/lib/secret-scan/staged.d.ts +5 -0
  175. package/dist/lib/secret-scan/staged.js +19 -2
  176. package/dist/lib/secret-scan/types.d.ts +2 -0
  177. package/dist/lib/sync/server-owned-local-only.d.ts +29 -0
  178. package/dist/lib/sync/server-owned-local-only.js +75 -0
  179. package/dist/main.d.ts +0 -2
  180. package/dist/main.js +19 -57
  181. package/dist/utils/package-use-lease.js +27 -9
  182. package/dist/utils/plan-gate-error.d.ts +30 -14
  183. package/dist/utils/plan-gate-error.js +60 -46
  184. package/dist/utils/team-upgrade.d.ts +19 -6
  185. package/dist/utils/team-upgrade.js +37 -39
  186. package/dist/utils/version-gate.d.ts +7 -0
  187. package/dist/utils/version-gate.js +66 -0
  188. package/package.json +8 -8
@@ -7,6 +7,8 @@ import { peekIdToken } from "../utils/id-token.js";
7
7
  import { redactAgentInboxItem as redactInboxCredentials } from "../lib/agent-kit/inbox-redact.js";
8
8
  import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
9
9
  import { dmForwardCliEnabled } from "./dm-forward-flag.js";
10
+ import { attachmentScopeForChannel, formatUploadedAttachments, personalScopeForCaller, uploadDmAttachments, validateAttachmentPaths, } from "./dm-attach.js";
11
+ import { ChannelNotFoundError, suggestChannels, withChannelSuggestions, } from "./channel-suggest.js";
10
12
  /**
11
13
  * Classify a recipient arg as an email or a person/agent uid. Mirrors the
12
14
  * email/uid heuristic used by `hq members`. Returns null for neither.
@@ -108,7 +110,57 @@ export function matchChannelsByName(channels, name) {
108
110
  return false;
109
111
  });
110
112
  }
111
- // ─── Channel @mentions ───────────────────────────────────────────────────────
113
+ /**
114
+ * Find the single contact whose display name (case-insensitive, or by slug)
115
+ * equals a bare recipient name. Email local parts are not matched: they are not
116
+ * the name the user sees, and matching them would divert channel posts. A bare word like `setup` used
117
+ * to go straight to a channel lookup, so `hq dm send setup` landed in a channel
118
+ * whose slug was `setup` even when a person by that name existed (feedback
119
+ * eba06f59). Returns null when nothing or more than one contact matches. Pure.
120
+ */
121
+ export function matchContactByName(contacts, name) {
122
+ const target = name.trim().toLowerCase();
123
+ const targetSlug = channelSlug(name);
124
+ if (!target)
125
+ return null;
126
+ const hits = contacts.filter((c) => {
127
+ if (!c.personUid)
128
+ return false;
129
+ const dn = c.displayName?.trim().toLowerCase();
130
+ return Boolean(dn && (dn === target || (targetSlug && channelSlug(dn) === targetSlug)));
131
+ });
132
+ const unique = [...new Map(hits.map((c) => [c.personUid, c])).values()];
133
+ return unique.length === 1 ? unique[0] : null;
134
+ }
135
+ async function fetchContacts(token) {
136
+ try {
137
+ const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
138
+ if (!res.ok)
139
+ return [];
140
+ const data = (await res.json());
141
+ return Array.isArray(data.contacts) ? data.contacts : [];
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
147
+ /**
148
+ * The confirmation line for a channel send. When the typed target is not the
149
+ * channel's own display name (for example `hq dm setup` matching a channel
150
+ * whose slug is `setup` but whose display name is `welcome`), say what it
151
+ * resolved to: the typed text, the channel's scope, and its id. Pure.
152
+ */
153
+ export function formatChannelPostedLine(channel, typed) {
154
+ const bareTyped = typed.trim().replace(/^#/, "");
155
+ const base = `Message posted to #${channel.name ?? bareTyped}`;
156
+ const typedSlug = channelSlug(bareTyped);
157
+ const nameSlug = channel.name ? channelSlug(channel.name) : typedSlug;
158
+ if (typedSlug === nameSlug)
159
+ return `${base}.`;
160
+ const scope = channel.scope ? `${channel.scope} channel` : "channel";
161
+ return `${base} (resolved '${bareTyped}' to ${scope} ${channel.channelId}).`;
162
+ }
163
+ // ─── Channel @mentions───────────────────────────────────────────────────────
112
164
  //
113
165
  // HQ Rooms mentions are STRUCTURED ONLY: the server never parses `@name` out of
114
166
  // the message body (see hq-pro src/vault-service/lib/room-agent-delivery.ts and
@@ -298,13 +350,18 @@ export function buildDmBody(args) {
298
350
  if (!rcpt) {
299
351
  throw new Error(`Invalid recipient '${args.recipient}': must be an email address, a personUid (prs_…), or an agentUid (agt_…).`);
300
352
  }
353
+ const attachments = args.attachments ?? [];
301
354
  const body = (args.message ?? "").trim();
302
- if (!body) {
355
+ // The server accepts an empty body when the message carries attachments.
356
+ if (!body && attachments.length === 0) {
303
357
  throw new Error("A message body is required: hq dm <recipient> <message>");
304
358
  }
305
359
  if (args.at && args.inDelay) {
306
360
  throw new Error("Use only one of --at or --in, not both.");
307
361
  }
362
+ if ((args.at || args.inDelay) && attachments.length > 0) {
363
+ throw new Error("--attach cannot be combined with a scheduled send (--at/--in); send the file now or schedule the text alone.");
364
+ }
308
365
  let deliverAt;
309
366
  if (args.at) {
310
367
  const when = new Date(args.at);
@@ -336,8 +393,36 @@ export function buildDmBody(args) {
336
393
  ...(details ? { details } : {}),
337
394
  ...(deliverAt ? { deliverAt } : {}),
338
395
  ...(audience ? { audience } : {}),
396
+ ...(attachments.length > 0 ? { attachments } : {}),
339
397
  };
340
398
  }
399
+ /**
400
+ * The other participant's uid for a 1:1 DM attachment key. A `prs_`/`agt_`
401
+ * recipient is already a uid; an email is looked up in the caller's contacts
402
+ * (GET /v1/notify/contacts), the same route `dm forward --from <email>` uses.
403
+ * There is no separate "person by email" route on the vault API, and a DM to
404
+ * someone outside your contacts is a connection request anyway, which the
405
+ * server holds without delivering files.
406
+ */
407
+ export async function resolveDmPeerUid(token, recipient) {
408
+ const rcpt = detectRecipient(recipient);
409
+ if (!rcpt)
410
+ throw new Error(`Invalid recipient '${recipient}'.`);
411
+ if (rcpt.toPersonUid)
412
+ return rcpt.toPersonUid;
413
+ const email = rcpt.toEmail.toLowerCase();
414
+ const res = await vaultApiFetch({ token, path: "/v1/notify/contacts" });
415
+ if (!res.ok) {
416
+ const err = (await res.json().catch(() => ({})));
417
+ throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
418
+ }
419
+ const data = (await res.json());
420
+ const peer = (data.contacts ?? []).find((c) => (c.email ?? "").toLowerCase() === email);
421
+ if (!peer?.personUid) {
422
+ throw new Error(`--attach needs ${recipient}'s person uid to place the file, and they are not in your contacts yet. Send the DM without --attach first (it becomes a connection request), or use their prs_ uid.`);
423
+ }
424
+ return peer.personUid;
425
+ }
341
426
  function friendlyDmError(status, code, fallback) {
342
427
  if (status === 401)
343
428
  return "Not authenticated — run `hq login` and try again.";
@@ -596,7 +681,10 @@ async function fetchChannelMessages(token, channelId, query = {}) {
596
681
  async function runChannelSend(channelName, message, opts) {
597
682
  try {
598
683
  let body = (message ?? "").trim();
599
- if (!body) {
684
+ // Validate attachments before anything touches the network; an empty body
685
+ // is allowed when files are attached.
686
+ const localFiles = validateAttachmentPaths(opts.attach ?? []);
687
+ if (!body && localFiles.length === 0) {
600
688
  console.error(chalk.red(`A message body is required: hq dm ${channelName} "<message>" (or hq dm --channel ${channelName} "<message>").`));
601
689
  process.exit(1);
602
690
  }
@@ -619,7 +707,7 @@ async function runChannelSend(channelName, message, opts) {
619
707
  const channels = await fetchChannels(token);
620
708
  const matches = matchChannelsByName(channels, channelName);
621
709
  if (matches.length === 0) {
622
- console.error(chalk.red(`No channel named '${channelName}' — run \`hq channels\` to see your channels.`));
710
+ console.error(chalk.red(withChannelSuggestions(`No channel named '${channelName}' — run \`hq channels\` to see your channels.`, suggestChannels(channels, channelName))));
623
711
  process.exit(1);
624
712
  }
625
713
  if (matches.length > 1) {
@@ -677,6 +765,24 @@ async function runChannelSend(channelName, message, opts) {
677
765
  payload.rootEventId = rootEventId;
678
766
  if (audience)
679
767
  payload.audience = audience;
768
+ // Upload after the channel and thread resolved (so a bad name never
769
+ // uploads anything) and before the post (so the post never names bytes
770
+ // that are not there). Company channels store in the company vault;
771
+ // personal channels in the caller's personal vault.
772
+ let uploaded = [];
773
+ if (localFiles.length > 0) {
774
+ const callerUid = callerFromToken(token).personUid;
775
+ const scopeUid = channel.companyUid?.trim()
776
+ ? attachmentScopeForChannel(channel, callerUid ?? "")
777
+ : personalScopeForCaller(callerUid);
778
+ uploaded = await uploadDmAttachments({
779
+ token,
780
+ scopeUid,
781
+ target: { scope: "chan", channelId: channel.channelId },
782
+ files: localFiles,
783
+ });
784
+ payload.attachments = uploaded;
785
+ }
680
786
  const sendRes = await vaultApiFetch({
681
787
  token,
682
788
  path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
@@ -692,7 +798,12 @@ async function runChannelSend(channelName, message, opts) {
692
798
  ? ` Mentioned ${mentions.map((m) => m.displayName).join(", ")}.`
693
799
  : "";
694
800
  const threadSuffix = rootEventId ? ` Reply in thread ${rootEventId}.` : "";
695
- console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}${threadSuffix}`));
801
+ const attachSuffix = uploaded.length > 0
802
+ ? ` Attached ${uploaded.length} file${uploaded.length === 1 ? "" : "s"}.`
803
+ : "";
804
+ console.log(chalk.green(`${formatChannelPostedLine(channel, channelName)}${mentionSuffix}${threadSuffix}${attachSuffix}`));
805
+ if (uploaded.length > 0)
806
+ console.log(chalk.dim(formatUploadedAttachments(uploaded)));
696
807
  }
697
808
  catch (err) {
698
809
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -719,14 +830,30 @@ async function runDmSend(recipient, message, opts) {
719
830
  process.exit(1);
720
831
  return;
721
832
  }
833
+ if (opts.attach && opts.attach.length > 0) {
834
+ // A group is created on the fly, so there is no conversation to place
835
+ // the file in before the send. Not supported in this release.
836
+ console.error(chalk.red("--attach is not supported for group DMs yet. Post the file in a named channel (hq dm <channel> --attach <path>) or DM each person."));
837
+ process.exit(1);
838
+ return;
839
+ }
722
840
  await runGroupSend(group, message);
723
841
  return;
724
842
  }
725
843
  // A bare name or #hash addresses a named DM channel.
726
844
  const channelName = parseChannelName(recipient);
727
845
  if (channelName !== null) {
728
- await runChannelSend(channelName, message, opts);
729
- return;
846
+ // A bare word (no '#') may name a person as well as a channel. Prefer the
847
+ // exact person and say so; '#name' or --channel always means the channel.
848
+ const person = recipient.trim().startsWith("#")
849
+ ? null
850
+ : matchContactByName(await fetchContacts(await ensureCognitoToken()), channelName);
851
+ if (!person?.personUid) {
852
+ await runChannelSend(channelName, message, opts);
853
+ return;
854
+ }
855
+ console.log(chalk.dim(`Resolved '${channelName}' to person ${person.displayName || person.email || person.personUid} (${person.personUid}). Use '#${channelName}' or --channel ${channelName} to post to a channel instead.`));
856
+ recipient = person.personUid;
730
857
  }
731
858
  if (opts.thread !== undefined) {
732
859
  console.error(chalk.red("--thread is only supported for channel messages (hq dm <channel> --thread <eventId|last> \"…\")."));
@@ -741,7 +868,10 @@ async function runDmSend(recipient, message, opts) {
741
868
  let details = opts.details;
742
869
  if (opts.detailsFile)
743
870
  details = readFileSync(opts.detailsFile, "utf8");
744
- const reqBody = buildDmBody({
871
+ // Validate the local files and every option combination before any
872
+ // network call, so a refused send uploads nothing.
873
+ const localFiles = validateAttachmentPaths(opts.attach ?? []);
874
+ const dryBody = buildDmBody({
745
875
  recipient,
746
876
  message: message ?? "",
747
877
  prompt,
@@ -750,8 +880,31 @@ async function runDmSend(recipient, message, opts) {
750
880
  inDelay: opts.in,
751
881
  now: Date.now(),
752
882
  audience: opts.audience,
883
+ // Placeholder entries only prove the option set is valid; the real
884
+ // entries come back from the upload below.
885
+ attachments: localFiles.map((f) => ({ vaultPath: "", companyUid: "", name: f.name, sizeBytes: f.sizeBytes, contentType: f.contentType })),
753
886
  });
754
887
  const token = await ensureCognitoToken();
888
+ let uploaded = [];
889
+ if (localFiles.length > 0) {
890
+ // A 1:1 attachment is stored in the SENDER's personal vault under the
891
+ // sorted participant pair; the server lets either member read it.
892
+ const callerUid = callerFromToken(token).personUid;
893
+ const scopeUid = personalScopeForCaller(callerUid);
894
+ const peerUid = await resolveDmPeerUid(token, recipient);
895
+ uploaded = await uploadDmAttachments({
896
+ token,
897
+ scopeUid,
898
+ target: { scope: "dm", uids: [scopeUid, peerUid] },
899
+ files: localFiles,
900
+ });
901
+ }
902
+ const reqBody = {
903
+ ...dryBody,
904
+ ...(uploaded.length > 0 ? { attachments: uploaded } : {}),
905
+ };
906
+ if (uploaded.length === 0)
907
+ delete reqBody.attachments;
755
908
  const res = await vaultApiFetch({
756
909
  token,
757
910
  path: "/v1/notify/dm",
@@ -777,7 +930,12 @@ async function runDmSend(recipient, message, opts) {
777
930
  console.log(chalk.dim("It delivers within ~60s of that time, even if you're offline."));
778
931
  }
779
932
  else {
780
- console.log(chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`));
933
+ const attachSuffix = uploaded.length > 0
934
+ ? ` Attached ${uploaded.length} file${uploaded.length === 1 ? "" : "s"}.`
935
+ : "";
936
+ console.log(chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).${attachSuffix}`));
937
+ if (uploaded.length > 0)
938
+ console.log(chalk.dim(formatUploadedAttachments(uploaded)));
781
939
  }
782
940
  }
783
941
  catch (err) {
@@ -1460,7 +1618,7 @@ async function resolveChannel(token, target) {
1460
1618
  const byId = channels.find((c) => c.channelId === raw);
1461
1619
  if (byId)
1462
1620
  return byId;
1463
- throw new Error(`No channel named or with id '${raw}' — run \`hq channels\` to see yours.`);
1621
+ throw new ChannelNotFoundError(`No channel named or with id '${raw}' — run \`hq channels\` to see yours.`, suggestChannels(channels, raw));
1464
1622
  }
1465
1623
  async function runDmChannel(target, opts) {
1466
1624
  try {
@@ -1529,6 +1687,9 @@ async function runDmChannel(target, opts) {
1529
1687
  }
1530
1688
  }
1531
1689
  catch (err) {
1690
+ if (opts.json && err instanceof ChannelNotFoundError) {
1691
+ console.log(JSON.stringify({ ok: false, error: err.baseMessage, suggestions: err.suggestions }, null, 2));
1692
+ }
1532
1693
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
1533
1694
  process.exit(1);
1534
1695
  }
@@ -1550,6 +1711,7 @@ export function registerDmCommand(program) {
1550
1711
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time; 1:1 DMs only)")
1551
1712
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d (1:1 DMs only)")
1552
1713
  .option("--audience <human|agent|both>", "Tag who this message is for: human (a person must read it), agent (progress/status, hidden from humans by default), or both")
1714
+ .option("--attach <path>", "Upload a local file and attach it to the message (repeatable, up to 5 files, 25 MB each). 1:1 DMs and named channels; the file lands in the conversation's vault scope and the recipient can open it. Not available for group DMs or scheduled sends.", (value, previous = []) => [...previous, value])
1553
1715
  .action(async (recipient, message, opts) => {
1554
1716
  await runDmSend(recipient, message, opts);
1555
1717
  });
@@ -0,0 +1,40 @@
1
+ import { Command } from "commander";
2
+ export declare function normalizeFeedbackId(raw: string): string;
3
+ export interface FeedbackAttachmentEntry {
4
+ /** Where the attachment came from: a CLI screenshot or an email-ticket message. */
5
+ source: "screenshot" | "email";
6
+ name: string;
7
+ key?: string;
8
+ contentType?: string;
9
+ bytes?: number;
10
+ downloadUrl?: string;
11
+ downloadPath?: string;
12
+ }
13
+ type Row = Record<string, unknown>;
14
+ /** Collect every attachment the row exposes, in a stable order. */
15
+ export declare function collectFeedbackAttachments(item: Row): FeedbackAttachmentEntry[];
16
+ /** The log bundle id (`logs-<uuid>`) from the row's logBundle key, if any. */
17
+ export declare function logBundleIdFor(item: Row): string | undefined;
18
+ /** Strip every download link so `--json` output never carries a presigned URL. */
19
+ export declare function redactDownloadLinks(item: Row): Row;
20
+ /** A safe local file name: no directory parts, no leading dots, unique in `used`. */
21
+ export declare function safeFileName(name: string, index: number, used: Set<string>): string;
22
+ export declare function fetchFeedbackRow(id: string, token: string): Promise<Row>;
23
+ export interface DownloadResult {
24
+ file?: string;
25
+ name: string;
26
+ bytes?: number;
27
+ error?: string;
28
+ }
29
+ export declare function downloadFeedbackAttachments(opts: {
30
+ id: string;
31
+ item: Row;
32
+ dir: string;
33
+ token: string;
34
+ includeLogs: boolean;
35
+ fetchImpl?: typeof fetch;
36
+ }): Promise<DownloadResult[]>;
37
+ export declare function formatFeedbackRow(item: Row): string;
38
+ export declare function registerFeedbackShowCommand(feedbackCmd: Command): void;
39
+ export {};
40
+ //# sourceMappingURL=feedback-show.d.ts.map
@@ -0,0 +1,261 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import chalk from "chalk";
4
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
5
+ import { vaultApiFetch } from "../utils/vault-api.js";
6
+ /**
7
+ * `hq feedback show <id>` — operator-only fetch of one feedback row and its
8
+ * attachments (CLI screenshots, email-ticket attachments, the log bundle).
9
+ *
10
+ * Access is enforced by hq-pro: `GET /v1/feedback/{id}` refuses non-operators
11
+ * with 403, and only staff callers receive download links. This command never
12
+ * prints a download URL or a token; with `--download` it writes the files to a
13
+ * local directory instead.
14
+ */
15
+ const FEEDBACK_ID_RE = /^(?:feedback_)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
16
+ const LOG_BUNDLE_ID_RE = /^logs-[0-9a-f-]{36}$/i;
17
+ export function normalizeFeedbackId(raw) {
18
+ const match = FEEDBACK_ID_RE.exec(raw.trim());
19
+ if (!match) {
20
+ throw new Error(`Invalid feedback id '${raw}'. Use feedback_<uuid> or the bare uuid.`);
21
+ }
22
+ return `feedback_${match[1].toLowerCase()}`;
23
+ }
24
+ function str(value) {
25
+ return typeof value === "string" && value.length > 0 ? value : undefined;
26
+ }
27
+ function baseName(key) {
28
+ const parts = key.split("/").filter(Boolean);
29
+ return parts[parts.length - 1] ?? key;
30
+ }
31
+ /** Collect every attachment the row exposes, in a stable order. */
32
+ export function collectFeedbackAttachments(item) {
33
+ const out = [];
34
+ const linked = Array.isArray(item.screenshotDownloads) ? item.screenshotDownloads : [];
35
+ const byKey = new Map();
36
+ for (const entry of linked) {
37
+ if (entry && typeof entry === "object" && typeof entry.key === "string") {
38
+ byKey.set(entry.key, entry);
39
+ }
40
+ }
41
+ const screenshots = Array.isArray(item.screenshots) ? item.screenshots : [];
42
+ for (const key of screenshots) {
43
+ if (typeof key !== "string")
44
+ continue;
45
+ const link = byKey.get(key);
46
+ out.push({
47
+ source: "screenshot",
48
+ name: baseName(key),
49
+ key,
50
+ ...(link && str(link.downloadUrl) ? { downloadUrl: link.downloadUrl } : {}),
51
+ });
52
+ }
53
+ const messages = Array.isArray(item.messages) ? item.messages : [];
54
+ for (const message of messages) {
55
+ if (!message || typeof message !== "object")
56
+ continue;
57
+ const attachments = message.attachments;
58
+ if (!Array.isArray(attachments))
59
+ continue;
60
+ for (const raw of attachments) {
61
+ if (!raw || typeof raw !== "object")
62
+ continue;
63
+ const a = raw;
64
+ const key = str(a.key);
65
+ out.push({
66
+ source: "email",
67
+ name: str(a.filename) ?? (key ? baseName(key) : "attachment"),
68
+ ...(key ? { key } : {}),
69
+ ...(str(a.contentType) ? { contentType: a.contentType } : {}),
70
+ ...(typeof a.bytes === "number" ? { bytes: a.bytes } : {}),
71
+ ...(str(a.downloadUrl) ? { downloadUrl: a.downloadUrl } : {}),
72
+ ...(str(a.downloadPath) ? { downloadPath: a.downloadPath } : {}),
73
+ });
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /** The log bundle id (`logs-<uuid>`) from the row's logBundle key, if any. */
79
+ export function logBundleIdFor(item) {
80
+ const bundle = item.logBundle;
81
+ if (!bundle || typeof bundle !== "object")
82
+ return undefined;
83
+ const key = str(bundle.key);
84
+ if (!key)
85
+ return undefined;
86
+ const id = baseName(key).split(".")[0] ?? "";
87
+ return LOG_BUNDLE_ID_RE.test(id) ? id : undefined;
88
+ }
89
+ /** Strip every download link so `--json` output never carries a presigned URL. */
90
+ export function redactDownloadLinks(item) {
91
+ return JSON.parse(JSON.stringify(item, (k, v) => (k === "downloadUrl" || k === "url" ? undefined : v)));
92
+ }
93
+ /** A safe local file name: no directory parts, no leading dots, unique in `used`. */
94
+ export function safeFileName(name, index, used) {
95
+ let base = path.basename(name).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
96
+ if (!base)
97
+ base = `attachment-${index}`;
98
+ let candidate = `${index}-${base}`;
99
+ let n = 1;
100
+ while (used.has(candidate))
101
+ candidate = `${index}-${n++}-${base}`;
102
+ used.add(candidate);
103
+ return candidate;
104
+ }
105
+ async function errorText(res) {
106
+ const data = (await res.json().catch(() => ({})));
107
+ return typeof data?.error === "string" ? data.error : res.statusText;
108
+ }
109
+ export async function fetchFeedbackRow(id, token) {
110
+ const res = await vaultApiFetch({ path: `/v1/feedback/${id}`, token });
111
+ if (res.status === 401 || res.status === 403) {
112
+ throw new Error("hq feedback show is for HQ operators only. Your login is not allowed to read feedback rows.");
113
+ }
114
+ if (res.status === 404)
115
+ throw new Error(`Feedback '${id}' not found.`);
116
+ if (!res.ok)
117
+ throw new Error(`Failed to fetch feedback: ${await errorText(res)}`);
118
+ const data = (await res.json());
119
+ if (!data.item || typeof data.item !== "object")
120
+ throw new Error("Malformed feedback response.");
121
+ return data.item;
122
+ }
123
+ async function downloadUrlTo(url, file, fetchImpl) {
124
+ const res = await fetchImpl(url);
125
+ if (!res.ok)
126
+ throw new Error(`download failed (HTTP ${res.status})`);
127
+ const bytes = Buffer.from(await res.arrayBuffer());
128
+ fs.writeFileSync(file, bytes);
129
+ return bytes.byteLength;
130
+ }
131
+ export async function downloadFeedbackAttachments(opts) {
132
+ const fetchImpl = opts.fetchImpl ?? fetch;
133
+ fs.mkdirSync(opts.dir, { recursive: true });
134
+ const used = new Set();
135
+ const results = [];
136
+ const attachments = collectFeedbackAttachments(opts.item);
137
+ for (const [index, a] of attachments.entries()) {
138
+ const file = path.join(opts.dir, safeFileName(a.name, index, used));
139
+ try {
140
+ if (a.downloadUrl) {
141
+ results.push({ file, name: a.name, bytes: await downloadUrlTo(a.downloadUrl, file, fetchImpl) });
142
+ }
143
+ else if (a.downloadPath && a.downloadPath.startsWith(`/v1/feedback/${opts.id}?`)) {
144
+ const [p, q] = a.downloadPath.split("?");
145
+ const query = Object.fromEntries(new URLSearchParams(q ?? ""));
146
+ const res = await vaultApiFetch({ path: p, query, token: opts.token });
147
+ if (!res.ok)
148
+ throw new Error(await errorText(res));
149
+ const data = (await res.json());
150
+ if (typeof data.contentBase64 !== "string")
151
+ throw new Error("no content returned");
152
+ const bytes = Buffer.from(data.contentBase64, "base64");
153
+ fs.writeFileSync(file, bytes);
154
+ results.push({ file, name: a.name, bytes: bytes.byteLength });
155
+ }
156
+ else {
157
+ results.push({ name: a.name, error: "no download link (operator access or the attachment-download flag is off)" });
158
+ }
159
+ }
160
+ catch (err) {
161
+ results.push({ name: a.name, error: err instanceof Error ? err.message : String(err) });
162
+ }
163
+ }
164
+ const bundleId = opts.includeLogs ? logBundleIdFor(opts.item) : undefined;
165
+ if (bundleId) {
166
+ const name = baseName(opts.item.logBundle.key);
167
+ try {
168
+ const res = await vaultApiFetch({ path: `/v1/feedback/${opts.id}/logs/${bundleId}/view`, token: opts.token });
169
+ if (!res.ok)
170
+ throw new Error(await errorText(res));
171
+ const data = (await res.json());
172
+ if (typeof data.url !== "string")
173
+ throw new Error("no link returned");
174
+ const file = path.join(opts.dir, safeFileName(name, attachments.length, used));
175
+ results.push({ file, name, bytes: await downloadUrlTo(data.url, file, fetchImpl) });
176
+ }
177
+ catch (err) {
178
+ results.push({ name, error: err instanceof Error ? err.message : String(err) });
179
+ }
180
+ }
181
+ return results;
182
+ }
183
+ export function formatFeedbackRow(item) {
184
+ const lines = [];
185
+ const field = (label, value) => {
186
+ if (value !== undefined && value !== null && value !== "")
187
+ lines.push(`${label}: ${String(value)}`);
188
+ };
189
+ field("ID", item.id);
190
+ field("Title", item.title);
191
+ field("Type", item.type);
192
+ field("Status", item.status);
193
+ field("Company", item.company ?? item.companySlug);
194
+ field("Reporter", item.email);
195
+ field("Submitted", item.submittedAt);
196
+ field("Linear", item.linearIssueIdentifier);
197
+ const attachments = collectFeedbackAttachments(item);
198
+ lines.push(`Attachments: ${attachments.length}`);
199
+ for (const a of attachments) {
200
+ const meta = [a.source, a.contentType, typeof a.bytes === "number" ? `${a.bytes} bytes` : undefined]
201
+ .filter(Boolean)
202
+ .join(", ");
203
+ const link = a.downloadUrl || a.downloadPath ? "downloadable" : "no link";
204
+ lines.push(` - ${a.name} (${meta}; ${link})`);
205
+ }
206
+ const bundle = item.logBundle;
207
+ if (bundle && typeof bundle === "object") {
208
+ const size = typeof bundle.sizeBytes === "number" ? `, ${bundle.sizeBytes} bytes` : "";
209
+ lines.push(`Log bundle: ${baseName(String(bundle.key ?? ""))}${size} (use --download <dir> --logs)`);
210
+ }
211
+ if (typeof item.body === "string")
212
+ lines.push("", item.body);
213
+ return lines.join("\n");
214
+ }
215
+ export function registerFeedbackShowCommand(feedbackCmd) {
216
+ feedbackCmd
217
+ .command("show <id>")
218
+ .description("Operators only: show one feedback row and fetch its attachments")
219
+ .option("--download <dir>", "Save screenshots and attachments into this directory")
220
+ .option("--logs", "With --download, also save the row's log bundle")
221
+ .option("--json", "Print the row as JSON (download links removed)")
222
+ .action(async (rawId, opts) => {
223
+ try {
224
+ const id = normalizeFeedbackId(rawId);
225
+ const token = await ensureCognitoToken({ interactive: false });
226
+ const item = await fetchFeedbackRow(id, token);
227
+ if (opts.json) {
228
+ console.log(JSON.stringify(redactDownloadLinks(item), null, 2));
229
+ }
230
+ else {
231
+ console.log(formatFeedbackRow(item));
232
+ }
233
+ if (opts.download) {
234
+ const results = await downloadFeedbackAttachments({
235
+ id,
236
+ item,
237
+ dir: path.resolve(opts.download),
238
+ token,
239
+ includeLogs: opts.logs === true,
240
+ });
241
+ let failed = 0;
242
+ for (const r of results) {
243
+ if (r.error) {
244
+ failed++;
245
+ console.error(chalk.yellow(`Not saved: ${r.name}: ${r.error}`));
246
+ }
247
+ else {
248
+ console.error(`Saved ${r.file} (${r.bytes} bytes)`);
249
+ }
250
+ }
251
+ if (failed > 0)
252
+ process.exitCode = 1;
253
+ }
254
+ }
255
+ catch (err) {
256
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
257
+ process.exit(1);
258
+ }
259
+ });
260
+ }
261
+ //# sourceMappingURL=feedback-show.js.map
@@ -20,6 +20,8 @@ export declare function validateFeedbackContent(title: string, body: string): vo
20
20
  export declare const REQUEST_MAX_BYTES: number;
21
21
  export interface FeedbackResult {
22
22
  id: string;
23
+ /** Public tracking page (status.hqforwork.com/t/<token>); absent on older servers. */
24
+ publicUrl?: string;
23
25
  }
24
26
  export interface FeedbackSubmitOptions {
25
27
  type: "bug" | "feature";
@@ -70,5 +72,24 @@ export declare function attachDebugLogs(requestBody: Record<string, unknown>, di
70
72
  envEnabled?: typeof feedbackLogsEnabled;
71
73
  }): void;
72
74
  export declare function submitFeedback(opts: FeedbackSubmitOptions): Promise<FeedbackResult>;
75
+ /** Customer-visible states accepted by `hq feedback update --state`. */
76
+ export declare const PUBLIC_TICKET_STATES: readonly ["triaged", "in-progress", "shipped", "verified", "resolved"];
77
+ export type PublicTicketState = (typeof PUBLIC_TICKET_STATES)[number];
78
+ export declare function assertFeedbackId(id: string): void;
79
+ /** Resolve (and lazily mint) the public tracking link for a ticket. */
80
+ export declare function fetchFeedbackLink(opts: {
81
+ id: string;
82
+ token: string;
83
+ }): Promise<string>;
84
+ /** Post a customer-visible update; the server also mirrors it into #hq-feedback. */
85
+ export declare function postFeedbackUpdate(opts: {
86
+ id: string;
87
+ token: string;
88
+ state: PublicTicketState;
89
+ note: string;
90
+ }): Promise<{
91
+ publicUrl: string | null;
92
+ slack: string | null;
93
+ }>;
73
94
  export declare function registerFeedbackCommand(program: Command): void;
74
95
  //# sourceMappingURL=feedback.d.ts.map