@dench.com/cli 2.1.3 → 2.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dench.ts +58 -6
  2. package/email.ts +424 -0
  3. package/package.json +2 -1
package/dench.ts CHANGED
@@ -2351,6 +2351,7 @@ async function getRuntime() {
2351
2351
  return {
2352
2352
  mode: "session" as const,
2353
2353
  host: resolveApiHost(host),
2354
+ convexUrl: stored.session.convexUrl,
2354
2355
  client: createCliConvexClient(stored.session.convexUrl),
2355
2356
  sessionToken: stored.session.sessionToken,
2356
2357
  organization: stored.session.organization,
@@ -2397,6 +2398,7 @@ async function getRuntime() {
2397
2398
  return {
2398
2399
  mode: "apiKey" as const,
2399
2400
  host: apiHost,
2401
+ convexUrl: sandboxConvexUrl,
2400
2402
  client: createCliConvexClient(sandboxConvexUrl),
2401
2403
  sessionToken: apiKey,
2402
2404
  organization: sandboxOrganization,
@@ -2412,6 +2414,7 @@ async function getRuntime() {
2412
2414
  return {
2413
2415
  mode: "session" as const,
2414
2416
  host: apiHost,
2417
+ convexUrl: sandboxConvexUrl,
2415
2418
  client: createCliConvexClient(sandboxConvexUrl),
2416
2419
  sessionToken: sandboxAgentToken,
2417
2420
  organization: sandboxOrganization,
@@ -2981,13 +2984,44 @@ function suggestedWorkFromArtifacts(artifacts: JsonRecord[]) {
2981
2984
  );
2982
2985
  }
2983
2986
 
2987
+ /**
2988
+ * The fs daemon authenticates purely from `CONVEX_URL` + `DENCH_API_KEY`
2989
+ * env vars (sandboxes bake them in). Local shells running off an OAuth
2990
+ * agent session have neither — resolve the active session and inject
2991
+ * its convexUrl + `dch_agent_*` token. The server-side files functions
2992
+ * accept the agent-session token in the same `apiKey` slot (prefix
2993
+ * dispatch, like `requireCrmAccess`), so the daemon works unchanged.
2994
+ */
2995
+ async function resolveFsDaemonEnv(): Promise<NodeJS.ProcessEnv> {
2996
+ if (process.env.CONVEX_URL?.trim() && process.env.DENCH_API_KEY?.trim()) {
2997
+ return process.env;
2998
+ }
2999
+ try {
3000
+ const runtime = await getRuntime();
3001
+ if (runtime.mode === "session" || runtime.mode === "apiKey") {
3002
+ return {
3003
+ ...process.env,
3004
+ CONVEX_URL: process.env.CONVEX_URL?.trim() || runtime.convexUrl,
3005
+ DENCH_API_KEY:
3006
+ process.env.DENCH_API_KEY?.trim() || runtime.sessionToken,
3007
+ };
3008
+ }
3009
+ } catch {
3010
+ // No saved session / dev env — fall through with the unmodified env;
3011
+ // the daemon prints its own missing-credential error for subcommands
3012
+ // that need Convex access.
3013
+ }
3014
+ return process.env;
3015
+ }
3016
+
2984
3017
  async function runFsCommand() {
2985
3018
  const subArgs = args.slice(args.indexOf("fs") + 1);
2986
3019
  const configuredBinary = process.env.DENCH_FS_DAEMON_BIN?.trim();
2987
3020
  const binary = configuredBinary || "dench-fs-daemon";
3021
+ const daemonEnv = await resolveFsDaemonEnv();
2988
3022
  let exitCode: number;
2989
3023
  try {
2990
- exitCode = await spawnInherited(binary, subArgs);
3024
+ exitCode = await spawnInherited(binary, subArgs, daemonEnv);
2991
3025
  } catch (error) {
2992
3026
  if (configuredBinary || !isCommandNotFound(error)) {
2993
3027
  throw error;
@@ -2995,7 +3029,11 @@ async function runFsCommand() {
2995
3029
  const localDaemon = fileURLToPath(
2996
3030
  new URL("./fs-daemon.ts", import.meta.url),
2997
3031
  );
2998
- exitCode = await spawnInherited("bun", [localDaemon, ...subArgs]);
3032
+ exitCode = await spawnInherited(
3033
+ "bun",
3034
+ [localDaemon, ...subArgs],
3035
+ daemonEnv,
3036
+ );
2999
3037
  }
3000
3038
  if (exitCode !== 0) {
3001
3039
  process.exitCode = exitCode;
@@ -3025,8 +3063,18 @@ async function requireFilesApiKey(
3025
3063
  runtime: Runtime,
3026
3064
  command: string,
3027
3065
  ): Promise<string> {
3028
- const auth = await resolveGatewayCommandAuth(runtime, command);
3029
- return auth.bearerToken;
3066
+ // The Convex files functions accept either a unified Dench API key OR a
3067
+ // `dch_agent_*` OAuth agent-session token in their `apiKey` arg (the
3068
+ // server dispatches on prefix, same as `requireCrmAccess`). So the
3069
+ // runtime's own bearer works directly — no gateway-key exchange, which
3070
+ // used to gate OAuth sessions behind a paid workspace and an extra
3071
+ // host round-trip.
3072
+ if (runtime.mode === "session" || runtime.mode === "apiKey") {
3073
+ return runtime.sessionToken;
3074
+ }
3075
+ const envApiKey = process.env.DENCH_API_KEY?.trim();
3076
+ if (envApiKey) return envApiKey;
3077
+ throw loginRequiredError(command);
3030
3078
  }
3031
3079
 
3032
3080
  type FileTreeRow = {
@@ -3603,11 +3651,15 @@ function sha256HexSync(bytes: Uint8Array): string {
3603
3651
  return createHash("sha256").update(bytes).digest("hex");
3604
3652
  }
3605
3653
 
3606
- async function spawnInherited(binary: string, commandArgs: string[]) {
3654
+ async function spawnInherited(
3655
+ binary: string,
3656
+ commandArgs: string[],
3657
+ env: NodeJS.ProcessEnv = process.env,
3658
+ ) {
3607
3659
  return await new Promise<number>((resolve, reject) => {
3608
3660
  const child = spawn(binary, commandArgs, {
3609
3661
  stdio: "inherit",
3610
- env: process.env,
3662
+ env,
3611
3663
  });
3612
3664
  child.on("error", reject);
3613
3665
  child.on("close", (code) => resolve(code ?? 1));
package/email.ts ADDED
@@ -0,0 +1,424 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type { ConvexHttpClient } from "convex/browser";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { getFlag, parseJson } from "./lib/cli-args";
5
+
6
+ type EmailCliContext = {
7
+ convex: ConvexHttpClient;
8
+ args: string[];
9
+ jsonOutput: boolean;
10
+ sessionToken?: string;
11
+ bearerToken?: string;
12
+ gatewayBaseUrl?: string;
13
+ gatewayHeaders?: Record<string, string>;
14
+ };
15
+
16
+ class EmailCliError extends Error {}
17
+
18
+ type JsonRecord = Record<string, unknown>;
19
+
20
+ function normalizeGatewayBaseUrl(value: string): string {
21
+ return value.replace(/\/+$/, "").replace(/\/v1$/, "");
22
+ }
23
+
24
+ function resolveGatewayBaseUrl(ctx: EmailCliContext): string {
25
+ const explicit =
26
+ process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
27
+ if (explicit) return normalizeGatewayBaseUrl(explicit);
28
+ if (ctx.gatewayBaseUrl?.trim()) {
29
+ return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
30
+ }
31
+ const host = process.env.DENCH_HOST?.trim();
32
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
33
+ return "http://localhost:8787";
34
+ }
35
+ return "https://gateway.merseoriginals.com";
36
+ }
37
+
38
+ function requireBearerToken(ctx: EmailCliContext): string {
39
+ const token = ctx.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
40
+ if (!token) {
41
+ throw new EmailCliError(
42
+ "No Dench credential found. Set DENCH_API_KEY or run `dench signin`.",
43
+ );
44
+ }
45
+ return token;
46
+ }
47
+
48
+ async function callGateway(
49
+ ctx: EmailCliContext,
50
+ method: "GET" | "POST",
51
+ path: string,
52
+ body?: JsonRecord,
53
+ ) {
54
+ const baseUrl = resolveGatewayBaseUrl(ctx);
55
+ const response = await fetch(`${baseUrl}${path}`, {
56
+ method,
57
+ headers: {
58
+ accept: "application/json",
59
+ authorization: `Bearer ${requireBearerToken(ctx)}`,
60
+ ...(ctx.gatewayHeaders ?? {}),
61
+ ...(method === "POST" ? { "content-type": "application/json" } : {}),
62
+ },
63
+ ...(method === "POST" ? { body: JSON.stringify(body ?? {}) } : {}),
64
+ });
65
+ const text = await response.text();
66
+ if (!response.ok) {
67
+ throw new EmailCliError(
68
+ `Email service returned ${response.status}: ${text.slice(0, 500)}`,
69
+ );
70
+ }
71
+ const payload = text ? JSON.parse(text) : {};
72
+ return payload;
73
+ }
74
+
75
+ async function callMutation(
76
+ ctx: EmailCliContext,
77
+ name: string,
78
+ args: JsonRecord,
79
+ ) {
80
+ const ref = makeFunctionReference<"mutation">(name);
81
+ return await ctx.convex.mutation(ref, {
82
+ ...args,
83
+ sessionToken: ctx.sessionToken,
84
+ } as never);
85
+ }
86
+
87
+ async function callQuery(ctx: EmailCliContext, name: string, args: JsonRecord) {
88
+ const ref = makeFunctionReference<"query">(name);
89
+ return await ctx.convex.query(ref, {
90
+ ...args,
91
+ sessionToken: ctx.sessionToken,
92
+ } as never);
93
+ }
94
+
95
+ async function callAction(
96
+ ctx: EmailCliContext,
97
+ name: string,
98
+ args: JsonRecord,
99
+ ) {
100
+ const ref = makeFunctionReference<"action">(name);
101
+ return await ctx.convex.action(ref, {
102
+ ...args,
103
+ sessionToken: ctx.sessionToken,
104
+ } as never);
105
+ }
106
+
107
+ function out(ctx: EmailCliContext, payload: unknown, text: string) {
108
+ if (ctx.jsonOutput) {
109
+ console.log(JSON.stringify(payload, null, 2));
110
+ return;
111
+ }
112
+ console.log(text);
113
+ }
114
+
115
+ function requiredFlag(args: string[], name: string): string {
116
+ const value = getFlag(args, name)?.trim();
117
+ if (!value) throw new EmailCliError(`Missing required ${name}`);
118
+ return value;
119
+ }
120
+
121
+ function optionalJsonFlag(args: string[], name: string): unknown | undefined {
122
+ const raw = getFlag(args, name);
123
+ return raw === undefined ? undefined : parseJson(raw);
124
+ }
125
+
126
+ async function readTextFlag(
127
+ args: string[],
128
+ inlineFlag: string,
129
+ fileFlag: string,
130
+ ) {
131
+ const inline = getFlag(args, inlineFlag);
132
+ if (inline !== undefined) return inline;
133
+ const path = getFlag(args, fileFlag);
134
+ if (!path) return undefined;
135
+ return await readFile(path, "utf8");
136
+ }
137
+
138
+ async function readRecipients(path: string): Promise<JsonRecord[]> {
139
+ const content = await readFile(path, "utf8");
140
+ const trimmed = content.trim();
141
+ if (!trimmed) return [];
142
+ if (trimmed.startsWith("[")) {
143
+ const parsed = JSON.parse(trimmed);
144
+ if (!Array.isArray(parsed))
145
+ throw new EmailCliError("Recipients file must be an array or JSONL");
146
+ return parsed.map((row) =>
147
+ row && typeof row === "object"
148
+ ? (row as JsonRecord)
149
+ : { email: String(row) },
150
+ );
151
+ }
152
+ return trimmed
153
+ .split(/\r?\n/)
154
+ .filter(Boolean)
155
+ .map((line) => {
156
+ const parsed = JSON.parse(line);
157
+ return parsed && typeof parsed === "object"
158
+ ? (parsed as JsonRecord)
159
+ : { email: String(parsed) };
160
+ });
161
+ }
162
+
163
+ function toRecipients(rows: JsonRecord[]) {
164
+ return rows.map((row) => {
165
+ const email =
166
+ row.email ?? row.Email ?? row["Email Address"] ?? row.emailAddress;
167
+ if (typeof email !== "string") {
168
+ throw new EmailCliError("Each recipient row must include an email field");
169
+ }
170
+ return {
171
+ email,
172
+ displayName:
173
+ typeof row.name === "string"
174
+ ? row.name
175
+ : typeof row.displayName === "string"
176
+ ? row.displayName
177
+ : undefined,
178
+ personalization: row.personalization ?? row,
179
+ personEntryId:
180
+ typeof row.personEntryId === "string" ? row.personEntryId : undefined,
181
+ companyEntryId:
182
+ typeof row.companyEntryId === "string" ? row.companyEntryId : undefined,
183
+ };
184
+ });
185
+ }
186
+
187
+ export function emailHelp() {
188
+ console.log(`dench email — Dench Emailing Service campaign commands
189
+
190
+ Usage:
191
+ dench email identity verify --from founder@example.com [--json]
192
+ dench email identity verify --domain example.com --from founder@example.com [--json]
193
+ dench email identity status --identity founder@example.com [--json]
194
+ dench email identity status --identity-id <identityId> [--json]
195
+ dench email template create --name "Intro" --subject "Hi {{firstName}}" --html-file body.html [--text-file body.txt] [--json]
196
+ dench email template preview --template <id> --data '{"firstName":"Ada"}' [--json]
197
+ dench email campaign create --name "Q2 outreach" --identity <id> --template <id> [--json]
198
+ dench email campaign recipients add --campaign <id> --file recipients.jsonl [--json]
199
+ dench email campaign preview --campaign <id> [--sample 5] [--json]
200
+ dench email campaign submit --campaign <id> [--json]
201
+ dench email campaign status --campaign <id> [--json]
202
+ dench email campaign pause|resume|cancel --campaign <id> [--json]`);
203
+ }
204
+
205
+ export async function runEmailCommand(ctx: EmailCliContext): Promise<void> {
206
+ const group = ctx.args.shift();
207
+ if (!group || group === "help" || group === "--help" || group === "-h") {
208
+ emailHelp();
209
+ return;
210
+ }
211
+ if (group === "identity") {
212
+ await runIdentity(ctx);
213
+ return;
214
+ }
215
+ if (group === "template") {
216
+ await runTemplate(ctx);
217
+ return;
218
+ }
219
+ if (group === "campaign") {
220
+ await runCampaign(ctx);
221
+ return;
222
+ }
223
+ throw new EmailCliError(`Unknown dench email subcommand: ${group}`);
224
+ }
225
+
226
+ async function runIdentity(ctx: EmailCliContext) {
227
+ const sub = ctx.args.shift();
228
+ if (sub === "verify") {
229
+ const domain = getFlag(ctx.args, "--domain");
230
+ const from = requiredFlag(ctx.args, "--from");
231
+ const type = domain ? "domain" : "email";
232
+ const identity = domain ?? from;
233
+ const configurationSetName = getFlag(ctx.args, "--configuration-set");
234
+ const created = await callMutation(
235
+ ctx,
236
+ "functions/emailCampaigns:createIdentity",
237
+ {
238
+ type,
239
+ domain,
240
+ fromEmail: from,
241
+ fromName: getFlag(ctx.args, "--from-name"),
242
+ replyToEmail: getFlag(ctx.args, "--reply-to"),
243
+ configurationSetName,
244
+ },
245
+ );
246
+ const verification = await callAction(
247
+ ctx,
248
+ "functions/emailCampaignsNode:beginSenderVerification",
249
+ { identityId: (created as JsonRecord).identityId },
250
+ );
251
+ out(
252
+ ctx,
253
+ { ok: true, created, verification },
254
+ `Verification started for ${identity}`,
255
+ );
256
+ return;
257
+ }
258
+ if (sub === "status") {
259
+ const identityId = getFlag(ctx.args, "--identity-id");
260
+ if (identityId) {
261
+ const payload = await callAction(
262
+ ctx,
263
+ "functions/emailCampaignsNode:refreshIdentityStatus",
264
+ { identityId },
265
+ );
266
+ out(ctx, payload, JSON.stringify(payload, null, 2));
267
+ return;
268
+ }
269
+ const identity = requiredFlag(ctx.args, "--identity");
270
+ const payload = await callGateway(
271
+ ctx,
272
+ "GET",
273
+ `/v1/email/identities/${encodeURIComponent(identity)}/status`,
274
+ );
275
+ const status = payload as JsonRecord;
276
+ const shaped = {
277
+ ok: status.ok === true,
278
+ identity: status.identity,
279
+ deliveryVerified: status.verifiedForSendingStatus === true,
280
+ deliveryVerificationStatus: status.verificationStatus,
281
+ };
282
+ out(ctx, shaped, JSON.stringify(shaped, null, 2));
283
+ return;
284
+ }
285
+ throw new EmailCliError("Usage: dench email identity verify|status");
286
+ }
287
+
288
+ async function runTemplate(ctx: EmailCliContext) {
289
+ const sub = ctx.args.shift();
290
+ if (sub === "create") {
291
+ const htmlBody = await readTextFlag(ctx.args, "--html", "--html-file");
292
+ if (!htmlBody) throw new EmailCliError("Missing --html or --html-file");
293
+ const textBody = await readTextFlag(ctx.args, "--text", "--text-file");
294
+ const payload = await callMutation(
295
+ ctx,
296
+ "functions/emailCampaigns:createTemplate",
297
+ {
298
+ name: requiredFlag(ctx.args, "--name"),
299
+ subject: requiredFlag(ctx.args, "--subject"),
300
+ htmlBody,
301
+ textBody,
302
+ },
303
+ );
304
+ out(
305
+ ctx,
306
+ payload,
307
+ `Template created: ${(payload as JsonRecord).templateId}`,
308
+ );
309
+ return;
310
+ }
311
+ if (sub === "preview") {
312
+ const templateId = requiredFlag(ctx.args, "--template");
313
+ const data = optionalJsonFlag(ctx.args, "--data");
314
+ const payload = await callQuery(
315
+ ctx,
316
+ "functions/emailCampaigns:previewTemplate",
317
+ { templateId, data },
318
+ );
319
+ out(ctx, payload, JSON.stringify(payload, null, 2));
320
+ return;
321
+ }
322
+ throw new EmailCliError("Usage: dench email template create|preview");
323
+ }
324
+
325
+ async function runCampaign(ctx: EmailCliContext) {
326
+ const sub = ctx.args.shift();
327
+ if (sub === "create") {
328
+ const payload = await callMutation(
329
+ ctx,
330
+ "functions/emailCampaigns:createCampaign",
331
+ {
332
+ name: requiredFlag(ctx.args, "--name"),
333
+ identityId: requiredFlag(ctx.args, "--identity"),
334
+ templateId: requiredFlag(ctx.args, "--template"),
335
+ scheduledAt: getFlag(ctx.args, "--scheduled-at")
336
+ ? Date.parse(requiredFlag(ctx.args, "--scheduled-at"))
337
+ : undefined,
338
+ maxRecipients: getFlag(ctx.args, "--max-recipients")
339
+ ? Number(requiredFlag(ctx.args, "--max-recipients"))
340
+ : undefined,
341
+ },
342
+ );
343
+ out(
344
+ ctx,
345
+ payload,
346
+ `Campaign created: ${(payload as JsonRecord).campaignId}`,
347
+ );
348
+ return;
349
+ }
350
+ if (sub === "recipients") {
351
+ const nested = ctx.args.shift();
352
+ if (nested !== "add") {
353
+ throw new EmailCliError(
354
+ "Usage: dench email campaign recipients add --campaign <id> --file recipients.jsonl",
355
+ );
356
+ }
357
+ const campaignId = requiredFlag(ctx.args, "--campaign");
358
+ const file = getFlag(ctx.args, "--file");
359
+ const rows = file
360
+ ? await readRecipients(file)
361
+ : ((optionalJsonFlag(ctx.args, "--data") as JsonRecord[] | undefined) ??
362
+ []);
363
+ if (rows.length === 0) throw new EmailCliError("No recipients provided");
364
+ const payload = await callMutation(
365
+ ctx,
366
+ "functions/emailCampaigns:addRecipients",
367
+ { campaignId, recipients: toRecipients(rows) },
368
+ );
369
+ out(ctx, payload, JSON.stringify(payload, null, 2));
370
+ return;
371
+ }
372
+ if (sub === "preview") {
373
+ const sampleRaw = getFlag(ctx.args, "--sample");
374
+ const payload = await callQuery(
375
+ ctx,
376
+ "functions/emailCampaigns:previewCampaign",
377
+ {
378
+ campaignId: requiredFlag(ctx.args, "--campaign"),
379
+ sample: sampleRaw ? Number(sampleRaw) : undefined,
380
+ },
381
+ );
382
+ out(ctx, payload, JSON.stringify(payload, null, 2));
383
+ return;
384
+ }
385
+ if (sub === "submit") {
386
+ const payload = await callMutation(
387
+ ctx,
388
+ "functions/emailCampaigns:submitCampaign",
389
+ {
390
+ campaignId: requiredFlag(ctx.args, "--campaign"),
391
+ approvalId: getFlag(ctx.args, "--approval"),
392
+ },
393
+ );
394
+ out(ctx, payload, JSON.stringify(payload, null, 2));
395
+ return;
396
+ }
397
+ if (sub === "status") {
398
+ const payload = await callQuery(
399
+ ctx,
400
+ "functions/emailCampaigns:getCampaign",
401
+ {
402
+ campaignId: requiredFlag(ctx.args, "--campaign"),
403
+ },
404
+ );
405
+ out(ctx, payload, JSON.stringify(payload, null, 2));
406
+ return;
407
+ }
408
+ if (sub === "pause" || sub === "resume" || sub === "cancel") {
409
+ const fn =
410
+ sub === "pause"
411
+ ? "pauseCampaign"
412
+ : sub === "resume"
413
+ ? "resumeCampaign"
414
+ : "cancelCampaign";
415
+ const payload = await callMutation(ctx, `functions/emailCampaigns:${fn}`, {
416
+ campaignId: requiredFlag(ctx.args, "--campaign"),
417
+ });
418
+ out(ctx, payload, JSON.stringify(payload, null, 2));
419
+ return;
420
+ }
421
+ throw new EmailCliError(
422
+ "Usage: dench email campaign create|recipients|preview|submit|status|pause|resume|cancel",
423
+ );
424
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.1.3",
3
+ "version": "2.1.5",
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": {
@@ -24,6 +24,7 @@
24
24
  "chat.ts",
25
25
  "chat-spawn.ts",
26
26
  "crm.ts",
27
+ "email.ts",
27
28
  "members.ts",
28
29
  "cron.ts",
29
30
  "browser.ts",