@dench.com/cli 2.1.4 → 2.2.0

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/dench.ts CHANGED
@@ -296,9 +296,34 @@ function throwIfCliError(value: unknown) {
296
296
  }
297
297
  }
298
298
 
299
+ /**
300
+ * Production Convex deployments mask `error.message` ("[Request ID: …]
301
+ * Server Error") but forward `ConvexError.data` verbatim. Prefer the data
302
+ * payload so agents see the actionable message instead of the mask.
303
+ */
304
+ function convexErrorData(error: unknown): string | undefined {
305
+ if (!error || typeof error !== "object" || !("data" in error)) {
306
+ return undefined;
307
+ }
308
+ const data = (error as { data?: unknown }).data;
309
+ if (typeof data === "string" && data.trim()) return data;
310
+ if (data && typeof data === "object") {
311
+ const message = (data as { message?: unknown }).message;
312
+ if (typeof message === "string" && message.trim()) return message;
313
+ try {
314
+ return JSON.stringify(data);
315
+ } catch {
316
+ return undefined;
317
+ }
318
+ }
319
+ return undefined;
320
+ }
321
+
299
322
  function normalizeCliError(error: unknown): CliError {
300
323
  if (error instanceof CliError) return error;
301
- const message = error instanceof Error ? error.message : String(error);
324
+ const message =
325
+ convexErrorData(error) ??
326
+ (error instanceof Error ? error.message : String(error));
302
327
  if (message.includes("Invalid dev agent key")) {
303
328
  return new CliError("Invalid dev agent key", {
304
329
  code: "invalid_dev_agent_key",
@@ -486,10 +511,6 @@ Cloud browser (Browserbase, runs entirely in Dench Cloud):
486
511
  dench browser stop
487
512
  Help: dench browser help
488
513
 
489
- LinkedIn outreach (Browserbase, verified before CRM update):
490
- dench linkedin connect <profileUrl> [--entry people:<entryId>] [--json]
491
- Help: dench linkedin help
492
-
493
514
  Managed email campaigns (Dench Emailing Service):
494
515
  dench email identity verify --from founder@example.com [--json]
495
516
  dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--json]
@@ -2351,6 +2372,7 @@ async function getRuntime() {
2351
2372
  return {
2352
2373
  mode: "session" as const,
2353
2374
  host: resolveApiHost(host),
2375
+ convexUrl: stored.session.convexUrl,
2354
2376
  client: createCliConvexClient(stored.session.convexUrl),
2355
2377
  sessionToken: stored.session.sessionToken,
2356
2378
  organization: stored.session.organization,
@@ -2397,6 +2419,7 @@ async function getRuntime() {
2397
2419
  return {
2398
2420
  mode: "apiKey" as const,
2399
2421
  host: apiHost,
2422
+ convexUrl: sandboxConvexUrl,
2400
2423
  client: createCliConvexClient(sandboxConvexUrl),
2401
2424
  sessionToken: apiKey,
2402
2425
  organization: sandboxOrganization,
@@ -2412,6 +2435,7 @@ async function getRuntime() {
2412
2435
  return {
2413
2436
  mode: "session" as const,
2414
2437
  host: apiHost,
2438
+ convexUrl: sandboxConvexUrl,
2415
2439
  client: createCliConvexClient(sandboxConvexUrl),
2416
2440
  sessionToken: sandboxAgentToken,
2417
2441
  organization: sandboxOrganization,
@@ -2981,13 +3005,44 @@ function suggestedWorkFromArtifacts(artifacts: JsonRecord[]) {
2981
3005
  );
2982
3006
  }
2983
3007
 
3008
+ /**
3009
+ * The fs daemon authenticates purely from `CONVEX_URL` + `DENCH_API_KEY`
3010
+ * env vars (sandboxes bake them in). Local shells running off an OAuth
3011
+ * agent session have neither — resolve the active session and inject
3012
+ * its convexUrl + `dch_agent_*` token. The server-side files functions
3013
+ * accept the agent-session token in the same `apiKey` slot (prefix
3014
+ * dispatch, like `requireCrmAccess`), so the daemon works unchanged.
3015
+ */
3016
+ async function resolveFsDaemonEnv(): Promise<NodeJS.ProcessEnv> {
3017
+ if (process.env.CONVEX_URL?.trim() && process.env.DENCH_API_KEY?.trim()) {
3018
+ return process.env;
3019
+ }
3020
+ try {
3021
+ const runtime = await getRuntime();
3022
+ if (runtime.mode === "session" || runtime.mode === "apiKey") {
3023
+ return {
3024
+ ...process.env,
3025
+ CONVEX_URL: process.env.CONVEX_URL?.trim() || runtime.convexUrl,
3026
+ DENCH_API_KEY:
3027
+ process.env.DENCH_API_KEY?.trim() || runtime.sessionToken,
3028
+ };
3029
+ }
3030
+ } catch {
3031
+ // No saved session / dev env — fall through with the unmodified env;
3032
+ // the daemon prints its own missing-credential error for subcommands
3033
+ // that need Convex access.
3034
+ }
3035
+ return process.env;
3036
+ }
3037
+
2984
3038
  async function runFsCommand() {
2985
3039
  const subArgs = args.slice(args.indexOf("fs") + 1);
2986
3040
  const configuredBinary = process.env.DENCH_FS_DAEMON_BIN?.trim();
2987
3041
  const binary = configuredBinary || "dench-fs-daemon";
3042
+ const daemonEnv = await resolveFsDaemonEnv();
2988
3043
  let exitCode: number;
2989
3044
  try {
2990
- exitCode = await spawnInherited(binary, subArgs);
3045
+ exitCode = await spawnInherited(binary, subArgs, daemonEnv);
2991
3046
  } catch (error) {
2992
3047
  if (configuredBinary || !isCommandNotFound(error)) {
2993
3048
  throw error;
@@ -2995,7 +3050,11 @@ async function runFsCommand() {
2995
3050
  const localDaemon = fileURLToPath(
2996
3051
  new URL("./fs-daemon.ts", import.meta.url),
2997
3052
  );
2998
- exitCode = await spawnInherited("bun", [localDaemon, ...subArgs]);
3053
+ exitCode = await spawnInherited(
3054
+ "bun",
3055
+ [localDaemon, ...subArgs],
3056
+ daemonEnv,
3057
+ );
2999
3058
  }
3000
3059
  if (exitCode !== 0) {
3001
3060
  process.exitCode = exitCode;
@@ -3025,8 +3084,18 @@ async function requireFilesApiKey(
3025
3084
  runtime: Runtime,
3026
3085
  command: string,
3027
3086
  ): Promise<string> {
3028
- const auth = await resolveGatewayCommandAuth(runtime, command);
3029
- return auth.bearerToken;
3087
+ // The Convex files functions accept either a unified Dench API key OR a
3088
+ // `dch_agent_*` OAuth agent-session token in their `apiKey` arg (the
3089
+ // server dispatches on prefix, same as `requireCrmAccess`). So the
3090
+ // runtime's own bearer works directly — no gateway-key exchange, which
3091
+ // used to gate OAuth sessions behind a paid workspace and an extra
3092
+ // host round-trip.
3093
+ if (runtime.mode === "session" || runtime.mode === "apiKey") {
3094
+ return runtime.sessionToken;
3095
+ }
3096
+ const envApiKey = process.env.DENCH_API_KEY?.trim();
3097
+ if (envApiKey) return envApiKey;
3098
+ throw loginRequiredError(command);
3030
3099
  }
3031
3100
 
3032
3101
  type FileTreeRow = {
@@ -3603,11 +3672,15 @@ function sha256HexSync(bytes: Uint8Array): string {
3603
3672
  return createHash("sha256").update(bytes).digest("hex");
3604
3673
  }
3605
3674
 
3606
- async function spawnInherited(binary: string, commandArgs: string[]) {
3675
+ async function spawnInherited(
3676
+ binary: string,
3677
+ commandArgs: string[],
3678
+ env: NodeJS.ProcessEnv = process.env,
3679
+ ) {
3607
3680
  return await new Promise<number>((resolve, reject) => {
3608
3681
  const child = spawn(binary, commandArgs, {
3609
3682
  stdio: "inherit",
3610
- env: process.env,
3683
+ env,
3611
3684
  });
3612
3685
  child.on("error", reject);
3613
3686
  child.on("close", (code) => resolve(code ?? 1));
@@ -3661,18 +3734,6 @@ async function main() {
3661
3734
  return;
3662
3735
  }
3663
3736
 
3664
- if (
3665
- command === "linkedin" &&
3666
- (!subcommand || subcommand === "help" || hasFlag("--help"))
3667
- ) {
3668
- const { runLinkedInCommand } = await import("./linkedin");
3669
- await runLinkedInCommand({
3670
- args: subcommand ? [subcommand] : ["help"],
3671
- runtime: { host: "", bearerToken: "" },
3672
- });
3673
- return;
3674
- }
3675
-
3676
3737
  if (
3677
3738
  command === "email" &&
3678
3739
  (!subcommand || subcommand === "help" || hasFlag("--help"))
@@ -3991,24 +4052,6 @@ async function main() {
3991
4052
  return;
3992
4053
  }
3993
4054
 
3994
- if (command === "linkedin") {
3995
- const subArgs = args.slice(args.indexOf("linkedin") + 1);
3996
- const { runLinkedInCommand } = await import("./linkedin");
3997
- const runtime = await getRuntime();
3998
- const authedRuntime = requireAuthenticatedRuntime(
3999
- runtime,
4000
- "dench linkedin",
4001
- );
4002
- await runLinkedInCommand({
4003
- args: subArgs,
4004
- runtime: {
4005
- host: authedRuntime.host,
4006
- bearerToken: authedRuntime.sessionToken,
4007
- },
4008
- });
4009
- return;
4010
- }
4011
-
4012
4055
  if (command === "chat") {
4013
4056
  const subArgs = args.slice(args.indexOf("chat") + 1);
4014
4057
  const sub = subArgs[0];
package/email.ts CHANGED
@@ -189,12 +189,17 @@ export function emailHelp() {
189
189
 
190
190
  Usage:
191
191
  dench email identity verify --from founder@example.com [--json]
192
- dench email identity verify --domain example.com --from founder@example.com [--json]
192
+ dench email identity verify --domain example.com [--from founder@example.com] [--json]
193
+ dench email identity list [--domain example.com] [--json]
194
+ dench email identity dns --identity <identityId|domain|email> [--json]
193
195
  dench email identity status --identity founder@example.com [--json]
194
196
  dench email identity status --identity-id <identityId> [--json]
197
+ dench email send --from founder@example.com --to "Ada <ada@x.com>,bob@y.com" --subject "Hi" (--html-file body.html | --text "...") [--cc ...] [--bcc ...] [--from-name "Ada"] [--reply-to support@x.com] [--reply-message-id <messageId|rfc-id>] [--json]
198
+ dench email message status --message <messageId> [--json]
199
+ dench email message list [--limit 25] [--json]
195
200
  dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json]
196
201
  dench email template preview --template <id> --data '{"firstName":"Ada"}' [--json]
197
- dench email campaign create --name "Q2 outreach" --identity <id> --template <id> [--json]
202
+ dench email campaign create --name "Q2 outreach" --identity <identityId|fromEmail> --template <id> [--json]
198
203
  dench email campaign recipients add --campaign <id> --file recipients.jsonl [--json]
199
204
  dench email campaign preview --campaign <id> [--sample 5] [--json]
200
205
  dench email campaign submit --campaign <id> [--json]
@@ -212,6 +217,14 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
212
217
  await runIdentity(ctx);
213
218
  return;
214
219
  }
220
+ if (group === "send") {
221
+ await runSend(ctx);
222
+ return;
223
+ }
224
+ if (group === "message") {
225
+ await runMessage(ctx);
226
+ return;
227
+ }
215
228
  if (group === "template") {
216
229
  await runTemplate(ctx);
217
230
  return;
@@ -223,11 +236,193 @@ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
223
236
  throw new EmailCliError(`Unknown dench email subcommand: ${group}`);
224
237
  }
225
238
 
239
+ /**
240
+ * Parse a comma-separated address list where each entry is either a bare
241
+ * email or `Name <email>`.
242
+ */
243
+ function parseAddressList(
244
+ value: string | undefined,
245
+ ): Array<{ email: string; name?: string }> {
246
+ if (!value?.trim()) return [];
247
+ return value
248
+ .split(",")
249
+ .map((entry) => entry.trim())
250
+ .filter(Boolean)
251
+ .map((entry) => {
252
+ const match = entry.match(/^(.*)<([^<>\s]+@[^<>\s]+)>$/);
253
+ if (match) {
254
+ const name = match[1].trim().replace(/^"|"$/g, "").trim();
255
+ return { email: match[2].trim(), name: name || undefined };
256
+ }
257
+ return { email: entry };
258
+ });
259
+ }
260
+
261
+ async function runSend(ctx: EmailCliContext) {
262
+ const from = requiredFlag(ctx.args, "--from");
263
+ const to = parseAddressList(requiredFlag(ctx.args, "--to"));
264
+ if (to.length === 0) {
265
+ throw new EmailCliError(
266
+ 'No valid --to recipients. Use --to "Ada <ada@x.com>,bob@y.com"',
267
+ );
268
+ }
269
+ const cc = parseAddressList(getFlag(ctx.args, "--cc"));
270
+ const bcc = parseAddressList(getFlag(ctx.args, "--bcc"));
271
+ const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file");
272
+ const textBody = await readTextFlag(ctx.args, "--text", "--text-file");
273
+ if (!htmlBody && !textBody) {
274
+ throw new EmailCliError(
275
+ "Provide a body: --html/--html-file and/or --text/--text-file",
276
+ );
277
+ }
278
+ const payload = (await callAction(
279
+ ctx,
280
+ "functions/emailCampaignsNode:sendEmail",
281
+ {
282
+ fromEmail: from,
283
+ fromName: getFlag(ctx.args, "--from-name"),
284
+ to,
285
+ cc: cc.length > 0 ? cc : undefined,
286
+ bcc: bcc.length > 0 ? bcc : undefined,
287
+ replyToEmail: getFlag(ctx.args, "--reply-to"),
288
+ subject: requiredFlag(ctx.args, "--subject"),
289
+ htmlBody,
290
+ textBody,
291
+ replyToMessageId: getFlag(ctx.args, "--reply-message-id"),
292
+ configurationSetName: getFlag(ctx.args, "--configuration-set"),
293
+ },
294
+ )) as JsonRecord;
295
+ out(
296
+ ctx,
297
+ payload,
298
+ `Sent to ${to.length + cc.length + bcc.length} recipient(s). Message: ${payload.messageId}\nCheck opens: dench email message status --message ${payload.messageId}`,
299
+ );
300
+ }
301
+
302
+ async function runMessage(ctx: EmailCliContext) {
303
+ const sub = ctx.args.shift();
304
+ if (sub === "status") {
305
+ const messageId = requiredFlag(ctx.args, "--message");
306
+ const payload = (await callQuery(
307
+ ctx,
308
+ "functions/emailCampaigns:getMessage",
309
+ { messageId },
310
+ )) as JsonRecord;
311
+ out(ctx, payload, JSON.stringify(payload, null, 2));
312
+ return;
313
+ }
314
+ if (sub === "list") {
315
+ const limitRaw = getFlag(ctx.args, "--limit");
316
+ const status = getFlag(ctx.args, "--status");
317
+ const payload = (await callQuery(
318
+ ctx,
319
+ "functions/emailCampaigns:listMessages",
320
+ {
321
+ limit: limitRaw ? Number(limitRaw) : undefined,
322
+ status,
323
+ },
324
+ )) as JsonRecord[];
325
+ const text =
326
+ payload.length === 0
327
+ ? "No raw messages sent yet. Use `dench email send`."
328
+ : payload
329
+ .map(
330
+ (message) =>
331
+ `${message.messageId} ${message.status} from=${message.fromEmail} subject=${JSON.stringify(message.subject)} opens=${message.openCount} clicks=${message.clickCount}`,
332
+ )
333
+ .join("\n");
334
+ out(ctx, { ok: true, messages: payload }, text);
335
+ return;
336
+ }
337
+ throw new EmailCliError("Usage: dench email message status|list");
338
+ }
339
+
340
+ type DnsRecord = {
341
+ type: string;
342
+ name: string;
343
+ value: string;
344
+ purpose: string;
345
+ };
346
+
347
+ function buildDnsRecords(domain: string, dkimTokens: string[]): DnsRecord[] {
348
+ return [
349
+ ...dkimTokens.map((token) => ({
350
+ type: "CNAME",
351
+ name: `${token}._domainkey.${domain}`,
352
+ value: `${token}.dkim.amazonses.com`,
353
+ purpose: "DKIM (required for domain verification)",
354
+ })),
355
+ {
356
+ type: "TXT",
357
+ name: domain,
358
+ value: "v=spf1 include:amazonses.com ~all",
359
+ purpose: "SPF (recommended)",
360
+ },
361
+ {
362
+ type: "TXT",
363
+ name: `_dmarc.${domain}`,
364
+ value: `v=DMARC1; p=none; rua=mailto:postmaster@${domain}`,
365
+ purpose: "DMARC (recommended)",
366
+ },
367
+ ];
368
+ }
369
+
370
+ function asDnsRecords(value: unknown): DnsRecord[] {
371
+ if (!Array.isArray(value)) return [];
372
+ return value.filter(
373
+ (record): record is DnsRecord =>
374
+ Boolean(record) &&
375
+ typeof record === "object" &&
376
+ typeof (record as DnsRecord).type === "string" &&
377
+ typeof (record as DnsRecord).name === "string" &&
378
+ typeof (record as DnsRecord).value === "string",
379
+ );
380
+ }
381
+
382
+ function formatDnsRecords(records: DnsRecord[]): string {
383
+ if (records.length === 0) return "";
384
+ const lines = ["DNS records to add:"];
385
+ for (const record of records) {
386
+ lines.push(` ${record.type} ${record.name}`);
387
+ lines.push(` value: ${record.value}`);
388
+ if (record.purpose) lines.push(` (${record.purpose})`);
389
+ }
390
+ return lines.join("\n");
391
+ }
392
+
393
+ async function listIdentitiesQuery(
394
+ ctx: EmailCliContext,
395
+ domain?: string,
396
+ ): Promise<JsonRecord[]> {
397
+ const payload = await callQuery(
398
+ ctx,
399
+ "functions/emailCampaigns:listIdentities",
400
+ domain ? { domain } : {},
401
+ );
402
+ return Array.isArray(payload) ? (payload as JsonRecord[]) : [];
403
+ }
404
+
226
405
  async function runIdentity(ctx: EmailCliContext) {
227
406
  const sub = ctx.args.shift();
228
407
  if (sub === "verify") {
229
- const domain = getFlag(ctx.args, "--domain");
230
- const from = requiredFlag(ctx.args, "--from");
408
+ const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase();
409
+ let from = getFlag(ctx.args, "--from")?.trim();
410
+ if (!from) {
411
+ if (!domain) throw new EmailCliError("Missing required --from");
412
+ // Domain-only verify: reuse the sender mailbox already registered
413
+ // on this domain so the user doesn't have to repeat --from.
414
+ const existing = await listIdentitiesQuery(ctx, domain);
415
+ const preferred =
416
+ existing.find(
417
+ (identity) => identity.denchVerificationStatus === "verified",
418
+ ) ?? existing[0];
419
+ if (!preferred || typeof preferred.fromEmail !== "string") {
420
+ throw new EmailCliError(
421
+ `No sender registered on ${domain} yet. Pass --from <mailbox@${domain}> to register one.`,
422
+ );
423
+ }
424
+ from = preferred.fromEmail;
425
+ }
231
426
  const type = domain ? "domain" : "email";
232
427
  const identity = domain ?? from;
233
428
  const configurationSetName = getFlag(ctx.args, "--configuration-set");
@@ -243,16 +438,89 @@ async function runIdentity(ctx: EmailCliContext) {
243
438
  configurationSetName,
244
439
  },
245
440
  );
246
- const verification = await callAction(
441
+ const verification = (await callAction(
247
442
  ctx,
248
443
  "functions/emailCampaignsNode:beginSenderVerification",
249
444
  { identityId: (created as JsonRecord).identityId },
250
- );
251
- out(
252
- ctx,
253
- { ok: true, created, verification },
445
+ )) as JsonRecord;
446
+ const dnsRecords = asDnsRecords(verification.dnsRecords);
447
+ const text = [
254
448
  `Verification started for ${identity}`,
255
- );
449
+ typeof verification.message === "string" ? verification.message : "",
450
+ formatDnsRecords(dnsRecords),
451
+ ]
452
+ .filter(Boolean)
453
+ .join("\n");
454
+ out(ctx, { ok: true, created, verification }, text);
455
+ return;
456
+ }
457
+ if (sub === "list") {
458
+ const domain = getFlag(ctx.args, "--domain")?.trim().toLowerCase();
459
+ const identities = await listIdentitiesQuery(ctx, domain);
460
+ const text =
461
+ identities.length === 0
462
+ ? "No sending identities yet. Run `dench email identity verify --from you@example.com`."
463
+ : identities
464
+ .map(
465
+ (identity) =>
466
+ `${identity.identityId} ${identity.fromEmail} type=${identity.type}` +
467
+ `${identity.domain ? ` domain=${identity.domain}` : ""}` +
468
+ ` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}`,
469
+ )
470
+ .join("\n");
471
+ out(ctx, { ok: true, identities }, text);
472
+ return;
473
+ }
474
+ if (sub === "dns") {
475
+ const identity = requiredFlag(ctx.args, "--identity");
476
+ if (!identity.includes("@") && !identity.includes(".")) {
477
+ // Identity id: the refresh action returns formatted dnsRecords.
478
+ const payload = (await callAction(
479
+ ctx,
480
+ "functions/emailCampaignsNode:refreshIdentityStatus",
481
+ { identityId: identity },
482
+ )) as JsonRecord;
483
+ const records = asDnsRecords(payload.dnsRecords);
484
+ const text =
485
+ records.length > 0
486
+ ? formatDnsRecords(records)
487
+ : payload.domainVerified === true
488
+ ? `Domain ${payload.domain} is verified — no DNS records pending.`
489
+ : "No DNS records available yet. Run `dench email identity verify --domain <domain>` first.";
490
+ out(ctx, { ok: true, ...payload }, text);
491
+ return;
492
+ }
493
+ const domain = identity.includes("@")
494
+ ? identity.slice(identity.lastIndexOf("@") + 1).toLowerCase()
495
+ : identity.toLowerCase();
496
+ const status = (await callGateway(
497
+ ctx,
498
+ "GET",
499
+ `/v1/email/identities/${encodeURIComponent(domain)}/status`,
500
+ )) as JsonRecord;
501
+ const dkim =
502
+ status.dkimAttributes && typeof status.dkimAttributes === "object"
503
+ ? (status.dkimAttributes as JsonRecord)
504
+ : {};
505
+ const tokens = Array.isArray(dkim.Tokens)
506
+ ? dkim.Tokens.filter(
507
+ (token): token is string => typeof token === "string",
508
+ )
509
+ : [];
510
+ const records = buildDnsRecords(domain, tokens);
511
+ const verified = status.verifiedForSendingStatus === true;
512
+ const payload = {
513
+ ok: true,
514
+ domain,
515
+ domainVerified: verified,
516
+ dnsRecords: records,
517
+ };
518
+ const text = verified
519
+ ? `Domain ${domain} is verified — DNS records below are already in place.\n${formatDnsRecords(records)}`
520
+ : tokens.length > 0
521
+ ? formatDnsRecords(records)
522
+ : `Domain ${domain} has no verification records yet. Run \`dench email identity verify --domain ${domain}\` first.`;
523
+ out(ctx, payload, text);
256
524
  return;
257
525
  }
258
526
  if (sub === "status") {
@@ -282,7 +550,7 @@ async function runIdentity(ctx: EmailCliContext) {
282
550
  out(ctx, shaped, JSON.stringify(shaped, null, 2));
283
551
  return;
284
552
  }
285
- throw new EmailCliError("Usage: dench email identity verify|status");
553
+ throw new EmailCliError("Usage: dench email identity verify|list|dns|status");
286
554
  }
287
555
 
288
556
  async function runTemplate(ctx: EmailCliContext) {
@@ -1158,6 +1158,66 @@ const rawApiOperations = [
1158
1158
  responseSchema: anyResponse,
1159
1159
  backend: gateway("GET", "/email/identities/{identity}/status"),
1160
1160
  }),
1161
+ op({
1162
+ id: "email.identity.list",
1163
+ group: "email",
1164
+ summary: "List the workspace's email sending identities.",
1165
+ cli: "dench email identity list",
1166
+ method: "GET",
1167
+ path: "/email/sending-identities",
1168
+ requestSchema: anyObject,
1169
+ responseSchema: anyResponse,
1170
+ backend: convex("query", "functions/emailCampaigns:listIdentities"),
1171
+ }),
1172
+ op({
1173
+ id: "email.identity.dns",
1174
+ group: "email",
1175
+ summary:
1176
+ "Fetch DNS records (DKIM/SPF/DMARC) for a pending domain sender verification.",
1177
+ cli: "dench email identity dns",
1178
+ method: "GET",
1179
+ path: "/email/sending-identities/{identityId}/dns",
1180
+ requestSchema: anyObject,
1181
+ responseSchema: anyResponse,
1182
+ backend: convex(
1183
+ "action",
1184
+ "functions/emailCampaignsNode:refreshIdentityStatus",
1185
+ ),
1186
+ }),
1187
+ op({
1188
+ id: "email.send",
1189
+ group: "email",
1190
+ summary:
1191
+ "Send one raw email (no campaign): to/cc/bcc, custom from-name and reply-to, optional reply threading via replyToMessageId.",
1192
+ cli: "dench email send",
1193
+ method: "POST",
1194
+ path: "/email/send",
1195
+ requestSchema: anyObject,
1196
+ responseSchema: anyResponse,
1197
+ backend: convex("action", "functions/emailCampaignsNode:sendEmail"),
1198
+ }),
1199
+ op({
1200
+ id: "email.message.get",
1201
+ group: "email",
1202
+ summary: "Read a raw email message's status and open/click counters.",
1203
+ cli: "dench email message status",
1204
+ method: "GET",
1205
+ path: "/email/messages/{messageId}",
1206
+ requestSchema: anyObject,
1207
+ responseSchema: anyResponse,
1208
+ backend: convex("query", "functions/emailCampaigns:getMessage"),
1209
+ }),
1210
+ op({
1211
+ id: "email.message.list",
1212
+ group: "email",
1213
+ summary: "List recent raw email messages for the workspace.",
1214
+ cli: "dench email message list",
1215
+ method: "GET",
1216
+ path: "/email/messages",
1217
+ requestSchema: anyObject,
1218
+ responseSchema: anyResponse,
1219
+ backend: convex("query", "functions/emailCampaigns:listMessages"),
1220
+ }),
1161
1221
  op({
1162
1222
  id: "email.template.create",
1163
1223
  group: "email",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,6 @@
28
28
  "members.ts",
29
29
  "cron.ts",
30
30
  "browser.ts",
31
- "linkedin.ts",
32
31
  "search.ts",
33
32
  "image.ts",
34
33
  "tools.ts",
package/linkedin.ts DELETED
@@ -1,210 +0,0 @@
1
- /**
2
- * `dench linkedin` — first-class LinkedIn outreach workflows.
3
- *
4
- * This is intentionally not a generic browser surface. The CLI sends
5
- * one high-level command to Dench Cloud, where Browserbase + the
6
- * LinkedIn-specific verifier do the whole transaction server-side.
7
- */
8
-
9
- import { hasFlag } from "./lib/cli-args";
10
-
11
- type RuntimeBundle = {
12
- host: string;
13
- bearerToken: string;
14
- };
15
-
16
- type LinkedInCliContext = {
17
- args: string[];
18
- jsonOutput: boolean;
19
- runtime: RuntimeBundle;
20
- };
21
-
22
- type LinkedInResponse = Record<string, unknown> & {
23
- crmUpdate?: unknown;
24
- currentUrl?: string | null;
25
- error?: string;
26
- message?: string;
27
- ok?: boolean;
28
- pageTitle?: string | null;
29
- profileUrl?: string;
30
- status?: string;
31
- threadId?: string;
32
- threadUrl?: string;
33
- verified?: boolean;
34
- };
35
-
36
- class LinkedInCliError extends Error {}
37
-
38
- function linkedinHelp(): void {
39
- console.log(`Usage: dench linkedin <subcommand>
40
-
41
- Connect with a LinkedIn profile using the cloud Browserbase browser:
42
- dench linkedin connect <profileUrl> [--entry people:<entryId>] [--thread <threadId>] [--json]
43
-
44
- CRM:
45
- --entry people:<entryId> Update that CRM row after verification.
46
- --object <object> --entry-id <entryId>
47
- Same as --entry, split into two flags.
48
-
49
- Behavior:
50
- CRM is marked reached out only when LinkedIn verifies Pending.
51
- If LinkedIn asks for login, rate-limits, or shows an unclear modal,
52
- the result is manual_review and the browser thread stays live.
53
- `);
54
- }
55
-
56
- function consumeFlagValue(args: string[], name: string): string | undefined {
57
- const idx = args.indexOf(name);
58
- if (idx === -1) return undefined;
59
- const value = args[idx + 1];
60
- args.splice(idx, 2);
61
- return value;
62
- }
63
-
64
- function normalizeProfileUrl(input: string | undefined): string {
65
- const value = input?.trim();
66
- if (!value) {
67
- throw new LinkedInCliError(
68
- "Usage: dench linkedin connect <profileUrl> [--entry people:<entryId>]",
69
- );
70
- }
71
- if (/^https?:\/\//i.test(value)) return value;
72
- return `https://${value}`;
73
- }
74
-
75
- function parseEntry(value: string | undefined) {
76
- if (!value) return null;
77
- const [objectName, ...rest] = value.split(":");
78
- const entryId = rest.join(":").trim();
79
- if (!objectName?.trim() || !entryId) {
80
- throw new LinkedInCliError("--entry must look like people:<entryId>");
81
- }
82
- return { objectName: objectName.trim(), entryId };
83
- }
84
-
85
- function buildLinkedInCliUrl(host: string): string {
86
- return `${host.replace(/\/+$/, "")}/api/linkedin/cli`;
87
- }
88
-
89
- async function postJson(
90
- url: string,
91
- bearer: string,
92
- body: unknown,
93
- ): Promise<{ ok: boolean; payload: unknown; status: number }> {
94
- const response = await fetch(url, {
95
- method: "POST",
96
- headers: {
97
- accept: "application/json",
98
- authorization: `Bearer ${bearer}`,
99
- "content-type": "application/json",
100
- },
101
- body: JSON.stringify(body),
102
- });
103
- const text = await response.text();
104
- let payload: unknown;
105
- try {
106
- payload = text ? JSON.parse(text) : null;
107
- } catch {
108
- payload = { raw: text };
109
- }
110
- return { ok: response.ok, payload, status: response.status };
111
- }
112
-
113
- function payloadError(payload: unknown): string {
114
- if (payload && typeof payload === "object" && !Array.isArray(payload)) {
115
- const error = (payload as { error?: unknown }).error;
116
- if (typeof error === "string" && error.trim()) return error;
117
- }
118
- return "LinkedIn request failed";
119
- }
120
-
121
- function output(ctx: LinkedInCliContext, value: unknown): void {
122
- if (ctx.jsonOutput) {
123
- console.log(JSON.stringify(value, null, 2));
124
- return;
125
- }
126
- console.log(String(value));
127
- }
128
-
129
- function formatHuman(response: LinkedInResponse) {
130
- const lines: string[] = [];
131
- const status = response.status ?? "unknown";
132
- if (response.ok === false) {
133
- lines.push(`LinkedIn connect failed: ${response.error ?? status}`);
134
- } else {
135
- lines.push(`LinkedIn connect: ${status}`);
136
- }
137
- if (response.verified !== undefined) {
138
- lines.push(`verified: ${response.verified ? "yes" : "no"}`);
139
- }
140
- if (response.message) lines.push(`result: ${response.message}`);
141
- if (response.profileUrl) lines.push(`profile: ${response.profileUrl}`);
142
- if (response.currentUrl) lines.push(`url: ${response.currentUrl}`);
143
- if (response.threadId) lines.push(`threadId: ${response.threadId}`);
144
- if (response.crmUpdate) lines.push("crm: updated");
145
- if (response.threadUrl) {
146
- lines.push("");
147
- lines.push(`Open in Dench: ${response.threadUrl}`);
148
- }
149
- return lines.join("\n");
150
- }
151
-
152
- async function runConnectCommand(ctx: LinkedInCliContext) {
153
- const threadId = consumeFlagValue(ctx.args, "--thread");
154
- const entry = parseEntry(consumeFlagValue(ctx.args, "--entry"));
155
- const objectName =
156
- consumeFlagValue(ctx.args, "--object") ?? entry?.objectName;
157
- const entryId = consumeFlagValue(ctx.args, "--entry-id") ?? entry?.entryId;
158
- const profileUrl = normalizeProfileUrl(ctx.args.shift());
159
-
160
- const body = {
161
- action: "connect",
162
- profileUrl,
163
- threadId,
164
- ...(objectName ? { crmObjectName: objectName } : {}),
165
- ...(entryId ? { crmEntryId: entryId } : {}),
166
- };
167
-
168
- const result = await postJson(
169
- buildLinkedInCliUrl(ctx.runtime.host),
170
- ctx.runtime.bearerToken,
171
- body,
172
- );
173
- if (!result.ok) {
174
- throw new LinkedInCliError(
175
- `${payloadError(result.payload)} (${result.status})`,
176
- );
177
- }
178
-
179
- const response =
180
- result.payload && typeof result.payload === "object"
181
- ? (result.payload as LinkedInResponse)
182
- : ({} as LinkedInResponse);
183
-
184
- output(ctx, ctx.jsonOutput ? response : formatHuman(response));
185
- }
186
-
187
- export async function runLinkedInCommand(opts: {
188
- args: string[];
189
- runtime: RuntimeBundle;
190
- }): Promise<void> {
191
- const args = [...opts.args];
192
- const jsonOutput = hasFlag(args, "--json");
193
- const ctx: LinkedInCliContext = {
194
- args,
195
- jsonOutput,
196
- runtime: opts.runtime,
197
- };
198
- const subcommand = args.shift();
199
- if (!subcommand || subcommand === "help" || subcommand === "--help") {
200
- linkedinHelp();
201
- return;
202
- }
203
- if (subcommand === "connect") {
204
- await runConnectCommand(ctx);
205
- return;
206
- }
207
- throw new LinkedInCliError(`Unknown linkedin subcommand: ${subcommand}`);
208
- }
209
-
210
- export { LinkedInCliError };