@dench.com/cli 2.2.6 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/cron.ts +338 -17
  2. package/package.json +1 -1
package/cron.ts CHANGED
@@ -33,10 +33,16 @@
33
33
  * enable <jobId> Re-arm a paused cron.
34
34
  * disable <jobId> Soft-pause without delete.
35
35
  * run <jobId> [--wait] Manual fire; --wait blocks.
36
+ * secret <jobId> Mint/rotate a signing secret (any type).
37
+ * events <toolkit> List a connected app's event types.
36
38
  * runs <jobId> [--limit N] [--status] Run history per trigger.
37
39
  * history [--limit N] Flat history across all crons.
38
40
  * help Print this help.
39
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
+ *
40
46
  * Schedule flags accepted by `create` and `update`:
41
47
  * --every <duration> 30s | 5m | 2h | 1d | 12h…
42
48
  * --every-ms <number> Same, raw milliseconds.
@@ -138,6 +144,28 @@ async function postJson(
138
144
  return json;
139
145
  }
140
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
+
141
169
  async function callQuery(
142
170
  ctx: CronCliContext,
143
171
  fn: Parameters<ConvexHttpClient["query"]>[0],
@@ -196,7 +224,9 @@ export type CronScheduleSpec =
196
224
  *
197
225
  * `--at` implicitly drops `--every` / `--cron` if both are passed.
198
226
  */
199
- export function consumeScheduleFlags(args: string[]): CronScheduleSpec | undefined {
227
+ export function consumeScheduleFlags(
228
+ args: string[],
229
+ ): CronScheduleSpec | undefined {
200
230
  const at = getFlag(args, "--at");
201
231
  if (at) {
202
232
  const ts = new Date(at).getTime();
@@ -335,10 +365,15 @@ async function runGet(ctx: CronCliContext): Promise<void> {
335
365
  }
336
366
 
337
367
  async function runCreate(ctx: CronCliContext): Promise<void> {
338
- const name = shift(ctx.args, "name (positional, e.g. dench cron create \"Daily standup digest\" --prompt … --every 1d)");
368
+ const name = shift(
369
+ ctx.args,
370
+ 'name (positional, e.g. dench cron create "Daily standup digest" --prompt … --every 1d)',
371
+ );
339
372
  const prompt = getFlag(ctx.args, "--prompt");
340
373
  if (!prompt || !prompt.trim()) {
341
- throw new CronCliError("--prompt is required (becomes the user message of each cron-fired chat thread)");
374
+ throw new CronCliError(
375
+ "--prompt is required (becomes the user message of each cron-fired chat thread)",
376
+ );
342
377
  }
343
378
  const description = getFlag(ctx.args, "--description");
344
379
  const overlapPolicy = getFlag(ctx.args, "--overlap") as
@@ -346,10 +381,7 @@ async function runCreate(ctx: CronCliContext): Promise<void> {
346
381
  | "queue"
347
382
  | "kill_old"
348
383
  | undefined;
349
- if (
350
- overlapPolicy &&
351
- !["skip", "queue", "kill_old"].includes(overlapPolicy)
352
- ) {
384
+ if (overlapPolicy && !["skip", "queue", "kill_old"].includes(overlapPolicy)) {
353
385
  throw new CronCliError(
354
386
  `--overlap must be one of skip|queue|kill_old (got "${overlapPolicy}")`,
355
387
  );
@@ -360,26 +392,285 @@ async function runCreate(ctx: CronCliContext): Promise<void> {
360
392
  if (target && target !== "chat_turn") {
361
393
  throw new CronCliError(`--target must be chat_turn (got "${target}")`);
362
394
  }
363
- const schedule = consumeScheduleFlags(ctx.args);
364
- if (!schedule) {
395
+ const triggerType = (getFlag(ctx.args, "--type") ?? "schedule") as
396
+ | "schedule"
397
+ | "webhook"
398
+ | "composio"
399
+ | "crm";
400
+ if (!["schedule", "webhook", "composio", "crm"].includes(triggerType)) {
365
401
  throw new CronCliError(
366
- "One of --every / --cron / --at is required. Examples:\n" +
367
- " dench cron create \"Hourly health check\" --prompt 'Check uptime…' --every 1h\n" +
368
- ' dench cron create "Weekday digest" --prompt \'Pull yesterday…\' --cron "0 9 * * 1-5"\n' +
369
- ' dench cron create "May 4 launch" --prompt \'Send launch tweet\' --at 2026-05-04T09:00:00Z',
402
+ `--type must be schedule|webhook|composio|crm (got "${triggerType}").`,
370
403
  );
371
404
  }
372
- const result = await callMutation(ctx, createCronTriggerRef, {
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
+ }
423
+ const isWebhook = triggerType === "webhook";
424
+
425
+ let schedule: CronScheduleSpec | undefined;
426
+ if (isWebhook) {
427
+ // Webhook automations fire on demand / via their signed URL — no schedule.
428
+ if (consumeScheduleFlags(ctx.args)) {
429
+ throw new CronCliError(
430
+ "Webhook automations don't take a schedule. Omit --every/--cron/--at.",
431
+ );
432
+ }
433
+ } else {
434
+ schedule = consumeScheduleFlags(ctx.args);
435
+ if (!schedule) {
436
+ throw new CronCliError(
437
+ "One of --every / --cron / --at is required for schedule automations. Examples:\n" +
438
+ " dench cron create \"Hourly health check\" --prompt 'Check uptime…' --every 1h\n" +
439
+ ' dench cron create "Weekday digest" --prompt \'Pull yesterday…\' --cron "0 9 * * 1-5"\n' +
440
+ " dench cron create \"Inbound lead\" --prompt 'Triage the lead…' --type webhook",
441
+ );
442
+ }
443
+ }
444
+ const created = (await callMutation(ctx, createCronTriggerRef, {
373
445
  name,
374
446
  prompt,
447
+ triggerType,
375
448
  schedule,
376
449
  description,
377
450
  overlapPolicy,
378
451
  enabled: disabled ? false : undefined,
379
452
  deleteAfterRun: deleteAfterRun ? true : undefined,
380
453
  target,
454
+ })) as { jobId?: string };
455
+
456
+ // Webhook automations need a signing secret to be externally callable. Mint
457
+ // one now (unless --no-secret) so `create` hands back a working URL+secret.
458
+ if (isWebhook && !hasFlag(ctx.args, "--no-secret") && created.jobId) {
459
+ const secret = await mintAutomationSecret(ctx, created.jobId);
460
+ out(ctx, { ...created, ...secret });
461
+ return;
462
+ }
463
+ out(ctx, created);
464
+ }
465
+
466
+ /**
467
+ * Mint (or rotate) a signing secret for an automation via the Next REST route
468
+ * (secret encryption runs in the Node route, not Convex's V8 runtime). Returns
469
+ * the plaintext secret + callable URL — shown once.
470
+ */
471
+ async function mintAutomationSecret(
472
+ ctx: CronCliContext,
473
+ jobId: string,
474
+ ): Promise<{ webhookUrl: string | null; webhookSecret: string | null }> {
475
+ const url = `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}/regenerate-secret`;
476
+ const res = (await postJson(
477
+ url,
478
+ apiKeyHeaders(ctx.sessionToken ?? ""),
479
+ {},
480
+ )) as { webhookUrl?: string | null; webhookSecret?: string | null };
481
+ return {
482
+ webhookUrl: res.webhookUrl ?? null,
483
+ webhookSecret: res.webhookSecret ?? null,
484
+ };
485
+ }
486
+
487
+ async function runSecret(ctx: CronCliContext): Promise<void> {
488
+ const jobId = shift(ctx.args, "job id");
489
+ const secret = await mintAutomationSecret(ctx, jobId);
490
+ out(ctx, {
491
+ jobId,
492
+ ...secret,
493
+ note: "Secret shown once — store it now. Re-running rotates it (invalidates the old one).",
381
494
  });
382
- out(ctx, result);
495
+ }
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
+ }
383
674
  }
384
675
 
385
676
  async function runUpdate(ctx: CronCliContext): Promise<void> {
@@ -458,7 +749,11 @@ async function runRunNow(ctx: CronCliContext): Promise<void> {
458
749
  })) as { entries: Array<{ ts: number; status?: string }> };
459
750
  const matching = runs.entries.find(() => true);
460
751
  if (matching && matching.status) {
461
- out(ctx, { ...fired, finalStatus: matching.status, durationMs: Date.now() - start });
752
+ out(ctx, {
753
+ ...fired,
754
+ finalStatus: matching.status,
755
+ durationMs: Date.now() - start,
756
+ });
462
757
  return;
463
758
  }
464
759
  await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -509,10 +804,12 @@ Browse:
509
804
  dench cron get <jobId> [--json]
510
805
  dench cron history [--limit 50] [--status ok|error|skipped] [--json]
511
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
512
808
 
513
- Create / update (one of --every / --cron / --at is required for create):
809
+ Create / update:
514
810
  dench cron create "Daily standup digest" \\
515
811
  --prompt "Pull yesterday's commits + Slack #standup msgs and post a summary…" \\
812
+ --type schedule # default. also: webhook | composio | crm (see below)
516
813
  --every 1d # or --cron "0 9 * * 1-5" or --at 2026-05-04T09:00:00Z
517
814
  [--description "..."] # human-readable note shown in dashboard / list
518
815
  [--overlap skip|queue|kill_old] # default: skip
@@ -521,6 +818,25 @@ Create / update (one of --every / --cron / --at is required for create):
521
818
  [--target chat_turn] # default: chat_turn (fires a fresh chat thread)
522
819
  [--json]
523
820
 
821
+ # Webhook automation (fires on demand or via a signed URL). Mints + prints a
822
+ # signing secret unless --no-secret. The secret is shown ONCE.
823
+ dench cron create "Inbound lead triage" --prompt "Triage the lead…" --type webhook
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
+
524
840
  dench cron update <jobId> [--name N] [--prompt P] [--description D] \\
525
841
  [--every X | --cron EXPR | --at ISO] [--enabled true|false] [--overlap …]
526
842
 
@@ -529,6 +845,7 @@ Lifecycle:
529
845
  dench cron disable <jobId>
530
846
  dench cron delete <jobId>
531
847
  dench cron run <jobId> [--wait] [--json] # manual fire; --wait blocks until run finishes
848
+ dench cron secret <jobId> [--json] # mint/rotate a signing secret → callable signed URL (any automation type)
532
849
 
533
850
  Schedule formats:
534
851
  --every <duration> Repeat every N. Accepts 30s, 5m, 2h, 1d, 12h, etc.
@@ -599,6 +916,10 @@ export async function runCronCommand(opts: {
599
916
  return await runEnable(ctx, false);
600
917
  case "run":
601
918
  return await runRunNow(ctx);
919
+ case "secret":
920
+ return await runSecret(ctx);
921
+ case "events":
922
+ return await runEvents(ctx);
602
923
  case "runs":
603
924
  return await runRuns(ctx);
604
925
  case "history":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.2.6",
3
+ "version": "2.3.0",
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": {