@indigoai-us/hq-cli 5.36.1 → 5.36.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.js +13 -8
- package/dist/commands/dm.d.ts +33 -0
- package/dist/commands/dm.js +199 -47
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +3 -3
- package/src/commands/cloud.push-all.test.ts +3 -3
- package/src/commands/cloud.ts +11 -5
- package/src/commands/dm.test.ts +211 -2
- package/src/commands/dm.ts +290 -74
package/dist/commands/cloud.js
CHANGED
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!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]="
|
|
16
|
+
!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]="856084be-3bbb-50cd-8e3b-1e19ea13f4fb")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
|
-
import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, computePersonalVaultPaths, } from "@indigoai-us/hq-cloud";
|
|
20
|
+
import { share, sync, readJournal, getJournalPath, loadCachedTokens, VaultClient, computePersonalVaultPaths, PERSONAL_VAULT_JOURNAL_SLUG, } from "@indigoai-us/hq-cloud";
|
|
21
21
|
import { DEFAULT_HQ_ROOT, ensureCognitoToken, buildVaultConfig, } from "../utils/cognito-session.js";
|
|
22
22
|
import { emitNarrowHint, isStrictRefusal, resolveBannerLevel, } from "../lib/narrow-hint-banner.js";
|
|
23
23
|
/**
|
|
@@ -89,13 +89,18 @@ export async function pullAll(options, deps) {
|
|
|
89
89
|
}
|
|
90
90
|
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
91
91
|
if (personal) {
|
|
92
|
+
// Canonical slug, NOT the literal "personal": that legacy slug is the
|
|
93
|
+
// companies/personal company's journal. hq-sync-runner moved the vault
|
|
94
|
+
// slot to PERSONAL_VAULT_JOURNAL_SLUG; hq-cli journaling the same vault
|
|
95
|
+
// under the old slug split the bookkeeping across two files — every CLI
|
|
96
|
+
// sync re-flagged the menubar's already-synced files as conflicts.
|
|
92
97
|
plan.push({
|
|
93
98
|
slug: "personal",
|
|
94
99
|
syncOptions: {
|
|
95
100
|
company: personal.uid,
|
|
96
101
|
hqRoot: options.hqRoot,
|
|
97
102
|
personalMode: true,
|
|
98
|
-
journalSlug:
|
|
103
|
+
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
99
104
|
...(options.onConflict ? { onConflict: options.onConflict } : {}),
|
|
100
105
|
},
|
|
101
106
|
});
|
|
@@ -213,7 +218,7 @@ export async function pushAll(options, deps) {
|
|
|
213
218
|
hqRoot: options.hqRoot,
|
|
214
219
|
paths: computePersonalVaultPaths(options.hqRoot),
|
|
215
220
|
personalMode: true,
|
|
216
|
-
journalSlug:
|
|
221
|
+
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
217
222
|
skipUnchanged: true,
|
|
218
223
|
propagateDeletes: true,
|
|
219
224
|
propagateDeletePolicy: resolveDeletePolicy(),
|
|
@@ -481,7 +486,7 @@ export function registerCloudCommands(program) {
|
|
|
481
486
|
getEntity: async () => null,
|
|
482
487
|
});
|
|
483
488
|
personalMode = true;
|
|
484
|
-
journalSlug =
|
|
489
|
+
journalSlug = PERSONAL_VAULT_JOURNAL_SLUG;
|
|
485
490
|
}
|
|
486
491
|
// Default paths. For `--personal` with no explicit paths, default to
|
|
487
492
|
// the canonical personal-vault top-level scope so re-runs from a
|
|
@@ -833,7 +838,7 @@ async function runPullPersonal(hqRoot, onConflict) {
|
|
|
833
838
|
vaultConfig,
|
|
834
839
|
hqRoot,
|
|
835
840
|
personalMode: true,
|
|
836
|
-
journalSlug:
|
|
841
|
+
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
837
842
|
});
|
|
838
843
|
if (result.aborted) {
|
|
839
844
|
console.log(chalk.yellow(`\n⚠ Pull aborted (${result.filesDownloaded} downloaded, ${result.filesSkipped} skipped, ${result.conflicts} conflicts)`));
|
|
@@ -954,7 +959,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
954
959
|
getEntity: async () => null,
|
|
955
960
|
});
|
|
956
961
|
personalMode = true;
|
|
957
|
-
journalSlug =
|
|
962
|
+
journalSlug = PERSONAL_VAULT_JOURNAL_SLUG;
|
|
958
963
|
pushPaths = computePersonalVaultPaths(hqRoot);
|
|
959
964
|
}
|
|
960
965
|
else {
|
|
@@ -1163,4 +1168,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
1163
1168
|
}
|
|
1164
1169
|
}
|
|
1165
1170
|
//# sourceMappingURL=cloud.js.map
|
|
1166
|
-
//# debugId=
|
|
1171
|
+
//# debugId=856084be-3bbb-50cd-8e3b-1e19ea13f4fb
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -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
|
package/dist/commands/dm.js
CHANGED
|
@@ -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]="
|
|
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
|
|
95
|
-
.description("Send a direct message
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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=
|
|
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.
|
|
3
|
+
"version": "5.36.3",
|
|
4
4
|
"description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "^6.2.
|
|
18
|
+
"@indigoai-us/hq-cloud": "^6.2.4",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -140,7 +140,7 @@ describe("pullAll", () => {
|
|
|
140
140
|
expect(sync.calls[2]).toMatchObject({
|
|
141
141
|
company: "psn_alice",
|
|
142
142
|
personalMode: true,
|
|
143
|
-
journalSlug: "
|
|
143
|
+
journalSlug: "__hq_personal_vault__",
|
|
144
144
|
});
|
|
145
145
|
|
|
146
146
|
expect(result.attempted).toBe(3);
|
|
@@ -300,7 +300,7 @@ describe("pullAll", () => {
|
|
|
300
300
|
expect(sync.calls).toHaveLength(1);
|
|
301
301
|
expect(sync.calls[0].company).toBe("psn_old");
|
|
302
302
|
expect(sync.calls[0].personalMode).toBe(true);
|
|
303
|
-
expect(sync.calls[0].journalSlug).toBe("
|
|
303
|
+
expect(sync.calls[0].journalSlug).toBe("__hq_personal_vault__");
|
|
304
304
|
});
|
|
305
305
|
|
|
306
306
|
// ── 8/9/10. US-011 narrow-hint banner ─────────────────────────────────────
|
|
@@ -565,7 +565,7 @@ describe("pullAll", () => {
|
|
|
565
565
|
"psn_alice",
|
|
566
566
|
]);
|
|
567
567
|
expect(sync.calls[1].personalMode).toBe(true);
|
|
568
|
-
expect(sync.calls[1].journalSlug).toBe("
|
|
568
|
+
expect(sync.calls[1].journalSlug).toBe("__hq_personal_vault__");
|
|
569
569
|
});
|
|
570
570
|
|
|
571
571
|
it("skipPersonal=true with empty memberships produces zero sync calls", async () => {
|
|
@@ -176,7 +176,7 @@ describe("pushAll", () => {
|
|
|
176
176
|
expect(share.calls[2]).toMatchObject({
|
|
177
177
|
company: "psn_alice",
|
|
178
178
|
personalMode: true,
|
|
179
|
-
journalSlug: "
|
|
179
|
+
journalSlug: "__hq_personal_vault__",
|
|
180
180
|
skipUnchanged: true,
|
|
181
181
|
propagateDeletes: true,
|
|
182
182
|
});
|
|
@@ -346,7 +346,7 @@ describe("pushAll", () => {
|
|
|
346
346
|
expect(share.calls).toHaveLength(1);
|
|
347
347
|
expect(share.calls[0].company).toBe("psn_old");
|
|
348
348
|
expect(share.calls[0].personalMode).toBe(true);
|
|
349
|
-
expect(share.calls[0].journalSlug).toBe("
|
|
349
|
+
expect(share.calls[0].journalSlug).toBe("__hq_personal_vault__");
|
|
350
350
|
});
|
|
351
351
|
|
|
352
352
|
// ── 7. Aggregate counts include deletes ───────────────────────────────────
|
|
@@ -451,6 +451,6 @@ describe("pushAll", () => {
|
|
|
451
451
|
"psn_alice",
|
|
452
452
|
]);
|
|
453
453
|
expect(share.calls[1].personalMode).toBe(true);
|
|
454
|
-
expect(share.calls[1].journalSlug).toBe("
|
|
454
|
+
expect(share.calls[1].journalSlug).toBe("__hq_personal_vault__");
|
|
455
455
|
});
|
|
456
456
|
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
loadCachedTokens,
|
|
27
27
|
VaultClient,
|
|
28
28
|
computePersonalVaultPaths,
|
|
29
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
29
30
|
type ConflictStrategy,
|
|
30
31
|
type EntityContext,
|
|
31
32
|
type MembershipSyncConfig,
|
|
@@ -301,13 +302,18 @@ export async function pullAll(
|
|
|
301
302
|
|
|
302
303
|
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
303
304
|
if (personal) {
|
|
305
|
+
// Canonical slug, NOT the literal "personal": that legacy slug is the
|
|
306
|
+
// companies/personal company's journal. hq-sync-runner moved the vault
|
|
307
|
+
// slot to PERSONAL_VAULT_JOURNAL_SLUG; hq-cli journaling the same vault
|
|
308
|
+
// under the old slug split the bookkeeping across two files — every CLI
|
|
309
|
+
// sync re-flagged the menubar's already-synced files as conflicts.
|
|
304
310
|
plan.push({
|
|
305
311
|
slug: "personal",
|
|
306
312
|
syncOptions: {
|
|
307
313
|
company: personal.uid,
|
|
308
314
|
hqRoot: options.hqRoot,
|
|
309
315
|
personalMode: true,
|
|
310
|
-
journalSlug:
|
|
316
|
+
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
311
317
|
...(options.onConflict ? { onConflict: options.onConflict } : {}),
|
|
312
318
|
},
|
|
313
319
|
});
|
|
@@ -442,7 +448,7 @@ export async function pushAll(
|
|
|
442
448
|
hqRoot: options.hqRoot,
|
|
443
449
|
paths: computePersonalVaultPaths(options.hqRoot),
|
|
444
450
|
personalMode: true,
|
|
445
|
-
journalSlug:
|
|
451
|
+
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
446
452
|
skipUnchanged: true,
|
|
447
453
|
propagateDeletes: true,
|
|
448
454
|
propagateDeletePolicy: resolveDeletePolicy(),
|
|
@@ -821,7 +827,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
821
827
|
getEntity: async () => null,
|
|
822
828
|
});
|
|
823
829
|
personalMode = true;
|
|
824
|
-
journalSlug =
|
|
830
|
+
journalSlug = PERSONAL_VAULT_JOURNAL_SLUG;
|
|
825
831
|
}
|
|
826
832
|
|
|
827
833
|
// Default paths. For `--personal` with no explicit paths, default to
|
|
@@ -1349,7 +1355,7 @@ async function runPullPersonal(
|
|
|
1349
1355
|
vaultConfig,
|
|
1350
1356
|
hqRoot,
|
|
1351
1357
|
personalMode: true,
|
|
1352
|
-
journalSlug:
|
|
1358
|
+
journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
|
|
1353
1359
|
});
|
|
1354
1360
|
|
|
1355
1361
|
if (result.aborted) {
|
|
@@ -1510,7 +1516,7 @@ async function runNowSingle(
|
|
|
1510
1516
|
getEntity: async () => null,
|
|
1511
1517
|
});
|
|
1512
1518
|
personalMode = true;
|
|
1513
|
-
journalSlug =
|
|
1519
|
+
journalSlug = PERSONAL_VAULT_JOURNAL_SLUG;
|
|
1514
1520
|
pushPaths = computePersonalVaultPaths(hqRoot);
|
|
1515
1521
|
} else {
|
|
1516
1522
|
// For company targets we need a concrete slug to compute the push path.
|
package/src/commands/dm.test.ts
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
+
});
|
package/src/commands/dm.ts
CHANGED
|
@@ -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
|
|
370
|
+
const dm = program
|
|
371
|
+
.command("dm")
|
|
123
372
|
.description(
|
|
124
|
-
"Send a direct message
|
|
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
|
-
|
|
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
|
}
|