@dench.com/cli 0.3.4 → 0.3.6

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/cron.ts ADDED
@@ -0,0 +1,612 @@
1
+ /**
2
+ * `dench cron <subcommand>` — CLI surface for managing Convex-native
3
+ * cron triggers.
4
+ *
5
+ * Each cron is a row in the Convex `triggers` table that, when due,
6
+ * spawns a fresh chat thread (the `target: "chat_turn"` path — new
7
+ * default) running in YOLO mode and seeded with the cron's prompt as
8
+ * the first user message. The Vercel cron dispatcher (`/api/cron/dispatch`,
9
+ * scheduled via `vercel.json` at every minute) walks the
10
+ * `by_enabled_nextFireAt` index, claims due rows, and fires the
11
+ * workflow.
12
+ *
13
+ * Auth: prefers the unified Dench API key from `DENCH_API_KEY`
14
+ * (sandbox envVar baked at create time). Falls back to `dench login`
15
+ * session token. The server-side `requireCronAccess` helper accepts
16
+ * both formats; we just pass whichever bearer the parent runtime
17
+ * resolved through `sessionToken`.
18
+ *
19
+ * Convex calls go direct via `ConvexHttpClient` (same pattern as
20
+ * `cli/crm.ts`); `run-now` is the one operation that goes through
21
+ * the REST API hop (`POST /api/cron/jobs/<id>/run-now`) because it
22
+ * needs to start a Vercel Workflow which can only be done from a
23
+ * Next.js route.
24
+ *
25
+ * Subcommands:
26
+ * list List all cron jobs in the org.
27
+ * get <jobId> Single job detail.
28
+ * create <name> --prompt … --every … Create a new cron.
29
+ * update <jobId> [flags] Patch any subset of fields.
30
+ * delete <jobId> [--force] Permanently delete.
31
+ * enable <jobId> Re-arm a paused cron.
32
+ * disable <jobId> Soft-pause without delete.
33
+ * run <jobId> [--wait] Manual fire; --wait blocks.
34
+ * runs <jobId> [--limit N] [--status] Run history per trigger.
35
+ * history [--limit N] Flat history across all crons.
36
+ * help Print this help.
37
+ *
38
+ * Schedule flags accepted by `create` and `update`:
39
+ * --every <duration> 30s | 5m | 2h | 1d | 12h…
40
+ * --every-ms <number> Same, raw milliseconds.
41
+ * --cron <expr> 5-field POSIX cron, e.g. "0 9 * * 1-5".
42
+ * --at <iso> ISO 8601 timestamp (one-shot; auto-disable).
43
+ */
44
+
45
+ import type { ConvexHttpClient } from "convex/browser";
46
+ import { makeFunctionReference } from "convex/server";
47
+ import {
48
+ CliArgError,
49
+ getFlag,
50
+ hasFlag,
51
+ shift as shiftRaw,
52
+ } from "./lib/cli-args";
53
+
54
+ class CronCliError extends Error {}
55
+
56
+ type CronCliContext = {
57
+ convex: ConvexHttpClient;
58
+ args: string[];
59
+ jsonOutput: boolean;
60
+ sessionToken?: string;
61
+ };
62
+
63
+ function shift(args: string[], expected: string): string {
64
+ try {
65
+ return shiftRaw(args, expected);
66
+ } catch (error) {
67
+ if (error instanceof CliArgError) throw new CronCliError(error.message);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ function out(ctx: CronCliContext, value: unknown): void {
73
+ if (ctx.jsonOutput) {
74
+ console.log(JSON.stringify(value, null, 2));
75
+ return;
76
+ }
77
+ if (value === null || value === undefined) {
78
+ console.log("(empty)");
79
+ return;
80
+ }
81
+ console.log(JSON.stringify(value, null, 2));
82
+ }
83
+
84
+ // ─── Auth helpers (mirror cli/agent.ts shape) ─────────────────────────────
85
+
86
+ function getApiBase(): string {
87
+ const base = (
88
+ process.env.DENCH_API_URL ??
89
+ process.env.DENCH_BASE_URL ??
90
+ "http://localhost:3000"
91
+ ).replace(/\/+$/, "");
92
+ if (!base) {
93
+ throw new CronCliError(
94
+ "DENCH_API_URL is not configured. Pass it as an env var or run from a sandbox where it is baked in.",
95
+ );
96
+ }
97
+ return base;
98
+ }
99
+
100
+ function apiKeyHeaders(token: string): Record<string, string> {
101
+ if (!token) {
102
+ throw new CronCliError(
103
+ "Missing auth token. Provide DENCH_API_KEY in env or run `dench login` first.",
104
+ );
105
+ }
106
+ return {
107
+ "content-type": "application/json",
108
+ authorization: `Bearer ${token}`,
109
+ };
110
+ }
111
+
112
+ async function postJson(
113
+ url: string,
114
+ headers: Record<string, string>,
115
+ body: unknown,
116
+ ): Promise<unknown> {
117
+ const response = await fetch(url, {
118
+ method: "POST",
119
+ headers,
120
+ body: JSON.stringify(body ?? {}),
121
+ });
122
+ const text = await response.text();
123
+ let json: unknown;
124
+ try {
125
+ json = text ? JSON.parse(text) : null;
126
+ } catch {
127
+ json = { raw: text };
128
+ }
129
+ if (!response.ok) {
130
+ const detail =
131
+ json && typeof json === "object" && "error" in json
132
+ ? String((json as Record<string, unknown>).error)
133
+ : JSON.stringify(json);
134
+ throw new CronCliError(`${url} failed (${response.status}): ${detail}`);
135
+ }
136
+ return json;
137
+ }
138
+
139
+ async function callQuery(
140
+ ctx: CronCliContext,
141
+ fn: Parameters<ConvexHttpClient["query"]>[0],
142
+ args: Record<string, unknown> = {},
143
+ ): Promise<unknown> {
144
+ return ctx.convex.query(fn, {
145
+ ...args,
146
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
147
+ } as never);
148
+ }
149
+
150
+ async function callMutation(
151
+ ctx: CronCliContext,
152
+ fn: Parameters<ConvexHttpClient["mutation"]>[0],
153
+ args: Record<string, unknown> = {},
154
+ ): Promise<unknown> {
155
+ return ctx.convex.mutation(fn, {
156
+ ...args,
157
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
158
+ } as never);
159
+ }
160
+
161
+ // ─── Schedule flag parser ────────────────────────────────────────────────
162
+
163
+ const DURATION_RE = /^(\d+)\s*(ms|s|m|h|d)$/i;
164
+
165
+ export function parseDurationFlagToMs(input: string): number | null {
166
+ const m = DURATION_RE.exec(input.trim());
167
+ if (!m) return null;
168
+ const n = parseInt(m[1], 10);
169
+ if (!Number.isFinite(n) || n <= 0) return null;
170
+ switch (m[2].toLowerCase()) {
171
+ case "ms":
172
+ return n;
173
+ case "s":
174
+ return n * 1000;
175
+ case "m":
176
+ return n * 60_000;
177
+ case "h":
178
+ return n * 3_600_000;
179
+ case "d":
180
+ return n * 86_400_000;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ export type CronScheduleSpec =
186
+ | { kind: "every"; everyMs: number; anchorMs?: number }
187
+ | { kind: "cron"; expr: string; tz?: string }
188
+ | { kind: "at"; at: string };
189
+
190
+ /**
191
+ * Pull a schedule out of the args, accepting any of `--every`,
192
+ * `--every-ms`, `--cron`, `--at`. Returns undefined if NONE of those
193
+ * flags are present (the caller decides whether that's an error).
194
+ *
195
+ * `--at` implicitly drops `--every` / `--cron` if both are passed.
196
+ */
197
+ export function consumeScheduleFlags(args: string[]): CronScheduleSpec | undefined {
198
+ const at = getFlag(args, "--at");
199
+ if (at) {
200
+ const ts = new Date(at).getTime();
201
+ if (Number.isNaN(ts)) {
202
+ throw new CronCliError(
203
+ `--at must be ISO 8601 (got "${at}"). Example: --at 2026-05-04T17:00:00Z`,
204
+ );
205
+ }
206
+ return { kind: "at", at };
207
+ }
208
+ const cronExpr = getFlag(args, "--cron");
209
+ if (cronExpr) {
210
+ const tz = getFlag(args, "--tz");
211
+ return { kind: "cron", expr: cronExpr, tz };
212
+ }
213
+ const everyRaw = getFlag(args, "--every");
214
+ const everyMsRaw = getFlag(args, "--every-ms");
215
+ if (everyMsRaw) {
216
+ const ms = parseInt(everyMsRaw, 10);
217
+ if (!Number.isFinite(ms) || ms < 1000) {
218
+ throw new CronCliError(
219
+ `--every-ms must be a positive integer >= 1000 (got "${everyMsRaw}")`,
220
+ );
221
+ }
222
+ return { kind: "every", everyMs: ms };
223
+ }
224
+ if (everyRaw) {
225
+ const ms = parseDurationFlagToMs(everyRaw);
226
+ if (ms == null || ms < 1000) {
227
+ throw new CronCliError(
228
+ `--every must be a duration like "30s", "5m", "2h", "1d" (got "${everyRaw}")`,
229
+ );
230
+ }
231
+ return { kind: "every", everyMs: ms };
232
+ }
233
+ return undefined;
234
+ }
235
+
236
+ function formatScheduleForList(schedule: unknown): string {
237
+ if (!schedule || typeof schedule !== "object") return String(schedule ?? "?");
238
+ const s = schedule as Record<string, unknown>;
239
+ if (s.kind === "every" && typeof s.everyMs === "number") {
240
+ const ms = s.everyMs;
241
+ if (ms >= 86_400_000) return `every ${Math.round(ms / 86_400_000)}d`;
242
+ if (ms >= 3_600_000) return `every ${Math.round(ms / 3_600_000)}h`;
243
+ if (ms >= 60_000) return `every ${Math.round(ms / 60_000)}m`;
244
+ return `every ${Math.round(ms / 1000)}s`;
245
+ }
246
+ if (s.kind === "cron" && typeof s.expr === "string") {
247
+ return `cron "${s.expr}"${s.tz ? ` (${s.tz})` : ""}`;
248
+ }
249
+ if (s.kind === "at" && typeof s.at === "string") return `at ${s.at}`;
250
+ return JSON.stringify(s);
251
+ }
252
+
253
+ function formatRowsAsTable(jobs: unknown[]): string {
254
+ if (jobs.length === 0) return "(no cron jobs)";
255
+ const rows: Array<[string, string, string, string, string]> = jobs.map(
256
+ (raw) => {
257
+ const j = raw as Record<string, unknown>;
258
+ const id = String(j.id ?? "?");
259
+ const name = String(j.name ?? "");
260
+ const enabled = j.enabled === false ? "off" : "on";
261
+ const sched = formatScheduleForList(
262
+ (j as { schedule?: unknown }).schedule,
263
+ );
264
+ const next =
265
+ typeof (j.state as Record<string, unknown> | undefined)?.nextRunAtMs ===
266
+ "number"
267
+ ? new Date(
268
+ (j.state as Record<string, unknown>).nextRunAtMs as number,
269
+ ).toISOString()
270
+ : "—";
271
+ return [id, enabled, name.slice(0, 30), sched, next];
272
+ },
273
+ );
274
+ const widths = [
275
+ Math.max(...rows.map((r) => r[0].length), 8),
276
+ 3,
277
+ Math.max(...rows.map((r) => r[2].length), 4),
278
+ Math.max(...rows.map((r) => r[3].length), 8),
279
+ Math.max(...rows.map((r) => r[4].length), 24),
280
+ ];
281
+ const header = ["job id", "en", "name", "schedule", "next fire"]
282
+ .map((h, i) => h.padEnd(widths[i]))
283
+ .join(" ");
284
+ const lines = rows.map((r) =>
285
+ r.map((cell, i) => cell.padEnd(widths[i])).join(" "),
286
+ );
287
+ return [header, "-".repeat(header.length), ...lines].join("\n");
288
+ }
289
+
290
+ // ─── Subcommands ──────────────────────────────────────────────────────────
291
+
292
+ const listCronJobsRef = makeFunctionReference<"query">(
293
+ "functions/triggers:listCronJobs",
294
+ );
295
+ const getCronJobRef = makeFunctionReference<"query">(
296
+ "functions/triggers:getCronJob",
297
+ );
298
+ const listCronRunsRef = makeFunctionReference<"query">(
299
+ "functions/triggers:listCronRuns",
300
+ );
301
+ const listOrgCronRunHistoryRef = makeFunctionReference<"query">(
302
+ "functions/triggers:listOrgCronRunHistory",
303
+ );
304
+ const createCronTriggerRef = makeFunctionReference<"mutation">(
305
+ "functions/triggers:createCronTrigger",
306
+ );
307
+ const updateCronTriggerRef = makeFunctionReference<"mutation">(
308
+ "functions/triggers:updateCronTrigger",
309
+ );
310
+ const deleteCronTriggerRef = makeFunctionReference<"mutation">(
311
+ "functions/triggers:deleteCronTrigger",
312
+ );
313
+ const setCronEnabledRef = makeFunctionReference<"mutation">(
314
+ "functions/triggers:setCronEnabled",
315
+ );
316
+
317
+ async function runList(ctx: CronCliContext): Promise<void> {
318
+ const enabledOnly = hasFlag(ctx.args, "--enabled-only");
319
+ const result = (await callQuery(ctx, listCronJobsRef, { enabledOnly })) as {
320
+ jobs: unknown[];
321
+ };
322
+ if (ctx.jsonOutput) {
323
+ out(ctx, result);
324
+ return;
325
+ }
326
+ console.log(formatRowsAsTable(result.jobs ?? []));
327
+ }
328
+
329
+ async function runGet(ctx: CronCliContext): Promise<void> {
330
+ const jobId = shift(ctx.args, "job id");
331
+ const job = await callQuery(ctx, getCronJobRef, { jobId });
332
+ out(ctx, job);
333
+ }
334
+
335
+ async function runCreate(ctx: CronCliContext): Promise<void> {
336
+ const name = shift(ctx.args, "name (positional, e.g. dench cron create \"Daily standup digest\" --prompt … --every 1d)");
337
+ const prompt = getFlag(ctx.args, "--prompt");
338
+ if (!prompt || !prompt.trim()) {
339
+ throw new CronCliError("--prompt is required (becomes the user message of each cron-fired chat thread)");
340
+ }
341
+ const description = getFlag(ctx.args, "--description");
342
+ const overlapPolicy = getFlag(ctx.args, "--overlap") as
343
+ | "skip"
344
+ | "queue"
345
+ | "kill_old"
346
+ | undefined;
347
+ if (
348
+ overlapPolicy &&
349
+ !["skip", "queue", "kill_old"].includes(overlapPolicy)
350
+ ) {
351
+ throw new CronCliError(
352
+ `--overlap must be one of skip|queue|kill_old (got "${overlapPolicy}")`,
353
+ );
354
+ }
355
+ const disabled = hasFlag(ctx.args, "--disabled");
356
+ const deleteAfterRun = hasFlag(ctx.args, "--delete-after-run");
357
+ const target = getFlag(ctx.args, "--target") as
358
+ | "chat_turn"
359
+ | "agent_run"
360
+ | undefined;
361
+ if (target && !["chat_turn", "agent_run"].includes(target)) {
362
+ throw new CronCliError(
363
+ `--target must be chat_turn|agent_run (got "${target}")`,
364
+ );
365
+ }
366
+ const schedule = consumeScheduleFlags(ctx.args);
367
+ if (!schedule) {
368
+ throw new CronCliError(
369
+ "One of --every / --cron / --at is required. Examples:\n" +
370
+ " dench cron create \"Hourly health check\" --prompt 'Check uptime…' --every 1h\n" +
371
+ ' dench cron create "Weekday digest" --prompt \'Pull yesterday…\' --cron "0 9 * * 1-5"\n' +
372
+ ' dench cron create "May 4 launch" --prompt \'Send launch tweet\' --at 2026-05-04T09:00:00Z',
373
+ );
374
+ }
375
+ const result = await callMutation(ctx, createCronTriggerRef, {
376
+ name,
377
+ prompt,
378
+ schedule,
379
+ description,
380
+ overlapPolicy,
381
+ enabled: disabled ? false : undefined,
382
+ deleteAfterRun: deleteAfterRun ? true : undefined,
383
+ target,
384
+ });
385
+ out(ctx, result);
386
+ }
387
+
388
+ async function runUpdate(ctx: CronCliContext): Promise<void> {
389
+ const jobId = shift(ctx.args, "job id");
390
+ const name = getFlag(ctx.args, "--name");
391
+ const prompt = getFlag(ctx.args, "--prompt");
392
+ const description = getFlag(ctx.args, "--description");
393
+ const overlapPolicy = getFlag(ctx.args, "--overlap") as
394
+ | "skip"
395
+ | "queue"
396
+ | "kill_old"
397
+ | undefined;
398
+ const enabledRaw = getFlag(ctx.args, "--enabled");
399
+ let enabled: boolean | undefined;
400
+ if (enabledRaw !== undefined) {
401
+ if (enabledRaw === "true") enabled = true;
402
+ else if (enabledRaw === "false") enabled = false;
403
+ else {
404
+ throw new CronCliError(
405
+ `--enabled must be true|false (got "${enabledRaw}")`,
406
+ );
407
+ }
408
+ }
409
+ const schedule = consumeScheduleFlags(ctx.args);
410
+ const result = await callMutation(ctx, updateCronTriggerRef, {
411
+ jobId,
412
+ name,
413
+ prompt,
414
+ description,
415
+ overlapPolicy,
416
+ enabled,
417
+ schedule,
418
+ });
419
+ out(ctx, result);
420
+ }
421
+
422
+ async function runDelete(ctx: CronCliContext): Promise<void> {
423
+ const jobId = shift(ctx.args, "job id");
424
+ const result = await callMutation(ctx, deleteCronTriggerRef, { jobId });
425
+ out(ctx, result);
426
+ }
427
+
428
+ async function runEnable(ctx: CronCliContext, enabled: boolean): Promise<void> {
429
+ const jobId = shift(ctx.args, "job id");
430
+ const result = await callMutation(ctx, setCronEnabledRef, {
431
+ jobId,
432
+ enabled,
433
+ });
434
+ out(ctx, result);
435
+ }
436
+
437
+ async function runRunNow(ctx: CronCliContext): Promise<void> {
438
+ const jobId = shift(ctx.args, "job id");
439
+ const wait = hasFlag(ctx.args, "--wait");
440
+ const url = `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}/run-now`;
441
+ const fired = (await postJson(
442
+ url,
443
+ apiKeyHeaders(ctx.sessionToken ?? ""),
444
+ {},
445
+ )) as { runId?: string; threadId?: string | null };
446
+ if (!wait) {
447
+ out(ctx, fired);
448
+ return;
449
+ }
450
+ // Poll the run status until terminal, with a 5-minute safety timeout.
451
+ if (!fired.runId) {
452
+ out(ctx, fired);
453
+ return;
454
+ }
455
+ const start = Date.now();
456
+ const TIMEOUT_MS = 5 * 60_000;
457
+ while (Date.now() - start < TIMEOUT_MS) {
458
+ const runs = (await callQuery(ctx, listCronRunsRef, {
459
+ jobId,
460
+ limit: 5,
461
+ })) as { entries: Array<{ ts: number; status?: string }> };
462
+ const matching = runs.entries.find(() => true);
463
+ if (matching && matching.status) {
464
+ out(ctx, { ...fired, finalStatus: matching.status, durationMs: Date.now() - start });
465
+ return;
466
+ }
467
+ await new Promise((resolve) => setTimeout(resolve, 3000));
468
+ }
469
+ out(ctx, { ...fired, status: "wait_timeout", waitedMs: TIMEOUT_MS });
470
+ }
471
+
472
+ async function runRuns(ctx: CronCliContext): Promise<void> {
473
+ const jobId = shift(ctx.args, "job id");
474
+ const limitRaw = getFlag(ctx.args, "--limit");
475
+ const limit = limitRaw ? parseInt(limitRaw, 10) : 50;
476
+ const status = getFlag(ctx.args, "--status");
477
+ const result = (await callQuery(ctx, listCronRunsRef, { jobId, limit })) as {
478
+ entries: Array<Record<string, unknown>>;
479
+ };
480
+ const filtered = status
481
+ ? {
482
+ entries: result.entries.filter((e) => e.status === status),
483
+ }
484
+ : result;
485
+ out(ctx, filtered);
486
+ }
487
+
488
+ async function runHistory(ctx: CronCliContext): Promise<void> {
489
+ const limitRaw = getFlag(ctx.args, "--limit");
490
+ const limit = limitRaw ? parseInt(limitRaw, 10) : 50;
491
+ const status = getFlag(ctx.args, "--status");
492
+ const result = (await callQuery(ctx, listOrgCronRunHistoryRef, {
493
+ limit,
494
+ })) as { entries: Array<Record<string, unknown>> };
495
+ const filtered = status
496
+ ? {
497
+ entries: result.entries.filter((e) => e.status === status),
498
+ }
499
+ : result;
500
+ out(ctx, filtered);
501
+ }
502
+
503
+ // ─── Help ─────────────────────────────────────────────────────────────────
504
+
505
+ function cronHelp(): void {
506
+ console.log(`Usage: dench cron <subcommand>
507
+
508
+ Browse:
509
+ dench cron list [--enabled-only] [--json]
510
+ dench cron get <jobId> [--json]
511
+ dench cron history [--limit 50] [--status ok|error|skipped] [--json]
512
+ dench cron runs <jobId> [--limit 50] [--status ok|error|skipped] [--json]
513
+
514
+ Create / update (one of --every / --cron / --at is required for create):
515
+ dench cron create "Daily standup digest" \\
516
+ --prompt "Pull yesterday's commits + Slack #standup msgs and post a summary…" \\
517
+ --every 1d # or --cron "0 9 * * 1-5" or --at 2026-05-04T09:00:00Z
518
+ [--description "..."] # human-readable note shown in dashboard / list
519
+ [--overlap skip|queue|kill_old] # default: skip
520
+ [--disabled] # default: enabled
521
+ [--delete-after-run] # one-shot at-schedule; default false
522
+ [--target chat_turn|agent_run] # default: chat_turn (fires a fresh chat thread)
523
+ [--json]
524
+
525
+ dench cron update <jobId> [--name N] [--prompt P] [--description D] \\
526
+ [--every X | --cron EXPR | --at ISO] [--enabled true|false] [--overlap …]
527
+
528
+ Lifecycle:
529
+ dench cron enable <jobId>
530
+ dench cron disable <jobId>
531
+ dench cron delete <jobId>
532
+ dench cron run <jobId> [--wait] [--json] # manual fire; --wait blocks until run finishes
533
+
534
+ Schedule formats:
535
+ --every <duration> Repeat every N. Accepts 30s, 5m, 2h, 1d, 12h, etc.
536
+ --every-ms <number> Same, raw milliseconds (>= 1000ms).
537
+ --cron <expr> 5-field POSIX cron (minute hour day-of-month month day-of-week).
538
+ Examples: "*/30 * * * *" (every 30 min), "0 9 * * 1-5" (weekdays 9am)
539
+ --tz <iana> Optional timezone for --cron (e.g. "America/New_York"). Default UTC.
540
+ --at <iso> ISO 8601 timestamp for a one-shot fire. Auto-disables after firing.
541
+
542
+ Examples:
543
+ # Remind me to drink water every 30 min
544
+ dench cron create "Water reminder" --prompt "Send a friendly nudge to drink water." --every 30m
545
+
546
+ # Daily 9am standup digest from CRM + workspace
547
+ dench cron create "Standup digest" \\
548
+ --prompt "Pull yesterday's CRM Activity entries and post a summary to /workspace/standups/$(date +%%Y-%%m-%%d).md" \\
549
+ --cron "0 9 * * 1-5"
550
+
551
+ # One-shot launch
552
+ dench cron create "May 4 launch" \\
553
+ --prompt "Send launch tweet from @denchhq quoting today's blog post." \\
554
+ --at 2026-05-04T17:00:00Z
555
+
556
+ # Pause and re-enable
557
+ dench cron disable <jobId>
558
+ dench cron enable <jobId>
559
+
560
+ Auth: prefers DENCH_API_KEY (sandbox envVar). Falls back to a session token from
561
+ \`dench login\` for local terminals. The Bearer token must carry either
562
+ \`crons:read\` (queries) or \`crons:write\` (mutations) scope, or the wildcard \`*\`.
563
+ `);
564
+ }
565
+
566
+ // ─── Entry point ──────────────────────────────────────────────────────────
567
+
568
+ export async function runCronCommand(opts: {
569
+ convex: ConvexHttpClient;
570
+ args: string[];
571
+ sessionToken?: string;
572
+ }): Promise<void> {
573
+ const args = [...opts.args];
574
+ const jsonOutput = hasFlag(args, "--json");
575
+ const ctx: CronCliContext = {
576
+ convex: opts.convex,
577
+ args,
578
+ jsonOutput,
579
+ sessionToken: opts.sessionToken,
580
+ };
581
+ const subcommand = args.shift();
582
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
583
+ cronHelp();
584
+ return;
585
+ }
586
+ switch (subcommand) {
587
+ case "list":
588
+ return await runList(ctx);
589
+ case "get":
590
+ return await runGet(ctx);
591
+ case "create":
592
+ return await runCreate(ctx);
593
+ case "update":
594
+ return await runUpdate(ctx);
595
+ case "delete":
596
+ return await runDelete(ctx);
597
+ case "enable":
598
+ return await runEnable(ctx, true);
599
+ case "disable":
600
+ return await runEnable(ctx, false);
601
+ case "run":
602
+ return await runRunNow(ctx);
603
+ case "runs":
604
+ return await runRuns(ctx);
605
+ case "history":
606
+ return await runHistory(ctx);
607
+ default:
608
+ throw new CronCliError(
609
+ `Unknown cron subcommand: ${subcommand}. Run 'dench cron help'.`,
610
+ );
611
+ }
612
+ }