@dench.com/cli 2.2.7 → 2.3.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/browser.ts +280 -5
- package/cron.ts +248 -4
- package/dench.ts +9 -1
- package/lib/api-schemas.ts +15 -0
- package/lib/command-registry.ts +52 -0
- package/package.json +1 -1
package/browser.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `dench browser` — CLI control for
|
|
2
|
+
* `dench browser` — CLI control for Dench Browser.
|
|
3
3
|
*
|
|
4
|
-
* The CLI does not launch Chrome locally and never receives
|
|
5
|
-
* credentials. It sends
|
|
6
|
-
* /api/browser/cli, where Dench Cloud drives Browserbase server-side.
|
|
4
|
+
* The CLI does not launch Chrome locally and never receives provider
|
|
5
|
+
* credentials. It sends bearer-authenticated JSON commands to Dench Cloud.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { createHash } from "node:crypto";
|
|
@@ -15,6 +14,9 @@ import { hasFlag } from "./lib/cli-args";
|
|
|
15
14
|
type RuntimeBundle = {
|
|
16
15
|
host: string;
|
|
17
16
|
bearerToken: string;
|
|
17
|
+
gatewayBaseUrl?: string;
|
|
18
|
+
gatewayBearerToken?: string;
|
|
19
|
+
gatewayHeaders?: Record<string, string>;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
type BrowserCliContext = {
|
|
@@ -46,6 +48,15 @@ type BrowserResponse = Record<string, unknown> & {
|
|
|
46
48
|
threadUrl?: string;
|
|
47
49
|
};
|
|
48
50
|
|
|
51
|
+
type DenchBrowserSessionResponse = {
|
|
52
|
+
provider?: string;
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
connectUrl?: string;
|
|
55
|
+
liveViewUrl?: string;
|
|
56
|
+
status?: string;
|
|
57
|
+
contextId?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
49
60
|
class BrowserCliError extends Error {}
|
|
50
61
|
|
|
51
62
|
const ACTIONS = new Set([
|
|
@@ -72,7 +83,7 @@ const ACTIONS = new Set([
|
|
|
72
83
|
function browserHelp(): void {
|
|
73
84
|
console.log(`Usage: dench browser <action> [options]
|
|
74
85
|
|
|
75
|
-
Control
|
|
86
|
+
Control Dench Browser without opening dench.com locally.
|
|
76
87
|
Commands authenticate with the active dench signin session or DENCH_API_KEY.
|
|
77
88
|
|
|
78
89
|
Common:
|
|
@@ -95,6 +106,11 @@ Common:
|
|
|
95
106
|
dench browser close-tab <targetId>
|
|
96
107
|
dench browser stop
|
|
97
108
|
|
|
109
|
+
Playwright sessions:
|
|
110
|
+
dench browser session create --json
|
|
111
|
+
dench browser session status <sessionId> --json
|
|
112
|
+
dench browser session stop <sessionId> --json
|
|
113
|
+
|
|
98
114
|
State:
|
|
99
115
|
The CLI remembers the latest browser thread per Dench host/session.
|
|
100
116
|
Use --thread <threadId> to control a specific thread, or
|
|
@@ -213,10 +229,54 @@ function buildCliBrowserUrl(host: string): string {
|
|
|
213
229
|
return `${host.replace(/\/+$/, "")}/api/browser/cli`;
|
|
214
230
|
}
|
|
215
231
|
|
|
232
|
+
function normalizeGatewayBaseUrl(value: string | undefined): string {
|
|
233
|
+
const explicit = value?.trim();
|
|
234
|
+
if (explicit) return explicit.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
235
|
+
if ("DENCH_GATEWAY_URL" in process.env || "GATEWAY_URL" in process.env) {
|
|
236
|
+
const env =
|
|
237
|
+
process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
|
|
238
|
+
if (env) return env.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
239
|
+
throw new BrowserCliError(
|
|
240
|
+
"DENCH_GATEWAY_URL is set but empty — configure a gateway URL or unset the variable",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (/localhost|127\.0\.0\.1/.test(process.env.DENCH_HOST ?? "")) {
|
|
244
|
+
return "http://localhost:8787";
|
|
245
|
+
}
|
|
246
|
+
return "https://gateway.merseoriginals.com";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function buildGatewayBrowserUrl(
|
|
250
|
+
runtime: RuntimeBundle,
|
|
251
|
+
path = "/v1/browser/sessions",
|
|
252
|
+
): string {
|
|
253
|
+
return `${normalizeGatewayBaseUrl(runtime.gatewayBaseUrl)}${path}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildRegisterSessionUrl(runtime: RuntimeBundle): string {
|
|
257
|
+
const convex = (
|
|
258
|
+
process.env.CONVEX_URL ||
|
|
259
|
+
process.env.NEXT_PUBLIC_CONVEX_URL ||
|
|
260
|
+
""
|
|
261
|
+
).trim();
|
|
262
|
+
if (convex) {
|
|
263
|
+
const siteBase = convex
|
|
264
|
+
.replace(/\/+$/, "")
|
|
265
|
+
.replace(/\.convex\.cloud$/i, ".convex.site");
|
|
266
|
+
return `${siteBase}/api/browser/register-session`;
|
|
267
|
+
}
|
|
268
|
+
const apiBase =
|
|
269
|
+
process.env.DENCH_API_URL?.trim() ||
|
|
270
|
+
process.env.DENCH_HOST?.trim() ||
|
|
271
|
+
runtime.host;
|
|
272
|
+
return `${apiBase.replace(/\/+$/, "")}/api/browser/register-session`;
|
|
273
|
+
}
|
|
274
|
+
|
|
216
275
|
async function postJson(
|
|
217
276
|
url: string,
|
|
218
277
|
bearer: string,
|
|
219
278
|
body: unknown,
|
|
279
|
+
extraHeaders: Record<string, string> = {},
|
|
220
280
|
): Promise<{ ok: boolean; payload: unknown; status: number }> {
|
|
221
281
|
const response = await fetch(url, {
|
|
222
282
|
method: "POST",
|
|
@@ -224,6 +284,7 @@ async function postJson(
|
|
|
224
284
|
accept: "application/json",
|
|
225
285
|
authorization: `Bearer ${bearer}`,
|
|
226
286
|
"content-type": "application/json",
|
|
287
|
+
...extraHeaders,
|
|
227
288
|
},
|
|
228
289
|
body: JSON.stringify(body),
|
|
229
290
|
});
|
|
@@ -237,14 +298,223 @@ async function postJson(
|
|
|
237
298
|
return { ok: response.ok, payload, status: response.status };
|
|
238
299
|
}
|
|
239
300
|
|
|
301
|
+
async function getJson(
|
|
302
|
+
url: string,
|
|
303
|
+
bearer: string,
|
|
304
|
+
extraHeaders: Record<string, string> = {},
|
|
305
|
+
): Promise<{ ok: boolean; payload: unknown; status: number }> {
|
|
306
|
+
const response = await fetch(url, {
|
|
307
|
+
method: "GET",
|
|
308
|
+
headers: {
|
|
309
|
+
accept: "application/json",
|
|
310
|
+
authorization: `Bearer ${bearer}`,
|
|
311
|
+
...extraHeaders,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
const text = await response.text();
|
|
315
|
+
let payload: unknown;
|
|
316
|
+
try {
|
|
317
|
+
payload = text ? JSON.parse(text) : null;
|
|
318
|
+
} catch {
|
|
319
|
+
payload = { raw: text };
|
|
320
|
+
}
|
|
321
|
+
return { ok: response.ok, payload, status: response.status };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function deleteJson(
|
|
325
|
+
url: string,
|
|
326
|
+
bearer: string,
|
|
327
|
+
extraHeaders: Record<string, string> = {},
|
|
328
|
+
): Promise<{ ok: boolean; payload: unknown; status: number }> {
|
|
329
|
+
const response = await fetch(url, {
|
|
330
|
+
method: "DELETE",
|
|
331
|
+
headers: {
|
|
332
|
+
accept: "application/json",
|
|
333
|
+
authorization: `Bearer ${bearer}`,
|
|
334
|
+
...extraHeaders,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
const text = await response.text();
|
|
338
|
+
let payload: unknown;
|
|
339
|
+
try {
|
|
340
|
+
payload = text ? JSON.parse(text) : null;
|
|
341
|
+
} catch {
|
|
342
|
+
payload = { raw: text };
|
|
343
|
+
}
|
|
344
|
+
return { ok: response.ok, payload, status: response.status };
|
|
345
|
+
}
|
|
346
|
+
|
|
240
347
|
function payloadError(payload: unknown): string {
|
|
241
348
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
242
349
|
const error = (payload as { error?: unknown }).error;
|
|
243
350
|
if (typeof error === "string" && error.trim()) return error;
|
|
351
|
+
if (error && typeof error === "object" && !Array.isArray(error)) {
|
|
352
|
+
const message = (error as { message?: unknown }).message;
|
|
353
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
354
|
+
}
|
|
244
355
|
}
|
|
245
356
|
return "Cloud browser request failed";
|
|
246
357
|
}
|
|
247
358
|
|
|
359
|
+
function gatewayBearerToken(runtime: RuntimeBundle): string {
|
|
360
|
+
return runtime.gatewayBearerToken?.trim() || runtime.bearerToken;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function gatewayBrowserHeaders(runtime: RuntimeBundle): Record<string, string> {
|
|
364
|
+
const runId = process.env.DENCH_RUN_ID?.trim();
|
|
365
|
+
return {
|
|
366
|
+
...(runtime.gatewayHeaders ?? {}),
|
|
367
|
+
...(runId ? { "x-dench-run-id": runId } : {}),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function registerScriptSession(args: {
|
|
372
|
+
runtime: RuntimeBundle;
|
|
373
|
+
session: DenchBrowserSessionResponse;
|
|
374
|
+
status: string;
|
|
375
|
+
}): Promise<void> {
|
|
376
|
+
const runId = process.env.DENCH_RUN_ID?.trim();
|
|
377
|
+
const sessionId = args.session.sessionId?.trim();
|
|
378
|
+
if (!runId || !sessionId) return;
|
|
379
|
+
try {
|
|
380
|
+
await postJson(
|
|
381
|
+
buildRegisterSessionUrl(args.runtime),
|
|
382
|
+
args.runtime.bearerToken,
|
|
383
|
+
{
|
|
384
|
+
runId,
|
|
385
|
+
sessionId,
|
|
386
|
+
liveViewUrl: args.session.liveViewUrl,
|
|
387
|
+
status: args.status,
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
} catch {
|
|
391
|
+
// Best-effort: browser automation must not fail because UI registration
|
|
392
|
+
// could not link the session to the run.
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function parseSessionCreateBody(args: string[]): Record<string, unknown> {
|
|
397
|
+
// The per-user browser profile is resolved server-side by the gateway
|
|
398
|
+
// from the authenticated Dench API key plus DENCH_RUN_ID when present
|
|
399
|
+
// (Convex `browserIdentities`).
|
|
400
|
+
// `--context-id` stays only as an explicit override for manual testing;
|
|
401
|
+
// there is no env-var fallback.
|
|
402
|
+
const contextId =
|
|
403
|
+
consumeFlagValue(args, "--context-id") ||
|
|
404
|
+
consumeFlagValue(args, "--contextId") ||
|
|
405
|
+
"";
|
|
406
|
+
const width = parseNumberFlag(args, "--width");
|
|
407
|
+
const height = parseNumberFlag(args, "--height");
|
|
408
|
+
return {
|
|
409
|
+
...(contextId ? { contextId } : {}),
|
|
410
|
+
...(width || height
|
|
411
|
+
? {
|
|
412
|
+
viewport: {
|
|
413
|
+
...(width ? { width } : {}),
|
|
414
|
+
...(height ? { height } : {}),
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
: {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function runSessionCommand(ctx: BrowserCliContext): Promise<void> {
|
|
422
|
+
ctx.args.shift(); // session
|
|
423
|
+
const subcommand = ctx.args.shift() ?? "create";
|
|
424
|
+
const bearerToken = gatewayBearerToken(ctx.runtime);
|
|
425
|
+
const gatewayHeaders = gatewayBrowserHeaders(ctx.runtime);
|
|
426
|
+
|
|
427
|
+
if (subcommand === "create") {
|
|
428
|
+
const body = parseSessionCreateBody(ctx.args);
|
|
429
|
+
const result = await postJson(
|
|
430
|
+
buildGatewayBrowserUrl(ctx.runtime),
|
|
431
|
+
bearerToken,
|
|
432
|
+
body,
|
|
433
|
+
gatewayHeaders,
|
|
434
|
+
);
|
|
435
|
+
if (!result.ok) {
|
|
436
|
+
throw new BrowserCliError(
|
|
437
|
+
`${payloadError(result.payload)} (${result.status})`,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
const session =
|
|
441
|
+
result.payload && typeof result.payload === "object"
|
|
442
|
+
? (result.payload as DenchBrowserSessionResponse)
|
|
443
|
+
: {};
|
|
444
|
+
await registerScriptSession({
|
|
445
|
+
runtime: ctx.runtime,
|
|
446
|
+
session,
|
|
447
|
+
status: session.status ?? "live",
|
|
448
|
+
});
|
|
449
|
+
output(
|
|
450
|
+
ctx,
|
|
451
|
+
ctx.jsonOutput ? session : `Browser session ${session.sessionId}`,
|
|
452
|
+
);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (subcommand === "status") {
|
|
457
|
+
const sessionId = ctx.args[0]?.trim();
|
|
458
|
+
if (!sessionId) {
|
|
459
|
+
throw new BrowserCliError(
|
|
460
|
+
"dench browser session status requires a session id",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const result = await getJson(
|
|
464
|
+
buildGatewayBrowserUrl(
|
|
465
|
+
ctx.runtime,
|
|
466
|
+
`/v1/browser/sessions/${encodeURIComponent(sessionId)}`,
|
|
467
|
+
),
|
|
468
|
+
bearerToken,
|
|
469
|
+
gatewayHeaders,
|
|
470
|
+
);
|
|
471
|
+
if (!result.ok) {
|
|
472
|
+
throw new BrowserCliError(
|
|
473
|
+
`${payloadError(result.payload)} (${result.status})`,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
output(
|
|
477
|
+
ctx,
|
|
478
|
+
ctx.jsonOutput ? result.payload : JSON.stringify(result.payload, null, 2),
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (subcommand === "stop") {
|
|
484
|
+
const sessionId = ctx.args[0]?.trim();
|
|
485
|
+
if (!sessionId) {
|
|
486
|
+
throw new BrowserCliError(
|
|
487
|
+
"dench browser session stop requires a session id",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
const result = await deleteJson(
|
|
491
|
+
buildGatewayBrowserUrl(
|
|
492
|
+
ctx.runtime,
|
|
493
|
+
`/v1/browser/sessions/${encodeURIComponent(sessionId)}`,
|
|
494
|
+
),
|
|
495
|
+
bearerToken,
|
|
496
|
+
gatewayHeaders,
|
|
497
|
+
);
|
|
498
|
+
if (!result.ok) {
|
|
499
|
+
throw new BrowserCliError(
|
|
500
|
+
`${payloadError(result.payload)} (${result.status})`,
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
await registerScriptSession({
|
|
504
|
+
runtime: ctx.runtime,
|
|
505
|
+
session: { sessionId },
|
|
506
|
+
status: "ended",
|
|
507
|
+
});
|
|
508
|
+
output(
|
|
509
|
+
ctx,
|
|
510
|
+
ctx.jsonOutput ? result.payload : `Stopped browser session ${sessionId}`,
|
|
511
|
+
);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
throw new BrowserCliError(`Unknown browser session command: ${subcommand}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
248
518
|
async function maybeWriteScreenshot(
|
|
249
519
|
response: BrowserResponse,
|
|
250
520
|
outputPath: string | undefined,
|
|
@@ -459,6 +729,11 @@ export async function runBrowserCommand(opts: {
|
|
|
459
729
|
return;
|
|
460
730
|
}
|
|
461
731
|
|
|
732
|
+
if (first === "session") {
|
|
733
|
+
await runSessionCommand(ctx);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
|
|
462
737
|
const { body, outputPath } = parseActionAndBody(ctx);
|
|
463
738
|
if (!body.threadId) {
|
|
464
739
|
body.threadId = await readRememberedThreadId(opts.runtime);
|
package/cron.ts
CHANGED
|
@@ -34,10 +34,15 @@
|
|
|
34
34
|
* disable <jobId> Soft-pause without delete.
|
|
35
35
|
* run <jobId> [--wait] Manual fire; --wait blocks.
|
|
36
36
|
* secret <jobId> Mint/rotate a signing secret (any type).
|
|
37
|
+
* events <toolkit> List a connected app's event types.
|
|
37
38
|
* runs <jobId> [--limit N] [--status] Run history per trigger.
|
|
38
39
|
* history [--limit N] Flat history across all crons.
|
|
39
40
|
* help Print this help.
|
|
40
41
|
*
|
|
42
|
+
* Trigger types accepted by `create --type`:
|
|
43
|
+
* schedule (default) | webhook | composio (app event) | crm (CRM event).
|
|
44
|
+
* composio/crm POST to /api/cron/jobs; the others use the Convex mutation.
|
|
45
|
+
*
|
|
41
46
|
* Schedule flags accepted by `create` and `update`:
|
|
42
47
|
* --every <duration> 30s | 5m | 2h | 1d | 12h…
|
|
43
48
|
* --every-ms <number> Same, raw milliseconds.
|
|
@@ -139,6 +144,28 @@ async function postJson(
|
|
|
139
144
|
return json;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
async function getJson(
|
|
148
|
+
url: string,
|
|
149
|
+
headers: Record<string, string>,
|
|
150
|
+
): Promise<unknown> {
|
|
151
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
152
|
+
const text = await response.text();
|
|
153
|
+
let json: unknown;
|
|
154
|
+
try {
|
|
155
|
+
json = text ? JSON.parse(text) : null;
|
|
156
|
+
} catch {
|
|
157
|
+
json = { raw: text };
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const detail =
|
|
161
|
+
json && typeof json === "object" && "error" in json
|
|
162
|
+
? String((json as Record<string, unknown>).error)
|
|
163
|
+
: JSON.stringify(json);
|
|
164
|
+
throw new CronCliError(`${url} failed (${response.status}): ${detail}`);
|
|
165
|
+
}
|
|
166
|
+
return json;
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
async function callQuery(
|
|
143
170
|
ctx: CronCliContext,
|
|
144
171
|
fn: Parameters<ConvexHttpClient["query"]>[0],
|
|
@@ -367,12 +394,32 @@ async function runCreate(ctx: CronCliContext): Promise<void> {
|
|
|
367
394
|
}
|
|
368
395
|
const triggerType = (getFlag(ctx.args, "--type") ?? "schedule") as
|
|
369
396
|
| "schedule"
|
|
370
|
-
| "webhook"
|
|
371
|
-
|
|
397
|
+
| "webhook"
|
|
398
|
+
| "composio"
|
|
399
|
+
| "crm";
|
|
400
|
+
if (!["schedule", "webhook", "composio", "crm"].includes(triggerType)) {
|
|
372
401
|
throw new CronCliError(
|
|
373
|
-
`--type must be schedule|webhook (got "${triggerType}")
|
|
402
|
+
`--type must be schedule|webhook|composio|crm (got "${triggerType}").`,
|
|
374
403
|
);
|
|
375
404
|
}
|
|
405
|
+
|
|
406
|
+
// App-event (composio) and CRM-event automations are created through the
|
|
407
|
+
// REST `/api/cron/jobs` POST: composio needs the upstream subscribe +
|
|
408
|
+
// connection-ownership flow (Node runtime, project Composio key), and CRM
|
|
409
|
+
// rides the same path for one create surface. schedule + webhook keep the
|
|
410
|
+
// direct Convex mutation below.
|
|
411
|
+
if (triggerType === "composio" || triggerType === "crm") {
|
|
412
|
+
return await runCreateAppOrCrm(ctx, {
|
|
413
|
+
triggerType,
|
|
414
|
+
name,
|
|
415
|
+
prompt,
|
|
416
|
+
description,
|
|
417
|
+
overlapPolicy,
|
|
418
|
+
disabled,
|
|
419
|
+
deleteAfterRun,
|
|
420
|
+
target,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
376
423
|
const isWebhook = triggerType === "webhook";
|
|
377
424
|
|
|
378
425
|
let schedule: CronScheduleSpec | undefined;
|
|
@@ -447,6 +494,185 @@ async function runSecret(ctx: CronCliContext): Promise<void> {
|
|
|
447
494
|
});
|
|
448
495
|
}
|
|
449
496
|
|
|
497
|
+
export type AppOrCrmCreateBase = {
|
|
498
|
+
triggerType: "composio" | "crm";
|
|
499
|
+
name: string;
|
|
500
|
+
prompt: string;
|
|
501
|
+
description?: string;
|
|
502
|
+
overlapPolicy?: "skip" | "queue" | "kill_old";
|
|
503
|
+
disabled: boolean;
|
|
504
|
+
deleteAfterRun: boolean;
|
|
505
|
+
target?: "chat_turn";
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Build the `/api/cron/jobs` POST body for an app-event (composio) or
|
|
510
|
+
* CRM-event automation from the create flags. Pure (no network) so the flag
|
|
511
|
+
* parsing + validation + payload shape can be unit-tested directly. Consumes
|
|
512
|
+
* the type-specific flags off `args` (composio: --connection/--event/--toolkit/
|
|
513
|
+
* --trigger-config; crm: --object/--event/--where/--where-logic) and throws a
|
|
514
|
+
* `CronCliError` with discovery hints when a required input is missing.
|
|
515
|
+
*/
|
|
516
|
+
export function buildAppOrCrmCreateBody(
|
|
517
|
+
args: string[],
|
|
518
|
+
base: AppOrCrmCreateBase,
|
|
519
|
+
): Record<string, unknown> {
|
|
520
|
+
// Neither type takes a schedule — reject one early instead of silently
|
|
521
|
+
// dropping it server-side.
|
|
522
|
+
if (consumeScheduleFlags(args)) {
|
|
523
|
+
throw new CronCliError(
|
|
524
|
+
`${base.triggerType} automations don't take a schedule. Omit --every/--cron/--at.`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const body: Record<string, unknown> = {
|
|
529
|
+
name: base.name,
|
|
530
|
+
prompt: base.prompt,
|
|
531
|
+
triggerType: base.triggerType,
|
|
532
|
+
description: base.description,
|
|
533
|
+
overlapPolicy: base.overlapPolicy,
|
|
534
|
+
enabled: base.disabled ? false : undefined,
|
|
535
|
+
deleteAfterRun: base.deleteAfterRun ? true : undefined,
|
|
536
|
+
target: base.target,
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
if (base.triggerType === "composio") {
|
|
540
|
+
const connection = getFlag(args, "--connection");
|
|
541
|
+
const event = getFlag(args, "--event");
|
|
542
|
+
if (!connection?.trim() || !event?.trim()) {
|
|
543
|
+
throw new CronCliError(
|
|
544
|
+
"app-event automations need --connection <connectedAccountId> and --event <triggerSlug>.\n" +
|
|
545
|
+
" Discover apps: dench apps --json (use connectedAccountId + toolkit)\n" +
|
|
546
|
+
" Discover events: dench cron events <toolkit> (use the event slug)\n" +
|
|
547
|
+
"Example:\n" +
|
|
548
|
+
' dench cron create "New GitHub star" --prompt "Thank the new stargazer" \\\n' +
|
|
549
|
+
" --type composio --connection ca_123 --event GITHUB_STAR_ADDED_EVENT --toolkit github",
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const toolkit = getFlag(args, "--toolkit");
|
|
553
|
+
const triggerConfigRaw = getFlag(args, "--trigger-config");
|
|
554
|
+
let triggerConfig: unknown;
|
|
555
|
+
if (triggerConfigRaw) {
|
|
556
|
+
try {
|
|
557
|
+
triggerConfig = JSON.parse(triggerConfigRaw);
|
|
558
|
+
} catch {
|
|
559
|
+
throw new CronCliError(
|
|
560
|
+
`--trigger-config must be valid JSON (got ${triggerConfigRaw})`,
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
body.composioConnectionId = connection.trim();
|
|
565
|
+
body.composioTriggerSlug = event.trim();
|
|
566
|
+
if (toolkit?.trim()) body.composioToolkitSlug = toolkit.trim();
|
|
567
|
+
if (triggerConfig !== undefined) body.triggerConfig = triggerConfig;
|
|
568
|
+
} else {
|
|
569
|
+
const object = getFlag(args, "--object");
|
|
570
|
+
const event = getFlag(args, "--event");
|
|
571
|
+
if (!object?.trim() || !event?.trim()) {
|
|
572
|
+
throw new CronCliError(
|
|
573
|
+
"CRM-event automations need --object <objectId> and --event create|update|delete.\n" +
|
|
574
|
+
" Discover objects: dench crm objects list\n" +
|
|
575
|
+
" Discover fields: dench crm fields list <object> (filter on field _id via --where)\n" +
|
|
576
|
+
"Example:\n" +
|
|
577
|
+
' dench cron create "New company" --prompt "Research the new company" \\\n' +
|
|
578
|
+
" --type crm --object <objectId> --event create",
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (!["create", "update", "delete"].includes(event)) {
|
|
582
|
+
throw new CronCliError(
|
|
583
|
+
`--event must be create|update|delete for CRM automations (got "${event}")`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
const whereRaw = getFlag(args, "--where");
|
|
587
|
+
const whereLogic = (getFlag(args, "--where-logic") ?? "AND") as
|
|
588
|
+
| "AND"
|
|
589
|
+
| "OR";
|
|
590
|
+
if (!["AND", "OR"].includes(whereLogic)) {
|
|
591
|
+
throw new CronCliError(
|
|
592
|
+
`--where-logic must be AND|OR (got "${whereLogic}")`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const crmTriggerConfig: Record<string, unknown> = {
|
|
596
|
+
eventType: event,
|
|
597
|
+
objectId: object.trim(),
|
|
598
|
+
};
|
|
599
|
+
if (whereRaw) {
|
|
600
|
+
let whereGroups: unknown;
|
|
601
|
+
try {
|
|
602
|
+
whereGroups = JSON.parse(whereRaw);
|
|
603
|
+
} catch {
|
|
604
|
+
throw new CronCliError(
|
|
605
|
+
"--where must be valid JSON: an array of groups like " +
|
|
606
|
+
'\'[{"logic":"AND","conditions":[{"fieldId":"<id>","operator":"equals","value":"x"}]}]\'',
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (!Array.isArray(whereGroups)) {
|
|
610
|
+
throw new CronCliError(
|
|
611
|
+
"--where must be a JSON array of where-groups (each { logic, conditions }).",
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
crmTriggerConfig.whereGroups = whereGroups;
|
|
615
|
+
crmTriggerConfig.whereLogic = whereLogic;
|
|
616
|
+
}
|
|
617
|
+
body.crmTriggerConfig = crmTriggerConfig;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return body;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Create an app-event (composio) or CRM-event automation via the REST
|
|
625
|
+
* `/api/cron/jobs` POST. composio creation also subscribes the trigger
|
|
626
|
+
* instance upstream + verifies the org owns the connection (handled
|
|
627
|
+
* server-side in the Node route); CRM creation validates the object +
|
|
628
|
+
* filter field ids in the mutation.
|
|
629
|
+
*/
|
|
630
|
+
async function runCreateAppOrCrm(
|
|
631
|
+
ctx: CronCliContext,
|
|
632
|
+
base: AppOrCrmCreateBase,
|
|
633
|
+
): Promise<void> {
|
|
634
|
+
const body = buildAppOrCrmCreateBody(ctx.args, base);
|
|
635
|
+
const url = `${getApiBase()}/api/cron/jobs`;
|
|
636
|
+
const created = await postJson(
|
|
637
|
+
url,
|
|
638
|
+
apiKeyHeaders(ctx.sessionToken ?? ""),
|
|
639
|
+
body,
|
|
640
|
+
);
|
|
641
|
+
out(ctx, created);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* List the app-event types a connected app publishes, so the caller can pick
|
|
646
|
+
* a `--event <triggerSlug>` for `dench cron create --type composio`. Backed by
|
|
647
|
+
* `GET /api/cron/trigger-types` (project-level Composio catalogue).
|
|
648
|
+
*/
|
|
649
|
+
async function runEvents(ctx: CronCliContext): Promise<void> {
|
|
650
|
+
const toolkit = shift(
|
|
651
|
+
ctx.args,
|
|
652
|
+
"toolkit slug (e.g. github — run `dench apps --json` to see each app's slug)",
|
|
653
|
+
);
|
|
654
|
+
const url = `${getApiBase()}/api/cron/trigger-types?toolkit=${encodeURIComponent(toolkit)}`;
|
|
655
|
+
const result = (await getJson(
|
|
656
|
+
url,
|
|
657
|
+
apiKeyHeaders(ctx.sessionToken ?? ""),
|
|
658
|
+
)) as {
|
|
659
|
+
items?: Array<{ slug?: string; name?: string; description?: string }>;
|
|
660
|
+
};
|
|
661
|
+
if (ctx.jsonOutput) {
|
|
662
|
+
out(ctx, result);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const items = result.items ?? [];
|
|
666
|
+
if (items.length === 0) {
|
|
667
|
+
console.log(`(no app events found for "${toolkit}")`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
for (const item of items) {
|
|
671
|
+
console.log(`${item.slug ?? "?"} — ${item.name ?? ""}`);
|
|
672
|
+
if (item.description) console.log(` ${item.description}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
450
676
|
async function runUpdate(ctx: CronCliContext): Promise<void> {
|
|
451
677
|
const jobId = shift(ctx.args, "job id");
|
|
452
678
|
const name = getFlag(ctx.args, "--name");
|
|
@@ -578,11 +804,12 @@ Browse:
|
|
|
578
804
|
dench cron get <jobId> [--json]
|
|
579
805
|
dench cron history [--limit 50] [--status ok|error|skipped] [--json]
|
|
580
806
|
dench cron runs <jobId> [--limit 50] [--status ok|error|skipped] [--json]
|
|
807
|
+
dench cron events <toolkit> [--json] # app-event types for a connected app
|
|
581
808
|
|
|
582
809
|
Create / update:
|
|
583
810
|
dench cron create "Daily standup digest" \\
|
|
584
811
|
--prompt "Pull yesterday's commits + Slack #standup msgs and post a summary…" \\
|
|
585
|
-
--type schedule # default
|
|
812
|
+
--type schedule # default. also: webhook | composio | crm (see below)
|
|
586
813
|
--every 1d # or --cron "0 9 * * 1-5" or --at 2026-05-04T09:00:00Z
|
|
587
814
|
[--description "..."] # human-readable note shown in dashboard / list
|
|
588
815
|
[--overlap skip|queue|kill_old] # default: skip
|
|
@@ -595,6 +822,21 @@ Create / update:
|
|
|
595
822
|
# signing secret unless --no-secret. The secret is shown ONCE.
|
|
596
823
|
dench cron create "Inbound lead triage" --prompt "Triage the lead…" --type webhook
|
|
597
824
|
|
|
825
|
+
# App-event automation (fires when something happens in a connected app).
|
|
826
|
+
# 1. dench apps --json → connectedAccountId + toolkit
|
|
827
|
+
# 2. dench cron events <toolkit> → event slug
|
|
828
|
+
dench cron create "New GitHub star" --prompt "Thank the new stargazer" \\
|
|
829
|
+
--type composio --connection <connectedAccountId> --event <triggerSlug> \\
|
|
830
|
+
[--toolkit <slug>] [--trigger-config '<json>']
|
|
831
|
+
|
|
832
|
+
# CRM-event automation (fires on a CRM object create/update/delete).
|
|
833
|
+
# 1. dench crm objects list → objectId
|
|
834
|
+
# 2. dench crm fields list <object> → field _id (for --where filters)
|
|
835
|
+
dench cron create "New company added" --prompt "Research the new company" \\
|
|
836
|
+
--type crm --object <objectId> --event create|update|delete \\
|
|
837
|
+
[--where '[{"logic":"AND","conditions":[{"fieldId":"<id>","operator":"equals","value":"x"}]}]'] \\
|
|
838
|
+
[--where-logic AND|OR]
|
|
839
|
+
|
|
598
840
|
dench cron update <jobId> [--name N] [--prompt P] [--description D] \\
|
|
599
841
|
[--every X | --cron EXPR | --at ISO] [--enabled true|false] [--overlap …]
|
|
600
842
|
|
|
@@ -676,6 +918,8 @@ export async function runCronCommand(opts: {
|
|
|
676
918
|
return await runRunNow(ctx);
|
|
677
919
|
case "secret":
|
|
678
920
|
return await runSecret(ctx);
|
|
921
|
+
case "events":
|
|
922
|
+
return await runEvents(ctx);
|
|
679
923
|
case "runs":
|
|
680
924
|
return await runRuns(ctx);
|
|
681
925
|
case "history":
|
package/dench.ts
CHANGED
|
@@ -501,7 +501,7 @@ Live web search (Exa via the Dench Gateway, auths with the active \`dench signin
|
|
|
501
501
|
dench search answer "<query>" [--json]
|
|
502
502
|
Help: dench search help
|
|
503
503
|
|
|
504
|
-
Cloud browser (
|
|
504
|
+
Cloud browser (runs entirely in Dench Cloud):
|
|
505
505
|
dench browser open [url] [--json]
|
|
506
506
|
dench browser inspect [--json]
|
|
507
507
|
dench browser navigate <url>
|
|
@@ -4053,11 +4053,19 @@ async function main() {
|
|
|
4053
4053
|
const { runBrowserCommand } = await import("./browser");
|
|
4054
4054
|
const runtime = await getRuntime();
|
|
4055
4055
|
const authedRuntime = requireAuthenticatedRuntime(runtime, "dench browser");
|
|
4056
|
+
const filteredSubArgs = subArgs.filter((a) => a !== "--json");
|
|
4057
|
+
const gatewayAuth =
|
|
4058
|
+
filteredSubArgs[0] === "session"
|
|
4059
|
+
? await resolveGatewayCommandAuth(runtime, "dench browser session")
|
|
4060
|
+
: undefined;
|
|
4056
4061
|
await runBrowserCommand({
|
|
4057
4062
|
args: subArgs,
|
|
4058
4063
|
runtime: {
|
|
4059
4064
|
host: authedRuntime.host,
|
|
4060
4065
|
bearerToken: authedRuntime.sessionToken,
|
|
4066
|
+
gatewayBearerToken: gatewayAuth?.bearerToken,
|
|
4067
|
+
gatewayBaseUrl: gatewayAuth?.gatewayBaseUrl,
|
|
4068
|
+
gatewayHeaders: gatewayAuth?.gatewayHeaders,
|
|
4061
4069
|
},
|
|
4062
4070
|
});
|
|
4063
4071
|
return;
|
package/lib/api-schemas.ts
CHANGED
|
@@ -2532,6 +2532,13 @@ export const convexExtras: Record<string, ConvexExtras> = {
|
|
|
2532
2532
|
// ---------------------------------------------------------------------------
|
|
2533
2533
|
|
|
2534
2534
|
export const requestExamples: Record<string, unknown> = {
|
|
2535
|
+
"browser.action": {
|
|
2536
|
+
action: "open",
|
|
2537
|
+
url: "https://example.com",
|
|
2538
|
+
},
|
|
2539
|
+
"browser.session.create": {
|
|
2540
|
+
viewport: { width: 1280, height: 800 },
|
|
2541
|
+
},
|
|
2535
2542
|
"crm.objects.create": {
|
|
2536
2543
|
name: "lead",
|
|
2537
2544
|
description: "Sales leads",
|
|
@@ -2703,6 +2710,14 @@ const workspaceStatusExample = {
|
|
|
2703
2710
|
};
|
|
2704
2711
|
|
|
2705
2712
|
export const responseExamples: Record<string, unknown> = {
|
|
2713
|
+
"browser.session.create": {
|
|
2714
|
+
provider: "dench_browser",
|
|
2715
|
+
sessionId: "bb_sess_123",
|
|
2716
|
+
connectUrl:
|
|
2717
|
+
"wss://gateway.merseoriginals.com/v1/browser/sessions/bb_sess_123/cdp?relay_token=...",
|
|
2718
|
+
liveViewUrl: "https://www.dench.com/dench?...",
|
|
2719
|
+
status: "live",
|
|
2720
|
+
},
|
|
2706
2721
|
"commands.list": {
|
|
2707
2722
|
operations: [
|
|
2708
2723
|
{
|
package/lib/command-registry.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type CustomOperation = {
|
|
|
34
34
|
| "upgrade"
|
|
35
35
|
| "filesMove"
|
|
36
36
|
| "filesStage"
|
|
37
|
+
| "browserAction"
|
|
37
38
|
| "signinOtpStart"
|
|
38
39
|
| "signinOtpVerify"
|
|
39
40
|
| "signinOtpFinalize";
|
|
@@ -1963,6 +1964,57 @@ const rawApiOperations = [
|
|
|
1963
1964
|
backend: gateway("DELETE", "/composio/connections/{connectionId}"),
|
|
1964
1965
|
}),
|
|
1965
1966
|
|
|
1967
|
+
// Browser automation (Dench Browser). The provider stays server-side; the
|
|
1968
|
+
// per-(org,user) browser profile is resolved by the gateway, so callers never
|
|
1969
|
+
// see or send provider credentials or context ids.
|
|
1970
|
+
op({
|
|
1971
|
+
id: "browser.action",
|
|
1972
|
+
group: "browser",
|
|
1973
|
+
summary:
|
|
1974
|
+
"Drive Dench Browser with a single interactive action (open, inspect, click, fill, type, screenshot, stop). Pass the step in `action` plus its arguments.",
|
|
1975
|
+
cli: "dench browser",
|
|
1976
|
+
method: "POST",
|
|
1977
|
+
path: "/browser/cli",
|
|
1978
|
+
requestSchema: anyObject,
|
|
1979
|
+
responseSchema: anyResponse,
|
|
1980
|
+
backend: custom("browserAction", "bearer"),
|
|
1981
|
+
}),
|
|
1982
|
+
op({
|
|
1983
|
+
id: "browser.session.create",
|
|
1984
|
+
group: "browser",
|
|
1985
|
+
summary:
|
|
1986
|
+
"Create a Dench Browser session and return a CDP connect URL for Playwright (chromium.connectOverCDP) plus a live-view URL. Use for durable scripts run from the sandbox.",
|
|
1987
|
+
cli: "dench browser session create",
|
|
1988
|
+
method: "POST",
|
|
1989
|
+
path: "/browser/sessions",
|
|
1990
|
+
requestSchema: anyObject,
|
|
1991
|
+
responseSchema: anyResponse,
|
|
1992
|
+
backend: gateway("POST", "/browser/sessions"),
|
|
1993
|
+
}),
|
|
1994
|
+
op({
|
|
1995
|
+
id: "browser.session.status",
|
|
1996
|
+
group: "browser",
|
|
1997
|
+
summary:
|
|
1998
|
+
"Get the status, connect URL, and live-view URL for a Dench Browser session.",
|
|
1999
|
+
cli: "dench browser session status",
|
|
2000
|
+
method: "GET",
|
|
2001
|
+
path: "/browser/sessions/{sessionId}",
|
|
2002
|
+
requestSchema: anyObject,
|
|
2003
|
+
responseSchema: anyResponse,
|
|
2004
|
+
backend: gateway("GET", "/browser/sessions/{sessionId}"),
|
|
2005
|
+
}),
|
|
2006
|
+
op({
|
|
2007
|
+
id: "browser.session.stop",
|
|
2008
|
+
group: "browser",
|
|
2009
|
+
summary: "Stop a Dench Browser session and release the remote browser.",
|
|
2010
|
+
cli: "dench browser session stop",
|
|
2011
|
+
method: "DELETE",
|
|
2012
|
+
path: "/browser/sessions/{sessionId}",
|
|
2013
|
+
requestSchema: anyObject,
|
|
2014
|
+
responseSchema: anyResponse,
|
|
2015
|
+
backend: gateway("DELETE", "/browser/sessions/{sessionId}"),
|
|
2016
|
+
}),
|
|
2017
|
+
|
|
1966
2018
|
// Agent harness config.
|
|
1967
2019
|
op({
|
|
1968
2020
|
id: "agentConfig.identity.show",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dench.com/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|