@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 +210 -110
- package/crm.ts +4 -1
- package/cron.ts +29 -9
- package/dench.ts +136 -2
- package/email.ts +31 -0
- package/lib/api-schemas.ts +11 -7
- package/lib/command-registry.ts +3 -2
- package/members.ts +4 -1
- package/package.json +2 -2
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
|
|
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 (!
|
|
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
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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?.
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
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.
|
|
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`)
|
|
23
|
-
*
|
|
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
|
|
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(".")) {
|
package/lib/api-schemas.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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: {
|
|
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: "
|
|
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,
|
package/lib/command-registry.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "Dench agent workspace CLI.
|
|
3
|
+
"version": "2.7.1",
|
|
4
|
+
"description": "Dench agent workspace CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dench": "dench",
|