@dench.com/cli 2.7.0 → 2.7.2
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 +312 -17
- package/dench.ts +143 -2
- package/lib/api-schemas.ts +25 -7
- package/lib/command-registry.ts +1 -1
- 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
|
@@ -90,8 +90,109 @@ type CrmCliContext = {
|
|
|
90
90
|
sessionToken?: string;
|
|
91
91
|
runId?: string;
|
|
92
92
|
enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
|
|
93
|
+
/** App origin used to build absolute entry deep links (e.g. https://www.dench.com). */
|
|
94
|
+
host?: string;
|
|
95
|
+
/** Workspace slug for absolute entry deep links (e.g. dench). */
|
|
96
|
+
workspaceSlug?: string;
|
|
93
97
|
};
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Canonical CRM entry deep links.
|
|
101
|
+
*
|
|
102
|
+
* Absolute (shareable): `https://…/<slug>?path=<object>&entry=<object>:<id>`
|
|
103
|
+
* Relative (in-app): `/?path=<object>&entry=<object>:<id>`
|
|
104
|
+
*
|
|
105
|
+
* Never invent `?object=` or a bare `entry=<id>` without the object prefix.
|
|
106
|
+
* `view` / `hidden` are optional UI chrome — not part of the canonical link.
|
|
107
|
+
*/
|
|
108
|
+
export function buildCrmEntryDeepLink(args: {
|
|
109
|
+
host?: string;
|
|
110
|
+
workspaceSlug?: string;
|
|
111
|
+
objectName: string;
|
|
112
|
+
entryId: string;
|
|
113
|
+
}): { href: string; url?: string } {
|
|
114
|
+
const objectName = args.objectName.trim();
|
|
115
|
+
const entryId = args.entryId.trim();
|
|
116
|
+
// Keep a literal `:` between object and id (docs + in-app links); only
|
|
117
|
+
// encode the segments themselves so Convex ids stay readable.
|
|
118
|
+
const entryParam = `${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`;
|
|
119
|
+
const pathParam = encodeURIComponent(objectName);
|
|
120
|
+
const href = `/?path=${pathParam}&entry=${entryParam}`;
|
|
121
|
+
const slug = args.workspaceSlug?.trim();
|
|
122
|
+
const host = args.host?.trim();
|
|
123
|
+
if (!slug || !host) {
|
|
124
|
+
return { href };
|
|
125
|
+
}
|
|
126
|
+
const withProtocol = /^https?:\/\//.test(host) ? host : `https://${host}`;
|
|
127
|
+
let base: string;
|
|
128
|
+
try {
|
|
129
|
+
base = new URL(withProtocol).origin;
|
|
130
|
+
} catch {
|
|
131
|
+
return { href };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
href,
|
|
135
|
+
url: `${base}/${encodeURIComponent(slug)}?path=${pathParam}&entry=${entryParam}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function entryIdFromUnknown(value: unknown): string | undefined {
|
|
140
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
const record = value as JsonRecord;
|
|
144
|
+
for (const key of ["entryId", "id"]) {
|
|
145
|
+
const candidate = record[key];
|
|
146
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
147
|
+
return candidate.trim();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Canonical object name echoed by `entries.get` / `entries.create` /
|
|
155
|
+
* `batch.apply`. The server resolves display labels, case, and plurals
|
|
156
|
+
* (`lib/crm/resolveObject.ts`), so the name the caller typed is often NOT
|
|
157
|
+
* the one the workspace URL resolver matches on — it compares object names
|
|
158
|
+
* exactly. Always prefer this over the raw CLI argument.
|
|
159
|
+
*/
|
|
160
|
+
function canonicalObjectNameFromUnknown(value: unknown): string | undefined {
|
|
161
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const candidate = (value as JsonRecord).objectName;
|
|
165
|
+
return typeof candidate === "string" && candidate.trim()
|
|
166
|
+
? candidate.trim()
|
|
167
|
+
: undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function withCrmEntryDeepLinks(
|
|
171
|
+
ctx: CrmCliContext,
|
|
172
|
+
fallbackObjectName: string,
|
|
173
|
+
value: unknown,
|
|
174
|
+
): unknown {
|
|
175
|
+
const entryId = entryIdFromUnknown(value);
|
|
176
|
+
if (!entryId || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
// Older deployments don't echo `objectName`; the raw arg is the only
|
|
180
|
+
// thing left, and it's correct whenever the caller used the canonical name.
|
|
181
|
+
const objectName =
|
|
182
|
+
canonicalObjectNameFromUnknown(value) ?? fallbackObjectName;
|
|
183
|
+
const links = buildCrmEntryDeepLink({
|
|
184
|
+
host: ctx.host,
|
|
185
|
+
workspaceSlug: ctx.workspaceSlug,
|
|
186
|
+
objectName,
|
|
187
|
+
entryId,
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
...(value as JsonRecord),
|
|
191
|
+
href: links.href,
|
|
192
|
+
...(links.url ? { url: links.url } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
95
196
|
// Adapt the shared helpers to throw CrmCliError so existing call sites
|
|
96
197
|
// don't need to change their try/catch shape.
|
|
97
198
|
class CrmCliError extends Error {}
|
|
@@ -291,18 +392,22 @@ async function applyBatchOpsInChunks(
|
|
|
291
392
|
ops: CrmBatchOp[],
|
|
292
393
|
idempotencyKey: string | undefined,
|
|
293
394
|
allowUnknown = false,
|
|
294
|
-
): Promise<number> {
|
|
395
|
+
): Promise<{ chunks: number; results: unknown[] }> {
|
|
295
396
|
const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
|
|
397
|
+
const results: unknown[] = [];
|
|
296
398
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
297
399
|
const chunkKey = chunkIdempotencyKey(idempotencyKey, chunkIndex);
|
|
298
|
-
await callMutation(ctx, api.batch.apply, {
|
|
400
|
+
const batchResult = (await callMutation(ctx, api.batch.apply, {
|
|
299
401
|
ops: chunk,
|
|
300
402
|
...runContextArgs(ctx),
|
|
301
403
|
...(chunkKey ? { idempotencyKey: chunkKey } : {}),
|
|
302
404
|
...(allowUnknown ? { allowUnknown: true } : {}),
|
|
303
|
-
});
|
|
405
|
+
})) as { results?: unknown[] } | null | undefined;
|
|
406
|
+
if (Array.isArray(batchResult?.results)) {
|
|
407
|
+
results.push(...batchResult.results);
|
|
408
|
+
}
|
|
304
409
|
}
|
|
305
|
-
return chunks.length;
|
|
410
|
+
return { chunks: chunks.length, results };
|
|
306
411
|
}
|
|
307
412
|
|
|
308
413
|
function runContextArgs(ctx: CrmCliContext): Record<string, string> {
|
|
@@ -568,6 +673,20 @@ const api = {
|
|
|
568
673
|
deleteView: makeFunctionReference<"mutation">(
|
|
569
674
|
"functions/crm/objects:deleteView",
|
|
570
675
|
),
|
|
676
|
+
setArchived: makeFunctionReference<"mutation">(
|
|
677
|
+
"functions/crm/objects:setArchived",
|
|
678
|
+
),
|
|
679
|
+
setViewPinned: makeFunctionReference<"mutation">(
|
|
680
|
+
"functions/crm/objects:setViewPinned",
|
|
681
|
+
),
|
|
682
|
+
},
|
|
683
|
+
sidebar: {
|
|
684
|
+
describe: makeFunctionReference<"query">(
|
|
685
|
+
"functions/workspaceNav:describeMySidebar",
|
|
686
|
+
),
|
|
687
|
+
reposition: makeFunctionReference<"mutation">(
|
|
688
|
+
"functions/workspaceNav:repositionMyNavItem",
|
|
689
|
+
),
|
|
571
690
|
},
|
|
572
691
|
fields: {
|
|
573
692
|
list: makeFunctionReference<"query">("functions/crm/fields:list"),
|
|
@@ -672,6 +791,8 @@ export async function runCrmCommand(opts: {
|
|
|
672
791
|
args: string[];
|
|
673
792
|
sessionToken?: string;
|
|
674
793
|
enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
|
|
794
|
+
host?: string;
|
|
795
|
+
workspaceSlug?: string;
|
|
675
796
|
}): Promise<void> {
|
|
676
797
|
const args = [...opts.args];
|
|
677
798
|
const jsonOutput = hasFlag(args, "--json");
|
|
@@ -682,6 +803,8 @@ export async function runCrmCommand(opts: {
|
|
|
682
803
|
sessionToken: opts.sessionToken,
|
|
683
804
|
runId: process.env.DENCH_RUN_ID?.trim() || undefined,
|
|
684
805
|
enrichmentGateway: opts.enrichmentGateway,
|
|
806
|
+
host: opts.host,
|
|
807
|
+
workspaceSlug: opts.workspaceSlug,
|
|
685
808
|
};
|
|
686
809
|
const subcommand = args.shift();
|
|
687
810
|
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
@@ -693,6 +816,8 @@ export async function runCrmCommand(opts: {
|
|
|
693
816
|
return await runObjectsCommand(ctx);
|
|
694
817
|
case "views":
|
|
695
818
|
return await runViewsCommand(ctx);
|
|
819
|
+
case "sidebar":
|
|
820
|
+
return await runSidebarCommand(ctx);
|
|
696
821
|
case "fields":
|
|
697
822
|
return await runFieldsCommand(ctx);
|
|
698
823
|
case "entries":
|
|
@@ -833,6 +958,50 @@ async function runCrmMembersCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
833
958
|
});
|
|
834
959
|
}
|
|
835
960
|
|
|
961
|
+
/**
|
|
962
|
+
* `dench crm sidebar …` — the workspace app rail.
|
|
963
|
+
*
|
|
964
|
+
* Row VISIBILITY is team-shared and lives on the CRM records themselves
|
|
965
|
+
* (`crm objects archive`, `crm views pin/archive`). Row ORDER is a
|
|
966
|
+
* per-member preference, which is what `move` writes. `list` shows both
|
|
967
|
+
* halves: the visible rows with their 1-based positions, plus what's
|
|
968
|
+
* hidden and why.
|
|
969
|
+
*/
|
|
970
|
+
async function runSidebarCommand(ctx: CrmCliContext): Promise<void> {
|
|
971
|
+
const verb = ctx.args.shift() ?? "list";
|
|
972
|
+
switch (verb) {
|
|
973
|
+
case "list": {
|
|
974
|
+
assertNoUnknownFlags(ctx.args, "crm sidebar list");
|
|
975
|
+
out(ctx, await callQuery(ctx, api.sidebar.describe, {}));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
case "move": {
|
|
979
|
+
const navId = shift(ctx.args, "nav id");
|
|
980
|
+
const rawPosition = shift(ctx.args, "position");
|
|
981
|
+
assertNoUnknownFlags(ctx.args, "crm sidebar move");
|
|
982
|
+
let position: string | number;
|
|
983
|
+
if (rawPosition === "top" || rawPosition === "bottom") {
|
|
984
|
+
position = rawPosition;
|
|
985
|
+
} else {
|
|
986
|
+
const parsed = Number.parseInt(rawPosition, 10);
|
|
987
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
988
|
+
throw new CrmCliError(
|
|
989
|
+
`position must be "top", "bottom", or a positive integer, got ${rawPosition}`,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
position = parsed;
|
|
993
|
+
}
|
|
994
|
+
out(
|
|
995
|
+
ctx,
|
|
996
|
+
await callMutation(ctx, api.sidebar.reposition, { navId, position }),
|
|
997
|
+
);
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
default:
|
|
1001
|
+
throw new CrmCliError(`Unknown crm sidebar verb: ${verb}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
836
1005
|
async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
|
|
837
1006
|
const verb = ctx.args.shift();
|
|
838
1007
|
switch (verb) {
|
|
@@ -902,6 +1071,22 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
902
1071
|
out(ctx, await callMutation(ctx, api.objects.remove, { name }));
|
|
903
1072
|
return;
|
|
904
1073
|
}
|
|
1074
|
+
// Archive is the sidebar's "remove from my rail", NOT a delete: the
|
|
1075
|
+
// table, its rows and every link to it keep working, the row just
|
|
1076
|
+
// moves into the rail's Archived flyout. Team-shared.
|
|
1077
|
+
case "archive":
|
|
1078
|
+
case "unarchive": {
|
|
1079
|
+
const name = shift(ctx.args, "object name");
|
|
1080
|
+
assertNoUnknownFlags(ctx.args, `crm objects ${verb}`);
|
|
1081
|
+
out(
|
|
1082
|
+
ctx,
|
|
1083
|
+
await callMutation(ctx, api.objects.setArchived, {
|
|
1084
|
+
name,
|
|
1085
|
+
archived: verb === "archive",
|
|
1086
|
+
}),
|
|
1087
|
+
);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
905
1090
|
default:
|
|
906
1091
|
throw new CrmCliError(`Unknown crm objects verb: ${verb ?? "<none>"}`);
|
|
907
1092
|
}
|
|
@@ -1363,6 +1548,39 @@ async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1363
1548
|
);
|
|
1364
1549
|
return;
|
|
1365
1550
|
}
|
|
1551
|
+
// Only `pinned` views render as workspace sidebar rows. Pinning also
|
|
1552
|
+
// clears any archive stamp, since a pinned-but-archived view still
|
|
1553
|
+
// wouldn't show up.
|
|
1554
|
+
case "pin":
|
|
1555
|
+
case "unpin": {
|
|
1556
|
+
const objectName = shift(ctx.args, "object name");
|
|
1557
|
+
const viewName = shift(ctx.args, "view name");
|
|
1558
|
+
assertNoUnknownFlags(ctx.args, `crm views ${verb}`);
|
|
1559
|
+
out(
|
|
1560
|
+
ctx,
|
|
1561
|
+
await callMutation(ctx, api.objects.setViewPinned, {
|
|
1562
|
+
objectName,
|
|
1563
|
+
viewName,
|
|
1564
|
+
pinned: verb === "pin",
|
|
1565
|
+
}),
|
|
1566
|
+
);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
case "archive":
|
|
1570
|
+
case "unarchive": {
|
|
1571
|
+
const objectName = shift(ctx.args, "object name");
|
|
1572
|
+
const viewName = shift(ctx.args, "view name");
|
|
1573
|
+
assertNoUnknownFlags(ctx.args, `crm views ${verb}`);
|
|
1574
|
+
out(
|
|
1575
|
+
ctx,
|
|
1576
|
+
await callMutation(ctx, api.objects.setArchived, {
|
|
1577
|
+
name: objectName,
|
|
1578
|
+
viewName,
|
|
1579
|
+
archived: verb === "archive",
|
|
1580
|
+
}),
|
|
1581
|
+
);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1366
1584
|
default:
|
|
1367
1585
|
throw new CrmCliError(`Unknown crm views verb: ${verb ?? "<none>"}`);
|
|
1368
1586
|
}
|
|
@@ -1624,10 +1842,14 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1624
1842
|
assertNoUnknownFlags(ctx.args, "crm entries get");
|
|
1625
1843
|
out(
|
|
1626
1844
|
ctx,
|
|
1627
|
-
|
|
1845
|
+
withCrmEntryDeepLinks(
|
|
1846
|
+
ctx,
|
|
1628
1847
|
objectName,
|
|
1629
|
-
|
|
1630
|
-
|
|
1848
|
+
await callQuery(ctx, api.entries.get, {
|
|
1849
|
+
objectName,
|
|
1850
|
+
entryId: entryId as any,
|
|
1851
|
+
}),
|
|
1852
|
+
),
|
|
1631
1853
|
);
|
|
1632
1854
|
return;
|
|
1633
1855
|
}
|
|
@@ -1657,12 +1879,16 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1657
1879
|
// pre-fetch — that would add a round-trip to every create.
|
|
1658
1880
|
out(
|
|
1659
1881
|
ctx,
|
|
1660
|
-
|
|
1882
|
+
withCrmEntryDeepLinks(
|
|
1883
|
+
ctx,
|
|
1661
1884
|
objectName,
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1885
|
+
await callMutation(ctx, api.entries.create, {
|
|
1886
|
+
objectName,
|
|
1887
|
+
data,
|
|
1888
|
+
...runContextArgs(ctx),
|
|
1889
|
+
...(allowUnknown ? { allowUnknown: true } : {}),
|
|
1890
|
+
}),
|
|
1891
|
+
),
|
|
1666
1892
|
);
|
|
1667
1893
|
return;
|
|
1668
1894
|
}
|
|
@@ -1680,7 +1906,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1680
1906
|
}),
|
|
1681
1907
|
);
|
|
1682
1908
|
await assertKnownDataKeys(ctx, objectName, rows, allowUnknown);
|
|
1683
|
-
const chunks = await applyBatchOpsInChunks(
|
|
1909
|
+
const { chunks, results } = await applyBatchOpsInChunks(
|
|
1684
1910
|
ctx,
|
|
1685
1911
|
rows.map((data) => ({
|
|
1686
1912
|
type: "entries.create",
|
|
@@ -1690,7 +1916,41 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1690
1916
|
idempotencyKey,
|
|
1691
1917
|
allowUnknown,
|
|
1692
1918
|
);
|
|
1693
|
-
|
|
1919
|
+
// Only claim per-row links when we actually got a result per row. A
|
|
1920
|
+
// silently-empty `results` is indistinguishable from "this object has
|
|
1921
|
+
// no links" and sends the agent right back to inventing URLs — the
|
|
1922
|
+
// exact failure this command's `url`/`href` output exists to prevent.
|
|
1923
|
+
const linked = results.length;
|
|
1924
|
+
out(ctx, {
|
|
1925
|
+
created: rows.length,
|
|
1926
|
+
chunks,
|
|
1927
|
+
...(linked > 0
|
|
1928
|
+
? {
|
|
1929
|
+
results: results.map((result) =>
|
|
1930
|
+
withCrmEntryDeepLinks(ctx, objectName, result),
|
|
1931
|
+
),
|
|
1932
|
+
}
|
|
1933
|
+
: {}),
|
|
1934
|
+
...(linked === rows.length
|
|
1935
|
+
? {}
|
|
1936
|
+
: {
|
|
1937
|
+
linksUnavailable: true,
|
|
1938
|
+
// The recovery path has to be followable. With zero results
|
|
1939
|
+
// there is no entryId to hand to `entries get`, so point at
|
|
1940
|
+
// `entries list` (it returns `id` per row) instead — a hint
|
|
1941
|
+
// the agent can't act on just sends it back to guessing.
|
|
1942
|
+
linksHint:
|
|
1943
|
+
`Got ${linked} link(s) for ${rows.length} row(s). ` +
|
|
1944
|
+
(linked === 0
|
|
1945
|
+
? `Run \`dench crm entries list ${objectName} --json\` to get each row's ` +
|
|
1946
|
+
`\`id\`, then \`dench crm entries get ${objectName} <id> --json\` for its ` +
|
|
1947
|
+
"canonical url"
|
|
1948
|
+
: `The rows above carry \`url\`/\`href\`; for the rest run ` +
|
|
1949
|
+
`\`dench crm entries list ${objectName} --json\` to get their \`id\`, then ` +
|
|
1950
|
+
`\`dench crm entries get ${objectName} <id> --json\``) +
|
|
1951
|
+
" — do not construct a url by hand.",
|
|
1952
|
+
}),
|
|
1953
|
+
});
|
|
1694
1954
|
return;
|
|
1695
1955
|
}
|
|
1696
1956
|
case "update": {
|
|
@@ -1743,7 +2003,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1743
2003
|
updates.map((update) => update.data),
|
|
1744
2004
|
allowUnknown,
|
|
1745
2005
|
);
|
|
1746
|
-
const chunks = await applyBatchOpsInChunks(
|
|
2006
|
+
const { chunks } = await applyBatchOpsInChunks(
|
|
1747
2007
|
ctx,
|
|
1748
2008
|
updates.map((update) => ({
|
|
1749
2009
|
type: "entries.update",
|
|
@@ -1874,7 +2134,7 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1874
2134
|
data,
|
|
1875
2135
|
}),
|
|
1876
2136
|
);
|
|
1877
|
-
const chunks = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
|
|
2137
|
+
const { chunks } = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
|
|
1878
2138
|
out(ctx, { updated: updates.length, entriesUpdated: ops.length, chunks });
|
|
1879
2139
|
return;
|
|
1880
2140
|
}
|
|
@@ -3287,6 +3547,10 @@ Objects (CRM tables):
|
|
|
3287
3547
|
command/API identifier (works on protected objects too).
|
|
3288
3548
|
dench crm objects rename <from> <to>
|
|
3289
3549
|
dench crm objects delete <name>
|
|
3550
|
+
dench crm objects archive <name> | unarchive <name>
|
|
3551
|
+
Hides/restores the object's workspace SIDEBAR row. Not a delete —
|
|
3552
|
+
the table, its rows and every link keep working; the row just moves
|
|
3553
|
+
into the rail's Archived flyout. Team-shared.
|
|
3290
3554
|
|
|
3291
3555
|
Views (saved CRM subsets):
|
|
3292
3556
|
dench crm views list <object> [--json]
|
|
@@ -3306,6 +3570,22 @@ Views (saved CRM subsets):
|
|
|
3306
3570
|
dench crm views delete <object> <view-name>
|
|
3307
3571
|
Idempotent; also clears the active-view pointer if it referenced
|
|
3308
3572
|
the deleted view.
|
|
3573
|
+
dench crm views pin <object> <view-name> | unpin <object> <view-name>
|
|
3574
|
+
Only pinned views render as workspace sidebar rows. Pinning also
|
|
3575
|
+
clears any archive stamp.
|
|
3576
|
+
dench crm views archive <object> <view-name> | unarchive <object> <view-name>
|
|
3577
|
+
Moves the view's sidebar row into the Archived flyout. Unarchiving
|
|
3578
|
+
re-asserts the pin so the row comes back.
|
|
3579
|
+
|
|
3580
|
+
Sidebar (the workspace app rail):
|
|
3581
|
+
dench crm sidebar list [--json]
|
|
3582
|
+
Visible rows in order with 1-based positions, plus what's hidden
|
|
3583
|
+
from the rail and why (archived vs. an unpinned view).
|
|
3584
|
+
dench crm sidebar move <nav-id> <top|bottom|N>
|
|
3585
|
+
Reposition one row. Nav ids come from 'sidebar list' — e.g.
|
|
3586
|
+
'crm-object:task', 'crm-view:task:Due%20today', 'crm-people',
|
|
3587
|
+
'automation'. Row ORDER is per-member; row visibility (above) is
|
|
3588
|
+
team-shared.
|
|
3309
3589
|
|
|
3310
3590
|
Fields (columns):
|
|
3311
3591
|
dench crm fields list <object> [--json]
|
|
@@ -3368,6 +3648,18 @@ Entries (rows):
|
|
|
3368
3648
|
being silently dropped. Verify names with 'crm fields list <object>'
|
|
3369
3649
|
first. Pass --allow-unknown to write the raw value anyway, and on
|
|
3370
3650
|
create the object's required fields must be present.
|
|
3651
|
+
create / get / create-many also return deep links when the
|
|
3652
|
+
workspace slug is known: \`url\` (absolute shareable) and \`href\`
|
|
3653
|
+
(relative in-app). Paste those — never invent \`?object=\` or a
|
|
3654
|
+
bare \`entry=<id>\` without the \`<object>:\` prefix. Canonical form:
|
|
3655
|
+
/<slug>?path=<object>&entry=<object>:<entryId>
|
|
3656
|
+
The link uses the object's CANONICAL name (echoed back as
|
|
3657
|
+
\`objectName\`), not the alias you typed — \`create Tasks\` resolves
|
|
3658
|
+
server-side but only \`task\` opens the record. If a response has no
|
|
3659
|
+
\`url\`/\`href\`, run 'crm entries get <object> <entryId> --json' for
|
|
3660
|
+
one. If create-many reports \`linksUnavailable\`, the unlinked rows
|
|
3661
|
+
have no id in that response — run 'crm entries list <object> --json'
|
|
3662
|
+
to recover their \`id\` first.
|
|
3371
3663
|
|
|
3372
3664
|
Cells (single-field updates):
|
|
3373
3665
|
dench crm cells get <object> <entryId> <field>
|
|
@@ -3393,11 +3685,14 @@ Workspace members (for user-type fields):
|
|
|
3393
3685
|
List \`{ userId, role, name, email }\` for every member of the
|
|
3394
3686
|
current org. Use the userIds when setting \`Owner\`, \`Assignee\`,
|
|
3395
3687
|
or any other \`user\`-type field so the UI shows a real linked
|
|
3396
|
-
avatar instead of an unlinked literal-string badge.
|
|
3688
|
+
avatar instead of an unlinked literal-string badge. Pass one id for
|
|
3689
|
+
one assignee or a JSON array for multiple assignees; never drop a
|
|
3690
|
+
co-owner from the source data. Check the
|
|
3397
3691
|
object's actual field names first (\`crm fields list <object>\`) —
|
|
3398
3692
|
the \`task\` object uses \`Assignee\`, not \`Owner\`, and some
|
|
3399
3693
|
workspaces rename \`Name\` to \`Title\`:
|
|
3400
3694
|
dench crm cells set task <entryId> Assignee '"<userId>"'
|
|
3695
|
+
dench crm cells set task <entryId> Assignee '["<userId1>","<userId2>"]'
|
|
3401
3696
|
dench crm entries create task --data '{"Name":"X","Assignee":"<userId>"}'
|
|
3402
3697
|
|
|
3403
3698
|
Batch:
|
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."],
|
|
@@ -3901,6 +4035,8 @@ async function main() {
|
|
|
3901
4035
|
convex: runtime.client,
|
|
3902
4036
|
args: subArgs,
|
|
3903
4037
|
sessionToken: encodeDevCrmSessionToken(runtime.workspaceArgs),
|
|
4038
|
+
host: resolveApiHost(LOCAL_HOST),
|
|
4039
|
+
workspaceSlug: runtime.workspaceArgs.workspaceSlug,
|
|
3904
4040
|
enrichmentGateway: gatewayAuth
|
|
3905
4041
|
? {
|
|
3906
4042
|
apiKey: gatewayAuth.bearerToken,
|
|
@@ -3919,6 +4055,11 @@ async function main() {
|
|
|
3919
4055
|
convex: authedRuntime.client,
|
|
3920
4056
|
args: subArgs,
|
|
3921
4057
|
sessionToken: authedRuntime.sessionToken,
|
|
4058
|
+
host: authedRuntime.host,
|
|
4059
|
+
workspaceSlug:
|
|
4060
|
+
authedRuntime.organization?.slug ??
|
|
4061
|
+
process.env.DENCH_ORG_SLUG?.trim() ??
|
|
4062
|
+
undefined,
|
|
3922
4063
|
enrichmentGateway: gatewayAuth
|
|
3923
4064
|
? {
|
|
3924
4065
|
apiKey: gatewayAuth.bearerToken,
|
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
|
|
@@ -2124,8 +2126,14 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
|
|
|
2124
2126
|
id: z.string(),
|
|
2125
2127
|
role: z.enum(["user", "assistant", "system"]),
|
|
2126
2128
|
createdAt: z.number(),
|
|
2129
|
+
updatedAt: z.number(),
|
|
2127
2130
|
text: z.string(),
|
|
2128
2131
|
runId: z.string().nullable(),
|
|
2132
|
+
status: z
|
|
2133
|
+
.enum(["streaming", "complete", "error", "canceled"])
|
|
2134
|
+
.nullable(),
|
|
2135
|
+
revision: z.number(),
|
|
2136
|
+
isDraft: z.boolean(),
|
|
2129
2137
|
}),
|
|
2130
2138
|
),
|
|
2131
2139
|
}),
|
|
@@ -2277,7 +2285,6 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
|
|
|
2277
2285
|
_creationTime: z.number(),
|
|
2278
2286
|
objectId: z.string(),
|
|
2279
2287
|
data: fieldsMap,
|
|
2280
|
-
searchableText: z.string(),
|
|
2281
2288
|
sortOrder: z.number(),
|
|
2282
2289
|
createdAt: z.number(),
|
|
2283
2290
|
updatedAt: z.number(),
|
|
@@ -2796,7 +2803,11 @@ export const requestExamples: Record<string, unknown> = {
|
|
|
2796
2803
|
"crm.fields.enum.color": { color: "#22c55e" },
|
|
2797
2804
|
"crm.fields.enum.add": { value: "Nurturing", color: "#eab308", position: 2 },
|
|
2798
2805
|
"crm.entries.create": {
|
|
2799
|
-
data: {
|
|
2806
|
+
data: {
|
|
2807
|
+
Name: "Joint review",
|
|
2808
|
+
Assignee: ["user_kumar", "user_mark"],
|
|
2809
|
+
Status: "New",
|
|
2810
|
+
},
|
|
2800
2811
|
},
|
|
2801
2812
|
"crm.entries.createMany": {
|
|
2802
2813
|
entries: [
|
|
@@ -2804,7 +2815,7 @@ export const requestExamples: Record<string, unknown> = {
|
|
|
2804
2815
|
{ Name: "Bob", Email: "bob@example.com" },
|
|
2805
2816
|
],
|
|
2806
2817
|
},
|
|
2807
|
-
"crm.cells.set": { value: "
|
|
2818
|
+
"crm.cells.set": { value: ["user_kumar", "user_mark"] },
|
|
2808
2819
|
"crm.query": {
|
|
2809
2820
|
objectName: "lead",
|
|
2810
2821
|
dslJson:
|
|
@@ -3125,15 +3136,23 @@ export const responseExamples: Record<string, unknown> = {
|
|
|
3125
3136
|
id: "cm_def456",
|
|
3126
3137
|
role: "user",
|
|
3127
3138
|
createdAt: 1765526400000,
|
|
3139
|
+
updatedAt: 1765526400000,
|
|
3128
3140
|
text: "How do we deploy?",
|
|
3129
3141
|
runId: null,
|
|
3142
|
+
status: "complete",
|
|
3143
|
+
revision: 0,
|
|
3144
|
+
isDraft: false,
|
|
3130
3145
|
},
|
|
3131
3146
|
{
|
|
3132
3147
|
id: "cm_ghi789",
|
|
3133
3148
|
role: "assistant",
|
|
3134
3149
|
createdAt: 1765526460000,
|
|
3150
|
+
updatedAt: 1765526460000,
|
|
3135
3151
|
text: "Push to main; Vercel auto-deploys.",
|
|
3136
3152
|
runId: "rn_jkl012",
|
|
3153
|
+
status: "complete",
|
|
3154
|
+
revision: 1,
|
|
3155
|
+
isDraft: false,
|
|
3137
3156
|
},
|
|
3138
3157
|
],
|
|
3139
3158
|
},
|
|
@@ -3368,7 +3387,6 @@ export const responseExamples: Record<string, unknown> = {
|
|
|
3368
3387
|
_creationTime: 1765526400000,
|
|
3369
3388
|
objectId: "obj_lead01",
|
|
3370
3389
|
data: { Name: "Acme Corp", Status: "Qualified" },
|
|
3371
|
-
searchableText: "Acme Corp Qualified",
|
|
3372
3390
|
sortOrder: 0,
|
|
3373
3391
|
createdAt: 1765526400000,
|
|
3374
3392
|
updatedAt: 1765612800000,
|
package/lib/command-registry.ts
CHANGED
|
@@ -1961,7 +1961,7 @@ const rawApiOperations = [
|
|
|
1961
1961
|
id: "files.downloadUrl",
|
|
1962
1962
|
group: "files",
|
|
1963
1963
|
summary: "Get a signed file download URL.",
|
|
1964
|
-
cli: "dench files
|
|
1964
|
+
cli: "dench files download",
|
|
1965
1965
|
method: "GET",
|
|
1966
1966
|
path: "/files/download-url",
|
|
1967
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.7.
|
|
4
|
-
"description": "Dench agent workspace CLI.
|
|
3
|
+
"version": "2.7.2",
|
|
4
|
+
"description": "Dench agent workspace CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"dench": "dench",
|