@indigoai-us/hq-cli 5.36.1 → 5.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,5 +37,38 @@ export declare function buildDmBody(args: {
37
37
  inDelay?: string;
38
38
  now: number;
39
39
  }): DmSendBody;
40
+ /** A pending incoming connection request, as returned by the backend. */
41
+ export interface ConnectionRequest {
42
+ pairKey: string;
43
+ fromPersonUid: string;
44
+ fromDisplayName?: string;
45
+ fromEmail?: string;
46
+ message?: string;
47
+ createdAt?: string;
48
+ }
49
+ /**
50
+ * Resolve a connection request from a pending-requests list by a caller-supplied
51
+ * identifier (email, personUid, or pairKey). Pure → unit-testable.
52
+ *
53
+ * Matching is case-insensitive for emails. Returns the matched request, or null
54
+ * if no request matches.
55
+ */
56
+ export declare function matchRequest(requests: ConnectionRequest[], identifier: string): ConnectionRequest | null;
57
+ /**
58
+ * Build the POST body for an accept/decline/block action. Prefers a resolved
59
+ * pairKey (from a matched pending request); otherwise falls back to the
60
+ * recipient identifier the backend can resolve (`withPersonUid` for a prs_ UID,
61
+ * `toEmail`-style is not supported by the connections endpoints so we send
62
+ * `withEmail`). Pure → unit-testable.
63
+ *
64
+ * Throws Error with a user-facing message when the identifier can't be resolved.
65
+ */
66
+ export declare function buildConnectionActionBody(identifier: string, matched: ConnectionRequest | null): {
67
+ pairKey: string;
68
+ } | {
69
+ withPersonUid: string;
70
+ } | {
71
+ withEmail: string;
72
+ };
40
73
  export declare function registerDmCommand(program: Command): void;
41
74
  //# sourceMappingURL=dm.d.ts.map
@@ -1,5 +1,5 @@
1
1
 
2
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="69d48194-d5ae-508e-b614-68f2066db6ee")}catch(e){}}();
2
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="19c5d8e0-ec0a-5b57-b3dc-e2209b60cf2f")}catch(e){}}();
3
3
  import chalk from "chalk";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { ensureCognitoToken } from "../utils/cognito-session.js";
@@ -89,10 +89,179 @@ function friendlyDmError(status, code, fallback) {
89
89
  return `Server error: ${fallback}`;
90
90
  return fallback;
91
91
  }
92
+ /**
93
+ * Resolve a connection request from a pending-requests list by a caller-supplied
94
+ * identifier (email, personUid, or pairKey). Pure → unit-testable.
95
+ *
96
+ * Matching is case-insensitive for emails. Returns the matched request, or null
97
+ * if no request matches.
98
+ */
99
+ export function matchRequest(requests, identifier) {
100
+ const id = identifier.trim();
101
+ const idLower = id.toLowerCase();
102
+ for (const r of requests) {
103
+ if (r.pairKey === id)
104
+ return r;
105
+ if (r.fromPersonUid === id)
106
+ return r;
107
+ if (r.fromEmail && r.fromEmail.toLowerCase() === idLower)
108
+ return r;
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * Build the POST body for an accept/decline/block action. Prefers a resolved
114
+ * pairKey (from a matched pending request); otherwise falls back to the
115
+ * recipient identifier the backend can resolve (`withPersonUid` for a prs_ UID,
116
+ * `toEmail`-style is not supported by the connections endpoints so we send
117
+ * `withEmail`). Pure → unit-testable.
118
+ *
119
+ * Throws Error with a user-facing message when the identifier can't be resolved.
120
+ */
121
+ export function buildConnectionActionBody(identifier, matched) {
122
+ if (matched) {
123
+ return { pairKey: matched.pairKey };
124
+ }
125
+ const r = identifier.trim();
126
+ const rcpt = detectRecipient(r);
127
+ if (rcpt?.toPersonUid)
128
+ return { withPersonUid: rcpt.toPersonUid };
129
+ if (rcpt?.toEmail)
130
+ return { withEmail: rcpt.toEmail };
131
+ throw new Error(`Could not resolve '${identifier}' to a pending request — pass the requester's email, personUid, or the pairKey from \`hq dm requests\`.`);
132
+ }
133
+ const CONNECTION_ACTIONS = {
134
+ accept: { path: "accept", done: "Connection accepted" },
135
+ decline: { path: "decline", done: "Request declined" },
136
+ block: { path: "block", done: "Blocked" },
137
+ };
138
+ /** Fetch the caller's pending incoming connection requests. */
139
+ async function fetchRequests(token) {
140
+ const res = await vaultApiFetch({
141
+ token,
142
+ path: "/v1/notify/connections/requests",
143
+ });
144
+ if (!res.ok) {
145
+ const err = (await res.json().catch(() => ({})));
146
+ throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
147
+ }
148
+ const data = (await res.json());
149
+ return data.requests ?? [];
150
+ }
151
+ async function runConnectionAction(action, identifier) {
152
+ try {
153
+ const token = await ensureCognitoToken();
154
+ // Resolve the pairKey from the caller's pending requests when possible so
155
+ // accept/decline/block work against a friendly email or personUid.
156
+ const requests = await fetchRequests(token).catch(() => []);
157
+ const matched = matchRequest(requests, identifier);
158
+ const body = buildConnectionActionBody(identifier, matched);
159
+ const res = await vaultApiFetch({
160
+ token,
161
+ path: `/v1/notify/connections/${CONNECTION_ACTIONS[action].path}`,
162
+ method: "POST",
163
+ body: body,
164
+ });
165
+ if (!res.ok) {
166
+ const err = (await res.json().catch(() => ({})));
167
+ console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
168
+ process.exit(1);
169
+ }
170
+ const who = matched?.fromDisplayName ?? matched?.fromEmail ?? identifier;
171
+ console.log(chalk.green(`${CONNECTION_ACTIONS[action].done} — ${who}.`));
172
+ if (action === "accept" && matched?.message) {
173
+ console.log(chalk.dim("Their held message will now be delivered."));
174
+ }
175
+ }
176
+ catch (err) {
177
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
178
+ process.exit(1);
179
+ }
180
+ }
181
+ async function runDmSend(recipient, message, opts) {
182
+ try {
183
+ // Resolve prompt/details from inline text or a file.
184
+ let prompt = opts.prompt;
185
+ if (opts.promptFile)
186
+ prompt = readFileSync(opts.promptFile, "utf8");
187
+ let details = opts.details;
188
+ if (opts.detailsFile)
189
+ details = readFileSync(opts.detailsFile, "utf8");
190
+ const reqBody = buildDmBody({
191
+ recipient,
192
+ message: message ?? "",
193
+ prompt,
194
+ details,
195
+ at: opts.at,
196
+ inDelay: opts.in,
197
+ now: Date.now(),
198
+ });
199
+ const token = await ensureCognitoToken();
200
+ const res = await vaultApiFetch({
201
+ token,
202
+ path: "/v1/notify/dm",
203
+ method: "POST",
204
+ body: reqBody,
205
+ });
206
+ if (!res.ok) {
207
+ const err = (await res.json().catch(() => ({})));
208
+ console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
209
+ process.exit(1);
210
+ }
211
+ // 202 connection_requested: the recipient isn't connected yet, so the DM is
212
+ // held as a connection request rather than delivered. This is a success, not
213
+ // an error — surface it quietly.
214
+ const data = (await res.json());
215
+ if (res.status === 202 || data.state === "connection_requested") {
216
+ console.log(chalk.green(`Request sent — pending ${recipient}'s acceptance.`));
217
+ console.log(chalk.dim("Your message is held and delivers automatically once they accept."));
218
+ return;
219
+ }
220
+ if (data.scheduled) {
221
+ console.log(chalk.green(`Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`));
222
+ console.log(chalk.dim("It delivers within ~60s of that time, even if you're offline."));
223
+ }
224
+ else {
225
+ console.log(chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`));
226
+ }
227
+ }
228
+ catch (err) {
229
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
230
+ process.exit(1);
231
+ }
232
+ }
233
+ async function runDmRequests() {
234
+ try {
235
+ const token = await ensureCognitoToken();
236
+ const requests = await fetchRequests(token);
237
+ if (requests.length === 0) {
238
+ console.log(chalk.dim("No pending connection requests."));
239
+ return;
240
+ }
241
+ console.log(chalk.green(`${requests.length} pending connection request${requests.length === 1 ? "" : "s"}:`));
242
+ for (const r of requests) {
243
+ const name = r.fromDisplayName ?? r.fromEmail ?? r.fromPersonUid;
244
+ const emailPart = r.fromEmail ? chalk.dim(` <${r.fromEmail}>`) : "";
245
+ console.log(`\n ${chalk.bold(name)}${emailPart}`);
246
+ if (r.message) {
247
+ console.log(` ${chalk.dim('"' + r.message + '"')}`);
248
+ }
249
+ const idHint = r.fromEmail ?? r.fromPersonUid ?? r.pairKey;
250
+ console.log(chalk.dim(` Accept: hq dm accept ${idHint} · Decline: hq dm decline ${idHint}`));
251
+ }
252
+ }
253
+ catch (err) {
254
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
255
+ process.exit(1);
256
+ }
257
+ }
92
258
  export function registerDmCommand(program) {
93
- program
94
- .command("dm <recipient> [message]")
95
- .description("Send a direct message to a teammate (email or personUid). They receive it as an HQ Sync notification.")
259
+ const dm = program
260
+ .command("dm")
261
+ .description("Send a direct message and manage connection requests.");
262
+ dm
263
+ .command("send <recipient> [message]", { isDefault: true, hidden: true })
264
+ .description("Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren't connected yet, it sends a connection request that holds your message.")
96
265
  .option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent")
97
266
  .option("--prompt-file <path>", "Read the agent prompt from a file")
98
267
  .option("--details <text>", "Longer detail shown in the recipient's DM detail window")
@@ -100,49 +269,32 @@ export function registerDmCommand(program) {
100
269
  .option("--at <iso>", "Schedule delivery at an ISO8601 time (store-and-forward; delivered within ~60s of the time)")
101
270
  .option("--in <duration>", "Schedule delivery after a relative delay: 30s, 10m, 2h, 1d")
102
271
  .action(async (recipient, message, opts) => {
103
- try {
104
- // Resolve prompt/details from inline text or a file.
105
- let prompt = opts.prompt;
106
- if (opts.promptFile)
107
- prompt = readFileSync(opts.promptFile, "utf8");
108
- let details = opts.details;
109
- if (opts.detailsFile)
110
- details = readFileSync(opts.detailsFile, "utf8");
111
- const reqBody = buildDmBody({
112
- recipient,
113
- message: message ?? "",
114
- prompt,
115
- details,
116
- at: opts.at,
117
- inDelay: opts.in,
118
- now: Date.now(),
119
- });
120
- const token = await ensureCognitoToken();
121
- const res = await vaultApiFetch({
122
- token,
123
- path: "/v1/notify/dm",
124
- method: "POST",
125
- body: reqBody,
126
- });
127
- if (!res.ok) {
128
- const err = (await res.json().catch(() => ({})));
129
- console.error(chalk.red(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText)));
130
- process.exit(1);
131
- }
132
- const data = (await res.json());
133
- if (data.scheduled) {
134
- console.log(chalk.green(`Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`));
135
- console.log(chalk.dim("It delivers within ~60s of that time, even if you're offline."));
136
- }
137
- else {
138
- console.log(chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`));
139
- }
140
- }
141
- catch (err) {
142
- console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
143
- process.exit(1);
144
- }
272
+ await runDmSend(recipient, message, opts);
273
+ });
274
+ dm
275
+ .command("requests")
276
+ .description("List your pending incoming connection requests.")
277
+ .action(async () => {
278
+ await runDmRequests();
279
+ });
280
+ dm
281
+ .command("accept <person>")
282
+ .description("Accept a pending connection request (by email, personUid, or pairKey). Their held message is then delivered.")
283
+ .action(async (person) => {
284
+ await runConnectionAction("accept", person);
285
+ });
286
+ dm
287
+ .command("decline <person>")
288
+ .description("Decline a pending connection request (by email, personUid, or pairKey).")
289
+ .action(async (person) => {
290
+ await runConnectionAction("decline", person);
291
+ });
292
+ dm
293
+ .command("block <person>")
294
+ .description("Block a person so they can't send you further requests (by email, personUid, or pairKey).")
295
+ .action(async (person) => {
296
+ await runConnectionAction("block", person);
145
297
  });
146
298
  }
147
299
  //# sourceMappingURL=dm.js.map
148
- //# debugId=69d48194-d5ae-508e-b614-68f2066db6ee
300
+ //# debugId=19c5d8e0-ec0a-5b57-b3dc-e2209b60cf2f
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.36.1",
3
+ "version": "5.36.2",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,5 +1,32 @@
1
- import { describe, it, expect } from "vitest";
2
- import { detectRecipient, parseDuration, buildDmBody } from "./dm.js";
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ vi,
8
+ type MockInstance,
9
+ } from "vitest";
10
+
11
+ // Stub ensureCognitoToken so the action handlers run without keychain/disk I/O.
12
+ vi.mock("../utils/cognito-session.js", async (importOriginal) => {
13
+ const original = (await importOriginal()) as Record<string, unknown>;
14
+ return {
15
+ ...original,
16
+ ensureCognitoToken: vi.fn(async () => "test-token"),
17
+ };
18
+ });
19
+
20
+ import { Command } from "commander";
21
+ import {
22
+ detectRecipient,
23
+ parseDuration,
24
+ buildDmBody,
25
+ matchRequest,
26
+ buildConnectionActionBody,
27
+ registerDmCommand,
28
+ type ConnectionRequest,
29
+ } from "./dm.js";
3
30
 
4
31
  describe("detectRecipient", () => {
5
32
  it("classifies an email", () => {
@@ -86,3 +113,185 @@ describe("buildDmBody", () => {
86
113
  expect(() => buildDmBody({ recipient: "a@b.com", message: "m", inDelay: "soon", now })).toThrow(/Invalid --in/);
87
114
  });
88
115
  });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // matchRequest (pure)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ const SAMPLE_REQUESTS: ConnectionRequest[] = [
122
+ {
123
+ pairKey: "pair_abc",
124
+ fromPersonUid: "prs_alice",
125
+ fromDisplayName: "Alice",
126
+ fromEmail: "alice@example.com",
127
+ message: "hey, lets connect",
128
+ createdAt: "2026-06-01T00:00:00.000Z",
129
+ },
130
+ {
131
+ pairKey: "pair_def",
132
+ fromPersonUid: "prs_bob",
133
+ fromDisplayName: "Bob",
134
+ fromEmail: "Bob@Example.com",
135
+ },
136
+ ];
137
+
138
+ describe("matchRequest", () => {
139
+ it("matches by email case-insensitively", () => {
140
+ expect(matchRequest(SAMPLE_REQUESTS, "ALICE@example.com")?.pairKey).toBe("pair_abc");
141
+ expect(matchRequest(SAMPLE_REQUESTS, "bob@example.com")?.pairKey).toBe("pair_def");
142
+ });
143
+ it("matches by personUid", () => {
144
+ expect(matchRequest(SAMPLE_REQUESTS, "prs_bob")?.pairKey).toBe("pair_def");
145
+ });
146
+ it("matches by pairKey", () => {
147
+ expect(matchRequest(SAMPLE_REQUESTS, "pair_abc")?.fromPersonUid).toBe("prs_alice");
148
+ });
149
+ it("returns null when nothing matches", () => {
150
+ expect(matchRequest(SAMPLE_REQUESTS, "nobody@example.com")).toBeNull();
151
+ });
152
+ });
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // buildConnectionActionBody (pure)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("buildConnectionActionBody", () => {
159
+ it("prefers the matched request's pairKey", () => {
160
+ expect(buildConnectionActionBody("alice@example.com", SAMPLE_REQUESTS[0])).toEqual({
161
+ pairKey: "pair_abc",
162
+ });
163
+ });
164
+ it("falls back to withPersonUid for an unmatched prs_ id", () => {
165
+ expect(buildConnectionActionBody("prs_zed", null)).toEqual({ withPersonUid: "prs_zed" });
166
+ });
167
+ it("falls back to withEmail for an unmatched email", () => {
168
+ expect(buildConnectionActionBody("Stranger@Example.com", null)).toEqual({
169
+ withEmail: "stranger@example.com",
170
+ });
171
+ });
172
+ it("throws on an unresolvable identifier", () => {
173
+ expect(() => buildConnectionActionBody("not-an-id", null)).toThrow(/Could not resolve/);
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Action handlers (HTTP/auth mocked)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function jsonResponse(status: number, body: unknown): Response {
182
+ return new Response(JSON.stringify(body), {
183
+ status,
184
+ headers: { "Content-Type": "application/json" },
185
+ });
186
+ }
187
+
188
+ describe("dm command actions", () => {
189
+ let fetchSpy: MockInstance<typeof fetch>;
190
+ let exitSpy: MockInstance<typeof process.exit>;
191
+ let logSpy: MockInstance<typeof console.log>;
192
+ let errSpy: MockInstance<typeof console.error>;
193
+ let program: Command;
194
+
195
+ beforeEach(() => {
196
+ fetchSpy = vi.spyOn(globalThis, "fetch");
197
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
198
+ throw new Error(`__EXIT__:${code ?? 0}`);
199
+ }) as never);
200
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
201
+ errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
202
+ program = new Command();
203
+ registerDmCommand(program);
204
+ });
205
+
206
+ afterEach(() => {
207
+ vi.restoreAllMocks();
208
+ });
209
+
210
+ function logged(): string {
211
+ return logSpy.mock.calls.map((c) => String(c[0])).join("\n");
212
+ }
213
+
214
+ it("send: prints delivered on 200", async () => {
215
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { eventId: "evt_1" }));
216
+ await program.parseAsync(["dm", "alice@example.com", "hi there"], { from: "user" });
217
+ const url = String(fetchSpy.mock.calls[0][0]);
218
+ expect(url).toContain("/v1/notify/dm");
219
+ expect(logged()).toMatch(/DM sent to alice@example.com/);
220
+ expect(errSpy).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it("send: prints pending request on 202 connection_requested (not an error)", async () => {
224
+ fetchSpy.mockResolvedValueOnce(
225
+ jsonResponse(202, { state: "connection_requested" }),
226
+ );
227
+ await program.parseAsync(["dm", "stranger@example.com", "hello"], { from: "user" });
228
+ expect(logged()).toMatch(/Request sent — pending stranger@example\.com's acceptance\./);
229
+ // 202 is a success, not an error path.
230
+ expect(exitSpy).not.toHaveBeenCalled();
231
+ expect(errSpy).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it("requests: lists pending incoming requests with identifiers", async () => {
235
+ fetchSpy.mockResolvedValueOnce(
236
+ jsonResponse(200, { requests: SAMPLE_REQUESTS }),
237
+ );
238
+ await program.parseAsync(["dm", "requests"], { from: "user" });
239
+ const url = String(fetchSpy.mock.calls[0][0]);
240
+ expect(url).toContain("/v1/notify/connections/requests");
241
+ const out = logged();
242
+ expect(out).toMatch(/2 pending connection requests/);
243
+ expect(out).toContain("Alice");
244
+ expect(out).toContain("alice@example.com");
245
+ expect(out).toContain('"hey, lets connect"');
246
+ });
247
+
248
+ it("requests: handles an empty list quietly", async () => {
249
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, { requests: [] }));
250
+ await program.parseAsync(["dm", "requests"], { from: "user" });
251
+ expect(logged()).toMatch(/No pending connection requests/);
252
+ });
253
+
254
+ it("accept: resolves email to pairKey and hits the accept endpoint", async () => {
255
+ // 1st call: fetch requests; 2nd call: accept.
256
+ fetchSpy
257
+ .mockResolvedValueOnce(jsonResponse(200, { requests: SAMPLE_REQUESTS }))
258
+ .mockResolvedValueOnce(jsonResponse(200, { state: "active" }));
259
+ await program.parseAsync(["dm", "accept", "alice@example.com"], { from: "user" });
260
+ const acceptCall = fetchSpy.mock.calls.find((c) =>
261
+ String(c[0]).includes("/v1/notify/connections/accept"),
262
+ );
263
+ expect(acceptCall).toBeTruthy();
264
+ const body = JSON.parse((acceptCall![1]?.body as string) ?? "{}");
265
+ expect(body).toEqual({ pairKey: "pair_abc" });
266
+ expect(logged()).toMatch(/Connection accepted — Alice\./);
267
+ });
268
+
269
+ it("decline: hits the decline endpoint", async () => {
270
+ fetchSpy
271
+ .mockResolvedValueOnce(jsonResponse(200, { requests: SAMPLE_REQUESTS }))
272
+ .mockResolvedValueOnce(jsonResponse(200, { state: "declined" }));
273
+ await program.parseAsync(["dm", "decline", "prs_bob"], { from: "user" });
274
+ const call = fetchSpy.mock.calls.find((c) =>
275
+ String(c[0]).includes("/v1/notify/connections/decline"),
276
+ );
277
+ expect(call).toBeTruthy();
278
+ const body = JSON.parse((call![1]?.body as string) ?? "{}");
279
+ expect(body).toEqual({ pairKey: "pair_def" });
280
+ expect(logged()).toMatch(/Request declined — Bob\./);
281
+ });
282
+
283
+ it("block: hits the block endpoint, falling back to withEmail for a stranger", async () => {
284
+ // No matching request → falls back to withEmail.
285
+ fetchSpy
286
+ .mockResolvedValueOnce(jsonResponse(200, { requests: [] }))
287
+ .mockResolvedValueOnce(jsonResponse(200, { state: "blocked" }));
288
+ await program.parseAsync(["dm", "block", "spammer@example.com"], { from: "user" });
289
+ const call = fetchSpy.mock.calls.find((c) =>
290
+ String(c[0]).includes("/v1/notify/connections/block"),
291
+ );
292
+ expect(call).toBeTruthy();
293
+ const body = JSON.parse((call![1]?.body as string) ?? "{}");
294
+ expect(body).toEqual({ withEmail: "spammer@example.com" });
295
+ expect(logged()).toMatch(/Blocked — spammer@example\.com\./);
296
+ });
297
+ });
@@ -117,11 +117,266 @@ function friendlyDmError(status: number, code: string | undefined, fallback: str
117
117
  return fallback;
118
118
  }
119
119
 
120
+ /** A pending incoming connection request, as returned by the backend. */
121
+ export interface ConnectionRequest {
122
+ pairKey: string;
123
+ fromPersonUid: string;
124
+ fromDisplayName?: string;
125
+ fromEmail?: string;
126
+ message?: string;
127
+ createdAt?: string;
128
+ }
129
+
130
+ /**
131
+ * Resolve a connection request from a pending-requests list by a caller-supplied
132
+ * identifier (email, personUid, or pairKey). Pure → unit-testable.
133
+ *
134
+ * Matching is case-insensitive for emails. Returns the matched request, or null
135
+ * if no request matches.
136
+ */
137
+ export function matchRequest(
138
+ requests: ConnectionRequest[],
139
+ identifier: string,
140
+ ): ConnectionRequest | null {
141
+ const id = identifier.trim();
142
+ const idLower = id.toLowerCase();
143
+ for (const r of requests) {
144
+ if (r.pairKey === id) return r;
145
+ if (r.fromPersonUid === id) return r;
146
+ if (r.fromEmail && r.fromEmail.toLowerCase() === idLower) return r;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Build the POST body for an accept/decline/block action. Prefers a resolved
153
+ * pairKey (from a matched pending request); otherwise falls back to the
154
+ * recipient identifier the backend can resolve (`withPersonUid` for a prs_ UID,
155
+ * `toEmail`-style is not supported by the connections endpoints so we send
156
+ * `withEmail`). Pure → unit-testable.
157
+ *
158
+ * Throws Error with a user-facing message when the identifier can't be resolved.
159
+ */
160
+ export function buildConnectionActionBody(
161
+ identifier: string,
162
+ matched: ConnectionRequest | null,
163
+ ): { pairKey: string } | { withPersonUid: string } | { withEmail: string } {
164
+ if (matched) {
165
+ return { pairKey: matched.pairKey };
166
+ }
167
+ const r = identifier.trim();
168
+ const rcpt = detectRecipient(r);
169
+ if (rcpt?.toPersonUid) return { withPersonUid: rcpt.toPersonUid };
170
+ if (rcpt?.toEmail) return { withEmail: rcpt.toEmail };
171
+ throw new Error(
172
+ `Could not resolve '${identifier}' to a pending request — pass the requester's email, personUid, or the pairKey from \`hq dm requests\`.`,
173
+ );
174
+ }
175
+
176
+ const CONNECTION_ACTIONS = {
177
+ accept: { path: "accept", done: "Connection accepted" },
178
+ decline: { path: "decline", done: "Request declined" },
179
+ block: { path: "block", done: "Blocked" },
180
+ } as const;
181
+
182
+ type ConnectionAction = keyof typeof CONNECTION_ACTIONS;
183
+
184
+ /** Fetch the caller's pending incoming connection requests. */
185
+ async function fetchRequests(token: string): Promise<ConnectionRequest[]> {
186
+ const res = await vaultApiFetch({
187
+ token,
188
+ path: "/v1/notify/connections/requests",
189
+ });
190
+ if (!res.ok) {
191
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
192
+ throw new Error(
193
+ friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
194
+ );
195
+ }
196
+ const data = (await res.json()) as { requests?: ConnectionRequest[] };
197
+ return data.requests ?? [];
198
+ }
199
+
200
+ async function runConnectionAction(
201
+ action: ConnectionAction,
202
+ identifier: string,
203
+ ): Promise<void> {
204
+ try {
205
+ const token = await ensureCognitoToken();
206
+ // Resolve the pairKey from the caller's pending requests when possible so
207
+ // accept/decline/block work against a friendly email or personUid.
208
+ const requests = await fetchRequests(token).catch(() => [] as ConnectionRequest[]);
209
+ const matched = matchRequest(requests, identifier);
210
+ const body = buildConnectionActionBody(identifier, matched);
211
+
212
+ const res = await vaultApiFetch({
213
+ token,
214
+ path: `/v1/notify/connections/${CONNECTION_ACTIONS[action].path}`,
215
+ method: "POST",
216
+ body: body as unknown as Record<string, unknown>,
217
+ });
218
+ if (!res.ok) {
219
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
220
+ console.error(
221
+ chalk.red(
222
+ friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
223
+ ),
224
+ );
225
+ process.exit(1);
226
+ }
227
+
228
+ const who = matched?.fromDisplayName ?? matched?.fromEmail ?? identifier;
229
+ console.log(chalk.green(`${CONNECTION_ACTIONS[action].done} — ${who}.`));
230
+ if (action === "accept" && matched?.message) {
231
+ console.log(chalk.dim("Their held message will now be delivered."));
232
+ }
233
+ } catch (err) {
234
+ console.error(
235
+ chalk.red("Error:"),
236
+ err instanceof Error ? err.message : String(err),
237
+ );
238
+ process.exit(1);
239
+ }
240
+ }
241
+
242
+ interface DmSendOpts {
243
+ prompt?: string;
244
+ promptFile?: string;
245
+ details?: string;
246
+ detailsFile?: string;
247
+ at?: string;
248
+ in?: string;
249
+ }
250
+
251
+ async function runDmSend(
252
+ recipient: string,
253
+ message: string | undefined,
254
+ opts: DmSendOpts,
255
+ ): Promise<void> {
256
+ try {
257
+ // Resolve prompt/details from inline text or a file.
258
+ let prompt = opts.prompt;
259
+ if (opts.promptFile) prompt = readFileSync(opts.promptFile, "utf8");
260
+ let details = opts.details;
261
+ if (opts.detailsFile) details = readFileSync(opts.detailsFile, "utf8");
262
+
263
+ const reqBody = buildDmBody({
264
+ recipient,
265
+ message: message ?? "",
266
+ prompt,
267
+ details,
268
+ at: opts.at,
269
+ inDelay: opts.in,
270
+ now: Date.now(),
271
+ });
272
+
273
+ const token = await ensureCognitoToken();
274
+ const res = await vaultApiFetch({
275
+ token,
276
+ path: "/v1/notify/dm",
277
+ method: "POST",
278
+ body: reqBody as unknown as Record<string, unknown>,
279
+ });
280
+
281
+ if (!res.ok) {
282
+ const err = (await res.json().catch(() => ({}))) as Record<string, string>;
283
+ console.error(
284
+ chalk.red(
285
+ friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
286
+ ),
287
+ );
288
+ process.exit(1);
289
+ }
290
+
291
+ // 202 connection_requested: the recipient isn't connected yet, so the DM is
292
+ // held as a connection request rather than delivered. This is a success, not
293
+ // an error — surface it quietly.
294
+ const data = (await res.json()) as {
295
+ eventId?: string;
296
+ createdAt?: string;
297
+ scheduled?: boolean;
298
+ deliverAt?: string;
299
+ state?: string;
300
+ };
301
+
302
+ if (res.status === 202 || data.state === "connection_requested") {
303
+ console.log(
304
+ chalk.green(`Request sent — pending ${recipient}'s acceptance.`),
305
+ );
306
+ console.log(
307
+ chalk.dim("Your message is held and delivers automatically once they accept."),
308
+ );
309
+ return;
310
+ }
311
+
312
+ if (data.scheduled) {
313
+ console.log(
314
+ chalk.green(
315
+ `Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`,
316
+ ),
317
+ );
318
+ console.log(
319
+ chalk.dim("It delivers within ~60s of that time, even if you're offline."),
320
+ );
321
+ } else {
322
+ console.log(
323
+ chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`),
324
+ );
325
+ }
326
+ } catch (err) {
327
+ console.error(
328
+ chalk.red("Error:"),
329
+ err instanceof Error ? err.message : String(err),
330
+ );
331
+ process.exit(1);
332
+ }
333
+ }
334
+
335
+ async function runDmRequests(): Promise<void> {
336
+ try {
337
+ const token = await ensureCognitoToken();
338
+ const requests = await fetchRequests(token);
339
+ if (requests.length === 0) {
340
+ console.log(chalk.dim("No pending connection requests."));
341
+ return;
342
+ }
343
+ console.log(
344
+ chalk.green(
345
+ `${requests.length} pending connection request${requests.length === 1 ? "" : "s"}:`,
346
+ ),
347
+ );
348
+ for (const r of requests) {
349
+ const name = r.fromDisplayName ?? r.fromEmail ?? r.fromPersonUid;
350
+ const emailPart = r.fromEmail ? chalk.dim(` <${r.fromEmail}>`) : "";
351
+ console.log(`\n ${chalk.bold(name)}${emailPart}`);
352
+ if (r.message) {
353
+ console.log(` ${chalk.dim('"' + r.message + '"')}`);
354
+ }
355
+ const idHint = r.fromEmail ?? r.fromPersonUid ?? r.pairKey;
356
+ console.log(
357
+ chalk.dim(` Accept: hq dm accept ${idHint} · Decline: hq dm decline ${idHint}`),
358
+ );
359
+ }
360
+ } catch (err) {
361
+ console.error(
362
+ chalk.red("Error:"),
363
+ err instanceof Error ? err.message : String(err),
364
+ );
365
+ process.exit(1);
366
+ }
367
+ }
368
+
120
369
  export function registerDmCommand(program: Command): void {
121
- program
122
- .command("dm <recipient> [message]")
370
+ const dm = program
371
+ .command("dm")
123
372
  .description(
124
- "Send a direct message to a teammate (email or personUid). They receive it as an HQ Sync notification.",
373
+ "Send a direct message and manage connection requests.",
374
+ );
375
+
376
+ dm
377
+ .command("send <recipient> [message]", { isDefault: true, hidden: true })
378
+ .description(
379
+ "Send a direct message to someone (email or personUid). They receive it as an HQ Sync notification. If you aren't connected yet, it sends a connection request that holds your message.",
125
380
  )
126
381
  .option(
127
382
  "--prompt <text>",
@@ -145,78 +400,39 @@ export function registerDmCommand(program: Command): void {
145
400
  async (
146
401
  recipient: string,
147
402
  message: string | undefined,
148
- opts: {
149
- prompt?: string;
150
- promptFile?: string;
151
- details?: string;
152
- detailsFile?: string;
153
- at?: string;
154
- in?: string;
155
- },
403
+ opts: DmSendOpts,
156
404
  ) => {
157
- try {
158
- // Resolve prompt/details from inline text or a file.
159
- let prompt = opts.prompt;
160
- if (opts.promptFile) prompt = readFileSync(opts.promptFile, "utf8");
161
- let details = opts.details;
162
- if (opts.detailsFile) details = readFileSync(opts.detailsFile, "utf8");
163
-
164
- const reqBody = buildDmBody({
165
- recipient,
166
- message: message ?? "",
167
- prompt,
168
- details,
169
- at: opts.at,
170
- inDelay: opts.in,
171
- now: Date.now(),
172
- });
173
-
174
- const token = await ensureCognitoToken();
175
- const res = await vaultApiFetch({
176
- token,
177
- path: "/v1/notify/dm",
178
- method: "POST",
179
- body: reqBody as unknown as Record<string, unknown>,
180
- });
181
-
182
- if (!res.ok) {
183
- const err = (await res.json().catch(() => ({}))) as Record<string, string>;
184
- console.error(
185
- chalk.red(
186
- friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText),
187
- ),
188
- );
189
- process.exit(1);
190
- }
191
-
192
- const data = (await res.json()) as {
193
- eventId?: string;
194
- createdAt?: string;
195
- scheduled?: boolean;
196
- deliverAt?: string;
197
- };
198
-
199
- if (data.scheduled) {
200
- console.log(
201
- chalk.green(
202
- `Scheduled DM to ${recipient} for ${data.deliverAt} (eventId ${data.eventId}).`,
203
- ),
204
- );
205
- console.log(
206
- chalk.dim("It delivers within ~60s of that time, even if you're offline."),
207
- );
208
- } else {
209
- console.log(
210
- chalk.green(`DM sent to ${recipient} (eventId ${data.eventId}).`),
211
- );
212
- }
213
- } catch (err) {
214
- console.error(
215
- chalk.red("Error:"),
216
- err instanceof Error ? err.message : String(err),
217
- );
218
- process.exit(1);
219
- }
405
+ await runDmSend(recipient, message, opts);
220
406
  },
221
407
  );
408
+
409
+ dm
410
+ .command("requests")
411
+ .description("List your pending incoming connection requests.")
412
+ .action(async () => {
413
+ await runDmRequests();
414
+ });
415
+
416
+ dm
417
+ .command("accept <person>")
418
+ .description(
419
+ "Accept a pending connection request (by email, personUid, or pairKey). Their held message is then delivered.",
420
+ )
421
+ .action(async (person: string) => {
422
+ await runConnectionAction("accept", person);
423
+ });
424
+
425
+ dm
426
+ .command("decline <person>")
427
+ .description("Decline a pending connection request (by email, personUid, or pairKey).")
428
+ .action(async (person: string) => {
429
+ await runConnectionAction("decline", person);
430
+ });
431
+
432
+ dm
433
+ .command("block <person>")
434
+ .description("Block a person so they can't send you further requests (by email, personUid, or pairKey).")
435
+ .action(async (person: string) => {
436
+ await runConnectionAction("block", person);
437
+ });
222
438
  }