@dench.com/cli 2.6.0 → 2.7.1

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/chat-spawn.ts CHANGED
@@ -108,6 +108,17 @@ function buildStreamUrlByThreadId(host: string, threadId: string): string {
108
108
  return `${host.replace(/\/+$/, "")}/api/chat/stream?sessionId=${encodeURIComponent(threadId)}`;
109
109
  }
110
110
 
111
+ const CHAT_STREAM_RECONNECT_DELAY_MS = 100;
112
+ const CHAT_STREAM_MAX_EMPTY_BACKOFF_MS = 2_000;
113
+
114
+ function emptyActiveStreamBackoffMs(consecutiveEmptyResponses: number): number {
115
+ const exponent = Math.max(0, Math.min(consecutiveEmptyResponses - 1, 10));
116
+ return Math.min(
117
+ CHAT_STREAM_RECONNECT_DELAY_MS * 2 ** exponent,
118
+ CHAT_STREAM_MAX_EMPTY_BACKOFF_MS,
119
+ );
120
+ }
121
+
111
122
  /**
112
123
  * Subscribe to the durable chat stream and pretty-print useful chunks
113
124
  * until the stream closes. Mirrors the chunk types the chat panel
@@ -131,37 +142,25 @@ async function followStream(args: {
131
142
  runId?: string;
132
143
  threadId?: string;
133
144
  }): Promise<void> {
134
- const url = args.runId
145
+ const baseUrl = args.runId
135
146
  ? buildStreamUrlByRunId(args.host, args.runId)
136
147
  : args.threadId
137
148
  ? buildStreamUrlByThreadId(args.host, args.threadId)
138
149
  : null;
139
- if (!url) {
150
+ if (!baseUrl) {
140
151
  throw new ChatSpawnError(
141
152
  "followStream requires either { runId } or { threadId }",
142
153
  );
143
154
  }
144
- const response = await fetch(url, {
145
- method: "GET",
146
- headers: {
147
- accept: "text/event-stream",
148
- authorization: `Bearer ${args.bearerToken}`,
149
- },
150
- });
151
- if (!response.ok || !response.body) {
152
- throw new ChatSpawnError(
153
- `Failed to open stream: ${response.status} ${response.statusText}`,
154
- );
155
- }
156
-
157
- const reader = response.body.getReader();
158
- const decoder = new TextDecoder();
159
- let buffer = "";
160
155
  // Tool calls in flight, keyed by toolCallId, so we can render
161
156
  // a single line per call ("→ tool_name … done") instead of
162
157
  // dumping every chunk verbatim.
163
158
  const inFlightTools = new Map<string, { toolName: string }>();
164
159
  let textBuffer = "";
160
+ let segmentWorkflowRunId: string | null = null;
161
+ let segmentOrdinal: number | null = null;
162
+ let startIndex = 0;
163
+ let consecutiveEmptyActiveResponses = 0;
165
164
 
166
165
  const flushText = () => {
167
166
  if (textBuffer) {
@@ -170,104 +169,199 @@ async function followStream(args: {
170
169
  }
171
170
  };
172
171
 
173
- const handleChunk = (raw: string) => {
174
- if (!raw.startsWith("data:")) return;
175
- const data = raw.slice("data:".length).trim();
176
- if (!data || data === "[DONE]") return;
177
- let parsed: unknown;
178
- try {
179
- parsed = JSON.parse(data);
180
- } catch {
181
- return;
172
+ const closeInFlightToolLines = (suffix: string) => {
173
+ if (inFlightTools.size === 0) return false;
174
+ process.stdout.write(suffix);
175
+ inFlightTools.clear();
176
+ return true;
177
+ };
178
+
179
+ while (true) {
180
+ const url = new URL(baseUrl);
181
+ if (startIndex > 0) {
182
+ url.searchParams.set("startIndex", String(startIndex));
182
183
  }
183
- if (!parsed || typeof parsed !== "object") return;
184
- const c = parsed as {
185
- type?: unknown;
186
- delta?: unknown;
187
- text?: unknown;
188
- toolName?: unknown;
189
- toolCallId?: unknown;
190
- input?: unknown;
191
- output?: unknown;
192
- errorText?: unknown;
193
- };
194
- switch (c.type) {
195
- case "text-delta":
196
- case "reasoning-delta": {
197
- if (typeof c.delta === "string") {
198
- textBuffer += c.delta;
199
- }
200
- break;
184
+ const response: Response = await fetch(url, {
185
+ method: "GET",
186
+ headers: {
187
+ accept: "text/event-stream",
188
+ authorization: `Bearer ${args.bearerToken}`,
189
+ ...(segmentWorkflowRunId
190
+ ? {
191
+ "x-chat-segment-workflow-run-id": segmentWorkflowRunId,
192
+ }
193
+ : {}),
194
+ ...(segmentOrdinal !== null
195
+ ? { "x-chat-segment-ordinal": String(segmentOrdinal) }
196
+ : {}),
197
+ },
198
+ });
199
+ const responseWorkflowRunId: string | null =
200
+ response.headers.get("x-chat-segment-workflow-run-id") ??
201
+ response.headers.get("x-workflow-run-id");
202
+ const rawOrdinal = response.headers.get("x-chat-segment-ordinal");
203
+ const responseOrdinal =
204
+ rawOrdinal !== null && Number.isSafeInteger(Number(rawOrdinal))
205
+ ? Number(rawOrdinal)
206
+ : null;
207
+ const segmentChanged =
208
+ (responseWorkflowRunId !== null &&
209
+ responseWorkflowRunId !== segmentWorkflowRunId) ||
210
+ (responseOrdinal !== null && responseOrdinal !== segmentOrdinal);
211
+ if (segmentChanged) {
212
+ if (segmentWorkflowRunId !== null || segmentOrdinal !== null) {
213
+ closeInFlightToolLines(" continued in next segment\n");
201
214
  }
202
- case "tool-input-start": {
203
- flushText();
204
- if (
205
- typeof c.toolCallId === "string" &&
206
- typeof c.toolName === "string"
207
- ) {
208
- inFlightTools.set(c.toolCallId, { toolName: c.toolName });
209
- process.stdout.write(`\n→ ${c.toolName} …`);
210
- }
211
- break;
215
+ startIndex = 0;
216
+ consecutiveEmptyActiveResponses = 0;
217
+ }
218
+ segmentWorkflowRunId = responseWorkflowRunId ?? segmentWorkflowRunId;
219
+ segmentOrdinal = responseOrdinal ?? segmentOrdinal;
220
+
221
+ if (response.status === 202) {
222
+ const retryAfterSeconds = Number(response.headers.get("retry-after"));
223
+ const retryDelayMs =
224
+ Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
225
+ ? Math.max(50, retryAfterSeconds * 1_000)
226
+ : 100;
227
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
228
+ continue;
229
+ }
230
+ if (!response.ok || !response.body) {
231
+ throw new ChatSpawnError(
232
+ `Failed to open stream: ${response.status} ${response.statusText}`,
233
+ );
234
+ }
235
+
236
+ const reader = response.body.getReader();
237
+ const decoder = new TextDecoder();
238
+ let buffer = "";
239
+ let responseChunkCount = 0;
240
+ let gotFinish = false;
241
+
242
+ const handleChunk = (raw: string) => {
243
+ if (!raw.startsWith("data:")) return;
244
+ const data = raw.slice("data:".length).trim();
245
+ if (!data || data === "[DONE]") return;
246
+ let parsed: unknown;
247
+ try {
248
+ parsed = JSON.parse(data);
249
+ } catch {
250
+ return;
212
251
  }
213
- case "tool-output-available": {
214
- flushText();
215
- if (typeof c.toolCallId === "string") {
216
- const meta = inFlightTools.get(c.toolCallId);
217
- inFlightTools.delete(c.toolCallId);
218
- process.stdout.write(` done (${meta?.toolName ?? "tool"})\n`);
252
+ if (!parsed || typeof parsed !== "object") return;
253
+ responseChunkCount += 1;
254
+ const c = parsed as {
255
+ type?: unknown;
256
+ delta?: unknown;
257
+ text?: unknown;
258
+ toolName?: unknown;
259
+ toolCallId?: unknown;
260
+ input?: unknown;
261
+ output?: unknown;
262
+ errorText?: unknown;
263
+ };
264
+ switch (c.type) {
265
+ case "text-delta":
266
+ case "reasoning-delta": {
267
+ if (typeof c.delta === "string") {
268
+ textBuffer += c.delta;
269
+ }
270
+ break;
219
271
  }
220
- break;
221
- }
222
- case "tool-output-error": {
223
- flushText();
224
- if (typeof c.toolCallId === "string") {
225
- const meta = inFlightTools.get(c.toolCallId);
226
- inFlightTools.delete(c.toolCallId);
227
- process.stdout.write(
228
- ` error (${meta?.toolName ?? "tool"}): ${
229
- typeof c.errorText === "string" ? c.errorText : "unknown"
230
- }\n`,
231
- );
272
+ case "tool-input-start": {
273
+ flushText();
274
+ if (
275
+ typeof c.toolCallId === "string" &&
276
+ typeof c.toolName === "string"
277
+ ) {
278
+ inFlightTools.set(c.toolCallId, { toolName: c.toolName });
279
+ process.stdout.write(`\n→ ${c.toolName} …`);
280
+ }
281
+ break;
232
282
  }
233
- break;
234
- }
235
- case "finish": {
236
- flushText();
237
- process.stdout.write("\n");
238
- break;
283
+ case "tool-output-available": {
284
+ flushText();
285
+ if (typeof c.toolCallId === "string") {
286
+ const meta = inFlightTools.get(c.toolCallId);
287
+ inFlightTools.delete(c.toolCallId);
288
+ process.stdout.write(` done (${meta?.toolName ?? "tool"})\n`);
289
+ }
290
+ break;
291
+ }
292
+ case "tool-output-error": {
293
+ flushText();
294
+ if (typeof c.toolCallId === "string") {
295
+ const meta = inFlightTools.get(c.toolCallId);
296
+ inFlightTools.delete(c.toolCallId);
297
+ process.stdout.write(
298
+ ` error (${meta?.toolName ?? "tool"}): ${
299
+ typeof c.errorText === "string" ? c.errorText : "unknown"
300
+ }\n`,
301
+ );
302
+ }
303
+ break;
304
+ }
305
+ case "finish": {
306
+ gotFinish = true;
307
+ flushText();
308
+ if (!closeInFlightToolLines(" ended\n")) {
309
+ process.stdout.write("\n");
310
+ }
311
+ break;
312
+ }
313
+ case "error": {
314
+ flushText();
315
+ const msg =
316
+ typeof c.errorText === "string" ? c.errorText : "stream error";
317
+ process.stderr.write(`\n[error] ${msg}\n`);
318
+ break;
319
+ }
320
+ default:
321
+ break;
239
322
  }
240
- case "error": {
241
- flushText();
242
- const msg =
243
- typeof c.errorText === "string" ? c.errorText : "stream error";
244
- process.stderr.write(`\n[error] ${msg}\n`);
245
- break;
323
+ };
324
+
325
+ // Read SSE-style chunks. The route uses Vercel AI SDK's
326
+ // `createUIMessageStreamResponse` which emits `data: {…}\n\n`
327
+ // lines, so we split on double newlines.
328
+ while (true) {
329
+ const { value, done } = await reader.read();
330
+ if (done) break;
331
+ buffer += decoder.decode(value, { stream: true });
332
+ let nextBreak = buffer.indexOf("\n\n");
333
+ while (nextBreak !== -1) {
334
+ const event = buffer.slice(0, nextBreak);
335
+ buffer = buffer.slice(nextBreak + 2);
336
+ // Each event may contain multiple `data:` lines.
337
+ for (const line of event.split("\n")) {
338
+ if (line.trim().length > 0) handleChunk(line);
339
+ }
340
+ nextBreak = buffer.indexOf("\n\n");
246
341
  }
247
- default:
248
- break;
249
342
  }
250
- };
343
+ flushText();
344
+ startIndex += responseChunkCount;
251
345
 
252
- // Read SSE-style chunks. The route uses Vercel AI SDK's
253
- // `createUIMessageStreamResponse` which emits `data: {…}\n\n`
254
- // lines, so we split on double newlines.
255
- while (true) {
256
- const { value, done } = await reader.read();
257
- if (done) break;
258
- buffer += decoder.decode(value, { stream: true });
259
- let nextBreak = buffer.indexOf("\n\n");
260
- while (nextBreak !== -1) {
261
- const event = buffer.slice(0, nextBreak);
262
- buffer = buffer.slice(nextBreak + 2);
263
- // Each event may contain multiple `data:` lines.
264
- for (const line of event.split("\n")) {
265
- if (line.trim().length > 0) handleChunk(line);
266
- }
267
- nextBreak = buffer.indexOf("\n\n");
346
+ const streamStatus = response.headers.get("x-workflow-stream-status");
347
+ const runActive = response.headers.get("X-Run-Active") !== "false";
348
+ if (gotFinish || !runActive || streamStatus === "terminal") {
349
+ closeInFlightToolLines(" ended\n");
350
+ return;
268
351
  }
352
+ // Segment rollover intentionally closes without `finish`; reconnect to
353
+ // the thread/run route, which resolves the new segment and resets the
354
+ // durable stream index when the segment identity changes. Empty active
355
+ // responses can occur during that handoff; back them off so a delayed
356
+ // rollover cannot turn into a tight polling loop.
357
+ consecutiveEmptyActiveResponses =
358
+ responseChunkCount === 0 ? consecutiveEmptyActiveResponses + 1 : 0;
359
+ const reconnectDelayMs =
360
+ responseChunkCount === 0
361
+ ? emptyActiveStreamBackoffMs(consecutiveEmptyActiveResponses)
362
+ : CHAT_STREAM_RECONNECT_DELAY_MS;
363
+ await new Promise((resolve) => setTimeout(resolve, reconnectDelayMs));
269
364
  }
270
- flushText();
271
365
  }
272
366
 
273
367
  function joinPositionalsExceptFlags(args: string[]): string {
@@ -315,17 +409,20 @@ async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
315
409
  const payload = result.payload as {
316
410
  threadId?: string;
317
411
  runId?: string;
412
+ workflowRunId?: string;
318
413
  threadUrl?: string;
319
414
  streamUrl?: string;
320
415
  } | null;
321
416
  out(ctx, payload);
322
417
 
323
- if (follow && payload?.runId) {
418
+ if (follow && (payload?.threadId || payload?.workflowRunId)) {
324
419
  if (!ctx.jsonOutput) console.error("\n--- following stream ---");
325
420
  await followStream({
326
421
  host: ctx.runtime.host,
327
422
  bearerToken: ctx.runtime.bearerToken,
328
- runId: payload.runId,
423
+ ...(payload.threadId
424
+ ? { threadId: payload.threadId }
425
+ : { runId: payload.workflowRunId }),
329
426
  });
330
427
  }
331
428
  }
@@ -371,17 +468,20 @@ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
371
468
  const payload = result.payload as {
372
469
  threadId?: string;
373
470
  runId?: string;
471
+ workflowRunId?: string;
374
472
  threadUrl?: string;
375
473
  streamUrl?: string;
376
474
  } | null;
377
475
  out(ctx, payload);
378
476
 
379
- if (follow && payload?.runId) {
477
+ if (follow && (payload?.threadId || payload?.workflowRunId)) {
380
478
  if (!ctx.jsonOutput) console.error("\n--- following stream ---");
381
479
  await followStream({
382
480
  host: ctx.runtime.host,
383
481
  bearerToken: ctx.runtime.bearerToken,
384
- runId: payload.runId,
482
+ ...(payload.threadId
483
+ ? { threadId: payload.threadId }
484
+ : { runId: payload.workflowRunId }),
385
485
  });
386
486
  }
387
487
  }
@@ -444,4 +544,4 @@ export async function runChatSpawnCommand(opts: {
444
544
  await runChatSend(ctx);
445
545
  }
446
546
 
447
- export { ChatSpawnError };
547
+ export { ChatSpawnError, followStream };
package/crm.ts CHANGED
@@ -3393,11 +3393,14 @@ Workspace members (for user-type fields):
3393
3393
  List \`{ userId, role, name, email }\` for every member of the
3394
3394
  current org. Use the userIds when setting \`Owner\`, \`Assignee\`,
3395
3395
  or any other \`user\`-type field so the UI shows a real linked
3396
- avatar instead of an unlinked literal-string badge. Check the
3396
+ avatar instead of an unlinked literal-string badge. Pass one id for
3397
+ one assignee or a JSON array for multiple assignees; never drop a
3398
+ co-owner from the source data. Check the
3397
3399
  object's actual field names first (\`crm fields list <object>\`) —
3398
3400
  the \`task\` object uses \`Assignee\`, not \`Owner\`, and some
3399
3401
  workspaces rename \`Name\` to \`Title\`:
3400
3402
  dench crm cells set task <entryId> Assignee '"<userId>"'
3403
+ dench crm cells set task <entryId> Assignee '["<userId1>","<userId2>"]'
3401
3404
  dench crm entries create task --data '{"Name":"X","Assignee":"<userId>"}'
3402
3405
 
3403
3406
  Batch:
package/cron.ts CHANGED
@@ -18,11 +18,9 @@
18
18
  * both formats; we just pass whichever bearer the parent runtime
19
19
  * resolved through `sessionToken`.
20
20
  *
21
- * Convex calls go direct via `ConvexHttpClient` (same pattern as
22
- * `cli/crm.ts`); `run-now` is the one operation that goes through
23
- * the REST API hop (`POST /api/cron/jobs/<id>/run-now`) because it
24
- * needs to start a Vercel Workflow which can only be done from a
25
- * Next.js route.
21
+ * Convex calls generally go direct via `ConvexHttpClient` (same pattern as
22
+ * `cli/crm.ts`). Run-now and delete go through REST: run-now starts a Vercel
23
+ * Workflow, while delete coordinates upstream app-event cleanup.
26
24
  *
27
25
  * Subcommands:
28
26
  * list List all cron jobs in the org.
@@ -166,6 +164,28 @@ async function getJson(
166
164
  return json;
167
165
  }
168
166
 
167
+ async function deleteJson(
168
+ url: string,
169
+ headers: Record<string, string>,
170
+ ): Promise<unknown> {
171
+ const response = await fetch(url, { method: "DELETE", headers });
172
+ const text = await response.text();
173
+ let json: unknown;
174
+ try {
175
+ json = text ? JSON.parse(text) : null;
176
+ } catch {
177
+ json = { raw: text };
178
+ }
179
+ if (!response.ok) {
180
+ const detail =
181
+ json && typeof json === "object" && "error" in json
182
+ ? String((json as Record<string, unknown>).error)
183
+ : JSON.stringify(json);
184
+ throw new CronCliError(`${url} failed (${response.status}): ${detail}`);
185
+ }
186
+ return json;
187
+ }
188
+
169
189
  async function callQuery(
170
190
  ctx: CronCliContext,
171
191
  fn: Parameters<ConvexHttpClient["query"]>[0],
@@ -339,9 +359,6 @@ const createCronTriggerRef = makeFunctionReference<"mutation">(
339
359
  const updateCronTriggerRef = makeFunctionReference<"mutation">(
340
360
  "functions/triggers:updateCronTrigger",
341
361
  );
342
- const deleteCronTriggerRef = makeFunctionReference<"mutation">(
343
- "functions/triggers:deleteCronTrigger",
344
- );
345
362
  const setCronEnabledRef = makeFunctionReference<"mutation">(
346
363
  "functions/triggers:setCronEnabled",
347
364
  );
@@ -709,7 +726,10 @@ async function runUpdate(ctx: CronCliContext): Promise<void> {
709
726
 
710
727
  async function runDelete(ctx: CronCliContext): Promise<void> {
711
728
  const jobId = shift(ctx.args, "job id");
712
- const result = await callMutation(ctx, deleteCronTriggerRef, { jobId });
729
+ const result = await deleteJson(
730
+ `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}`,
731
+ apiKeyHeaders(ctx.sessionToken ?? ""),
732
+ );
713
733
  out(ctx, result);
714
734
  }
715
735
 
package/dench.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  writeFile,
11
11
  } from "node:fs/promises";
12
12
  import { homedir, hostname, userInfo } from "node:os";
13
- import { basename, join, relative, resolve } from "node:path";
13
+ import { basename, dirname, join, relative, resolve } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { ConvexHttpClient } from "convex/browser";
16
16
  import { makeFunctionReference } from "convex/server";
@@ -579,6 +579,7 @@ returns ENOSYS on the FUSE-backed volume mount):
579
579
  dench files ls <path> [--json]
580
580
  dench files mv <src> <dst> [--json]
581
581
  dench files rm <path> [--recursive] [--json]
582
+ dench files download <path> [<local-dest>] [--url] [--json]
582
583
 
583
584
  Persist scratch -> workspace (cp from /tmp into /workspace + Convex sync):
584
585
  dench stage <local-path> [<workspace-dest>] [--clean] [--json]
@@ -3412,19 +3413,151 @@ async function runFilesRm() {
3412
3413
  });
3413
3414
  }
3414
3415
 
3416
+ async function writeLocalFileBytes(localPath: string, bytes: Uint8Array) {
3417
+ const parent = dirname(localPath);
3418
+ if (parent && parent !== ".") {
3419
+ await mkdir(parent, { recursive: true });
3420
+ }
3421
+ await writeFile(localPath, bytes);
3422
+ }
3423
+
3424
+ async function runFilesDownload() {
3425
+ const runtime = await getRuntime();
3426
+ const apiKey = await requireFilesApiKey(runtime, "dench files download");
3427
+ const targetRaw = positional(2);
3428
+ if (!targetRaw) {
3429
+ throw new CliError(
3430
+ "Usage: dench files download <path> [<local-dest>] [--url]",
3431
+ { code: "files_download_usage" },
3432
+ );
3433
+ }
3434
+ const target = normalizeFilesPath(targetRaw);
3435
+ const jsonOut = hasFlag("--json");
3436
+
3437
+ if (hasFlag("--url")) {
3438
+ const url = (await runtime.client.action(
3439
+ api.functions.files.getFileSignedDownloadUrl,
3440
+ { path: target, apiKey } as never,
3441
+ )) as string | null;
3442
+ if (!url) {
3443
+ throw new CliError(`No downloadable bytes for ${target}`, {
3444
+ code: "files_download_url_not_found",
3445
+ nextActions: [
3446
+ "Directories have no signed URL; run `dench files download <dir>` without --url to fetch the tree.",
3447
+ "Run `dench files ls` on the parent directory to confirm the path.",
3448
+ ],
3449
+ });
3450
+ }
3451
+ if (jsonOut) {
3452
+ print({ ok: true, path: target, url, note: "Signed URL expires." });
3453
+ } else {
3454
+ process.stdout.write(`${url}\n`);
3455
+ }
3456
+ return;
3457
+ }
3458
+
3459
+ const self =
3460
+ target === "/"
3461
+ ? null
3462
+ : (await listConvexTreeAt(runtime, apiKey, parentOfPath(target))).find(
3463
+ (row) => row.path === target,
3464
+ );
3465
+ const isDir = target === "/" || self?.isDir === true;
3466
+
3467
+ if (!isDir) {
3468
+ const bytes = await downloadConvexFileBytes(runtime, apiKey, target);
3469
+ if (!bytes) {
3470
+ throw new CliError(`Not found in Convex: ${target}`, {
3471
+ code: "files_download_not_found",
3472
+ nextActions: [
3473
+ "Run `dench files ls` on the parent directory to confirm.",
3474
+ "If the file only exists on the sandbox FS, run `dench fs sync --initial` first.",
3475
+ ],
3476
+ });
3477
+ }
3478
+ let dest = resolve(positional(3) ?? basename(target));
3479
+ const destStat = await stat(dest).catch(() => null);
3480
+ if (destStat?.isDirectory()) {
3481
+ dest = join(dest, basename(target));
3482
+ }
3483
+ await writeLocalFileBytes(dest, bytes);
3484
+ if (jsonOut) {
3485
+ print({ ok: true, path: target, dest, bytes: bytes.byteLength });
3486
+ } else {
3487
+ process.stdout.write(
3488
+ `dench files download: ${target} -> ${dest} (${bytes.byteLength}B)\n`,
3489
+ );
3490
+ }
3491
+ return;
3492
+ }
3493
+
3494
+ // Directory: recreate the tree locally under <local-dest> (defaults to
3495
+ // the directory's basename in the cwd).
3496
+ const destRoot = resolve(
3497
+ positional(3) ?? (target === "/" ? "workspace" : basename(target)),
3498
+ );
3499
+ const rows = await listConvexTreeRecursive(runtime, apiKey, target);
3500
+ const prefixLength = target === "/" ? 1 : target.length + 1;
3501
+ let downloaded = 0;
3502
+ let bytesTotal = 0;
3503
+ const skipped: string[] = [];
3504
+ await mkdir(destRoot, { recursive: true });
3505
+ for (const row of rows) {
3506
+ const rel = row.path.slice(prefixLength);
3507
+ if (!rel) continue;
3508
+ const local = join(destRoot, rel);
3509
+ if (row.isDir) {
3510
+ await mkdir(local, { recursive: true });
3511
+ continue;
3512
+ }
3513
+ const bytes = await downloadConvexFileBytes(runtime, apiKey, row.path);
3514
+ if (!bytes) {
3515
+ skipped.push(row.path);
3516
+ continue;
3517
+ }
3518
+ await writeLocalFileBytes(local, bytes);
3519
+ downloaded += 1;
3520
+ bytesTotal += bytes.byteLength;
3521
+ if (!jsonOut && downloaded % 25 === 0) {
3522
+ process.stdout.write(
3523
+ `dench files download: ${downloaded} files, ${bytesTotal}B…\n`,
3524
+ );
3525
+ }
3526
+ }
3527
+ if (jsonOut) {
3528
+ print({
3529
+ ok: true,
3530
+ path: target,
3531
+ dest: destRoot,
3532
+ files: downloaded,
3533
+ bytes: bytesTotal,
3534
+ skipped,
3535
+ });
3536
+ } else {
3537
+ process.stdout.write(
3538
+ `dench files download: ${downloaded} files (${bytesTotal}B) -> ${destRoot}${
3539
+ skipped.length > 0 ? ` (${skipped.length} skipped)` : ""
3540
+ }\n`,
3541
+ );
3542
+ }
3543
+ }
3544
+
3415
3545
  async function runFilesCommand() {
3416
3546
  const sub = positional(1);
3417
3547
  if (!sub || sub === "help" || hasFlag("--help")) {
3418
3548
  process.stdout.write(
3419
3549
  [
3420
- "dench files <ls|mv|rm> — workspace file ops that update the UI tree",
3550
+ "dench files <ls|mv|rm|download> — workspace file ops that update the UI tree",
3421
3551
  "",
3422
3552
  " dench files ls [<path>] [--recursive] [--json]",
3423
3553
  " dench files mv <src> <dst> [--json]",
3424
3554
  " dench files rm <path> [--recursive] [--json]",
3555
+ " dench files download <path> [<local-dest>] [--url] [--json]",
3425
3556
  "",
3426
3557
  "Paths are POSIX paths under /workspace; you can pass either form.",
3427
3558
  "These mutate Convex directly (and the daemon syncs to the volume).",
3559
+ "download fetches canonical bytes from Convex (dirs recurse);",
3560
+ "--url prints a short-lived signed URL instead of saving.",
3428
3561
  ].join("\n"),
3429
3562
  );
3430
3563
  return;
@@ -3432,6 +3565,7 @@ async function runFilesCommand() {
3432
3565
  if (sub === "ls") return await runFilesLs();
3433
3566
  if (sub === "mv") return await runFilesMv();
3434
3567
  if (sub === "rm") return await runFilesRm();
3568
+ if (sub === "download") return await runFilesDownload();
3435
3569
  throw new CliError(`Unknown subcommand: dench files ${sub}`, {
3436
3570
  code: "files_unknown_subcommand",
3437
3571
  nextActions: ["Run dench files help for usage."],
package/email.ts CHANGED
@@ -197,6 +197,8 @@ Usage:
197
197
  dench email identity disable --identity <identityId|email|domain> [--json]
198
198
  dench email identity restore --identity <identityId|email|domain> [--json]
199
199
  dench email identity delete --identity <identityId|email|domain> [--json]
200
+ dench email identity forward --identity <identityId|email> --to inbox@you.com [--json] # forward this sender's sequence replies
201
+ dench email identity forward --identity <identityId|email> --off [--json] # turn forwarding off
200
202
  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]
201
203
  dench email message status --message <messageId> [--json]
202
204
  dench email message list [--limit 25] [--json]
@@ -500,6 +502,7 @@ async function runIdentity(ctx: EmailCliContext) {
500
502
  `${identity.identityId} ${identity.fromEmail} type=${identity.type}` +
501
503
  `${identity.domain ? ` domain=${identity.domain}` : ""}` +
502
504
  ` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}` +
505
+ `${identity.forwardRepliesTo ? ` forward=${identity.forwardRepliesTo}` : ""}` +
503
506
  `${identity.disabled ? " DISABLED" : ""}`,
504
507
  )
505
508
  .join("\n");
@@ -545,6 +548,34 @@ async function runIdentity(ctx: EmailCliContext) {
545
548
  out(ctx, payload, text);
546
549
  return;
547
550
  }
551
+ if (sub === "forward") {
552
+ // Configure where a sender's managed sequence replies are forwarded.
553
+ // `--to <email>` turns it on (composed from the verified sender, Reply-To =
554
+ // the prospect); `--off` clears it. Replies are always still captured
555
+ // in-app regardless — forwarding is an extra copy to a chosen inbox.
556
+ const identity = requiredFlag(ctx.args, "--identity");
557
+ const off = hasFlag(ctx.args, "--off");
558
+ const to = off ? "" : (getFlag(ctx.args, "--to")?.trim() ?? "");
559
+ if (!off && !to) {
560
+ throw new Error(
561
+ "Provide --to <email> to forward this sender's replies, or --off to turn forwarding off.",
562
+ );
563
+ }
564
+ const payload = (await callMutation(
565
+ ctx,
566
+ "functions/emailCampaigns:setSenderReplyForward",
567
+ { identity, forwardRepliesTo: to },
568
+ )) as JsonRecord;
569
+ const forwardTo =
570
+ typeof payload.forwardRepliesTo === "string"
571
+ ? payload.forwardRepliesTo
572
+ : null;
573
+ const text = forwardTo
574
+ ? `Replies to ${identity}'s sequences will be forwarded to ${forwardTo}.`
575
+ : `Reply forwarding turned off for ${identity}.`;
576
+ out(ctx, { ok: true, ...payload }, text);
577
+ return;
578
+ }
548
579
  if (sub === "dns") {
549
580
  const identity = requiredFlag(ctx.args, "--identity");
550
581
  if (!identity.includes("@") && !identity.includes(".")) {
@@ -44,7 +44,7 @@ export const FIELD_TYPES = [
44
44
  const fieldType = z
45
45
  .enum(FIELD_TYPES)
46
46
  .describe(
47
- "Field data type. `relation` needs relatedObjectName + relationshipType; `enum` uses enumValues/enumColors; `user` cells store a member userId; long prose belongs in the protected `Notes` richtext field.",
47
+ "Field data type. `relation` needs relatedObjectName + relationshipType; `enum` uses enumValues/enumColors; `user` cells store one member userId or an array of member userIds; long prose belongs in the protected `Notes` richtext field.",
48
48
  );
49
49
 
50
50
  const viewKind = z
@@ -139,12 +139,14 @@ const scheduleSpec = z
139
139
 
140
140
  const dataMap = z
141
141
  .record(z.string(), z.unknown())
142
- .describe("Field-name -> value map. Use member userIds for `user` fields.");
142
+ .describe(
143
+ "Field-name -> value map. For `user` fields use one member userId or a userId array; preserve every assignee.",
144
+ );
143
145
 
144
146
  const cellValue = z
145
147
  .unknown()
146
148
  .describe(
147
- "Cell value. Enum=option string, tags=string array, user=member userId, relation=related entry id, boolean=true/false.",
149
+ "Cell value. Enum=option string, tags=string array, user=one member userId or a userId array, relation=related entry id, boolean=true/false.",
148
150
  );
149
151
 
150
152
  const enrichmentConfig = z
@@ -2277,7 +2279,6 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
2277
2279
  _creationTime: z.number(),
2278
2280
  objectId: z.string(),
2279
2281
  data: fieldsMap,
2280
- searchableText: z.string(),
2281
2282
  sortOrder: z.number(),
2282
2283
  createdAt: z.number(),
2283
2284
  updatedAt: z.number(),
@@ -2796,7 +2797,11 @@ export const requestExamples: Record<string, unknown> = {
2796
2797
  "crm.fields.enum.color": { color: "#22c55e" },
2797
2798
  "crm.fields.enum.add": { value: "Nurturing", color: "#eab308", position: 2 },
2798
2799
  "crm.entries.create": {
2799
- data: { Name: "Ada Lovelace", Email: "ada@example.com", Status: "New" },
2800
+ data: {
2801
+ Name: "Joint review",
2802
+ Assignee: ["user_kumar", "user_mark"],
2803
+ Status: "New",
2804
+ },
2800
2805
  },
2801
2806
  "crm.entries.createMany": {
2802
2807
  entries: [
@@ -2804,7 +2809,7 @@ export const requestExamples: Record<string, unknown> = {
2804
2809
  { Name: "Bob", Email: "bob@example.com" },
2805
2810
  ],
2806
2811
  },
2807
- "crm.cells.set": { value: "In progress" },
2812
+ "crm.cells.set": { value: ["user_kumar", "user_mark"] },
2808
2813
  "crm.query": {
2809
2814
  objectName: "lead",
2810
2815
  dslJson:
@@ -3368,7 +3373,6 @@ export const responseExamples: Record<string, unknown> = {
3368
3373
  _creationTime: 1765526400000,
3369
3374
  objectId: "obj_lead01",
3370
3375
  data: { Name: "Acme Corp", Status: "Qualified" },
3371
- searchableText: "Acme Corp Qualified",
3372
3376
  sortOrder: 0,
3373
3377
  createdAt: 1765526400000,
3374
3378
  updatedAt: 1765612800000,
@@ -29,6 +29,7 @@ export type CustomOperation = {
29
29
  | "commandsList"
30
30
  | "chatSpawn"
31
31
  | "chatFollow"
32
+ | "cronDelete"
32
33
  | "cronRunNow"
33
34
  | "billingTopup"
34
35
  | "upgrade"
@@ -1895,7 +1896,7 @@ const rawApiOperations = [
1895
1896
  path: "/routines/{jobId}",
1896
1897
  requestSchema: anyObject,
1897
1898
  responseSchema: anyResponse,
1898
- backend: convex("mutation", "functions/triggers:deleteCronTrigger"),
1899
+ backend: custom("cronDelete", "bearer"),
1899
1900
  }),
1900
1901
  op({
1901
1902
  id: "cron.enable",
@@ -1960,7 +1961,7 @@ const rawApiOperations = [
1960
1961
  id: "files.downloadUrl",
1961
1962
  group: "files",
1962
1963
  summary: "Get a signed file download URL.",
1963
- cli: "dench files ls",
1964
+ cli: "dench files download",
1964
1965
  method: "GET",
1965
1966
  path: "/files/download-url",
1966
1967
  requestSchema: noBody,
package/members.ts CHANGED
@@ -168,7 +168,9 @@ function membersHelp(): void {
168
168
 
169
169
  Workspace members for the active organization. Use these userIds when
170
170
  assigning CRM \`user\`-type cells (e.g. \`Owner\`, \`Assignee\`) so the
171
- UI shows a real linked avatar instead of an unlinked literal string.
171
+ UI shows real linked avatars instead of unlinked literal strings. Use one id
172
+ for one assignee or a JSON id array for multiple assignees; preserve every
173
+ owner from the source data.
172
174
 
173
175
  Subcommands:
174
176
  dench members list [--json]
@@ -185,6 +187,7 @@ After picking a userId (confirm field names with
185
187
  \`dench crm fields list <object>\` — the default \`task\` uses
186
188
  \`Assignee\`, not \`Owner\`, and some workspaces rename \`Name\` to \`Title\`):
187
189
  dench crm cells set task <entryId> Assignee '"<userId>"'
190
+ dench crm cells set task <entryId> Assignee '["<userId1>","<userId2>"]'
188
191
  dench crm entries create task --data '{"Name":"Ship CRM","Assignee":"<userId>"}'
189
192
 
190
193
  The server best-effort-resolves a name / email passed to a \`user\`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.6.0",
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.",
3
+ "version": "2.7.1",
4
+ "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dench": "dench",