@dench.com/cli 2.2.5 → 2.2.7

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/README.md CHANGED
@@ -99,9 +99,10 @@ memory only when the human asks or when saving a stable workspace fact.
99
99
 
100
100
  Tasks live as CRM `task` entries — there is no separate `dench tasks`,
101
101
  `dench task`, `dench claim`, or `dench log` command. Create one via
102
- `dench crm entries create task --cells '{"Name":"…","Status":"Open"}'`
102
+ `dench crm entries create task --data '{"Name":"…","Status":"To Do"}'`
103
103
  only when the human assigns durable work or coordination benefits from
104
- a tracked entry. For ad-hoc requests, work directly.
104
+ a tracked entry (confirm field names with `dench crm fields list task`
105
+ first — unknown fields are rejected). For ad-hoc requests, work directly.
105
106
 
106
107
  Use `dench context` when you need to know who you are, which workspace you
107
108
  are using, pending approvals you requested, connected apps, and the best
package/crm.ts CHANGED
@@ -289,6 +289,7 @@ async function applyBatchOpsInChunks(
289
289
  ctx: CrmCliContext,
290
290
  ops: CrmBatchOp[],
291
291
  idempotencyKey: string | undefined,
292
+ allowUnknown = false,
292
293
  ): Promise<number> {
293
294
  const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
294
295
  for (const [chunkIndex, chunk] of chunks.entries()) {
@@ -296,11 +297,110 @@ async function applyBatchOpsInChunks(
296
297
  await callMutation(ctx, api.batch.apply, {
297
298
  ops: chunk,
298
299
  ...(chunkKey ? { idempotencyKey: chunkKey } : {}),
300
+ ...(allowUnknown ? { allowUnknown: true } : {}),
299
301
  });
300
302
  }
301
303
  return chunks.length;
302
304
  }
303
305
 
306
+ /**
307
+ * Fetch the canonical field names for an object. Best-effort: returns
308
+ * `null` (rather than throwing) on any failure so the caller falls back
309
+ * to the server's own unknown-field validation instead of blocking a
310
+ * write on a transient read error.
311
+ */
312
+ async function fetchFieldNames(
313
+ ctx: CrmCliContext,
314
+ objectName: string,
315
+ ): Promise<string[] | null> {
316
+ try {
317
+ const fields = (await callQuery(ctx, api.fields.list, { objectName })) as
318
+ | Array<{ name?: unknown }>
319
+ | null
320
+ | undefined;
321
+ if (!Array.isArray(fields)) return null;
322
+ const names = fields
323
+ .map((field) => (typeof field?.name === "string" ? field.name : null))
324
+ .filter((name): name is string => Boolean(name));
325
+ return names.length > 0 ? names : null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ function levenshteinDistance(a: string, b: string): number {
332
+ if (a === b) return 0;
333
+ if (a.length === 0) return b.length;
334
+ if (b.length === 0) return a.length;
335
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
336
+ let curr = new Array<number>(b.length + 1);
337
+ for (let i = 1; i <= a.length; i += 1) {
338
+ curr[0] = i;
339
+ for (let j = 1; j <= b.length; j += 1) {
340
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
341
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
342
+ }
343
+ [prev, curr] = [curr, prev];
344
+ }
345
+ return prev[b.length];
346
+ }
347
+
348
+ function suggestKnownField(name: string, validNames: string[]): string | null {
349
+ const lower = name.toLowerCase();
350
+ const caseMatch = validNames.find((valid) => valid.toLowerCase() === lower);
351
+ if (caseMatch) return caseMatch;
352
+ let best: string | null = null;
353
+ let bestDistance = Number.POSITIVE_INFINITY;
354
+ for (const valid of validNames) {
355
+ const distance = levenshteinDistance(lower, valid.toLowerCase());
356
+ if (distance < bestDistance) {
357
+ bestDistance = distance;
358
+ best = valid;
359
+ }
360
+ }
361
+ const threshold = Math.max(2, Math.floor(name.length / 2));
362
+ return best !== null && bestDistance <= threshold ? best : null;
363
+ }
364
+
365
+ /**
366
+ * Pre-flight check for `--data` keys against the object's real field
367
+ * schema, so a typo'd field name (e.g. `Name` on a `Title`-shaped task,
368
+ * or `Owner` instead of `Assignee`) fails fast with a "did you mean …"
369
+ * hint instead of silently dropping the value. The server enforces the
370
+ * same rule; this just gives a faster, friendlier local error. No-op
371
+ * when `--allow-unknown` is set or the schema can't be read.
372
+ */
373
+ async function assertKnownDataKeys(
374
+ ctx: CrmCliContext,
375
+ objectName: string,
376
+ rows: Array<Record<string, unknown>>,
377
+ allowUnknown: boolean,
378
+ ): Promise<void> {
379
+ if (allowUnknown) return;
380
+ const validNames = await fetchFieldNames(ctx, objectName);
381
+ if (!validNames) return;
382
+ const validSet = new Set(validNames);
383
+ const unknown = new Set<string>();
384
+ for (const row of rows) {
385
+ for (const key of Object.keys(row)) {
386
+ if (!validSet.has(key)) unknown.add(key);
387
+ }
388
+ }
389
+ if (unknown.size === 0) return;
390
+ const parts = Array.from(unknown).map((name) => {
391
+ const suggestion = suggestKnownField(name, validNames);
392
+ return suggestion
393
+ ? `"${name}" (did you mean "${suggestion}"?)`
394
+ : `"${name}"`;
395
+ });
396
+ throw new CrmCliError(
397
+ `Unknown field${unknown.size > 1 ? "s" : ""} for "${objectName}": ` +
398
+ `${parts.join(", ")}. Valid fields: ${validNames.join(", ")}. ` +
399
+ `Run \`dench crm fields list ${objectName}\` to check the schema, ` +
400
+ `or pass --allow-unknown to write the raw value anyway.`,
401
+ );
402
+ }
403
+
304
404
  async function callQuery(
305
405
  ctx: CrmCliContext,
306
406
  fn: Parameters<ConvexHttpClient["query"]>[0],
@@ -1209,6 +1309,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1209
1309
  case "create": {
1210
1310
  const objectName = shift(ctx.args, "object name");
1211
1311
  const dataRaw = getDataFlag(ctx.args);
1312
+ const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
1212
1313
  assertNoUnknownFlags(ctx.args, "crm entries create");
1213
1314
  // Refuse silent blank-row creation. The agent that triggered this
1214
1315
  // refactor passed `--fields` (which used to be silently dropped),
@@ -1226,9 +1327,16 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1226
1327
  'dench crm entries create: --data must include at least one field. Pass --data \'{"Name":"…"}\' or use cells set after creation.',
1227
1328
  );
1228
1329
  }
1330
+ // Single-row writes rely on the server's schema validation (which
1331
+ // returns the same "did you mean …" hint the CLI would). No local
1332
+ // pre-fetch — that would add a round-trip to every create.
1229
1333
  out(
1230
1334
  ctx,
1231
- await callMutation(ctx, api.entries.create, { objectName, data }),
1335
+ await callMutation(ctx, api.entries.create, {
1336
+ objectName,
1337
+ data,
1338
+ ...(allowUnknown ? { allowUnknown: true } : {}),
1339
+ }),
1232
1340
  );
1233
1341
  return;
1234
1342
  }
@@ -1236,6 +1344,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1236
1344
  const objectName = shift(ctx.args, "object name");
1237
1345
  const dataRaw = getDataFlag(ctx.args);
1238
1346
  const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
1347
+ const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
1239
1348
  const rows = parseCreateManyRows(
1240
1349
  await readJsonArrayInput({
1241
1350
  args: ctx.args,
@@ -1244,6 +1353,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1244
1353
  inlineValue: dataRaw,
1245
1354
  }),
1246
1355
  );
1356
+ await assertKnownDataKeys(ctx, objectName, rows, allowUnknown);
1247
1357
  const chunks = await applyBatchOpsInChunks(
1248
1358
  ctx,
1249
1359
  rows.map((data) => ({
@@ -1252,6 +1362,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1252
1362
  data,
1253
1363
  })),
1254
1364
  idempotencyKey,
1365
+ allowUnknown,
1255
1366
  );
1256
1367
  out(ctx, { created: rows.length, chunks });
1257
1368
  return;
@@ -1260,6 +1371,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1260
1371
  const objectName = shift(ctx.args, "object name");
1261
1372
  const entryId = shift(ctx.args, "entry id");
1262
1373
  const dataRaw = getDataFlag(ctx.args);
1374
+ const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
1263
1375
  assertNoUnknownFlags(ctx.args, "crm entries update");
1264
1376
  if (dataRaw === undefined) {
1265
1377
  throw new CrmCliError(
@@ -1272,20 +1384,24 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1272
1384
  'dench crm entries update: --data must include at least one field to change. Pass --data \'{"Field":"…"}\'.',
1273
1385
  );
1274
1386
  }
1387
+ // Single-row writes rely on the server's schema validation (same
1388
+ // "did you mean …" hint, no extra round-trip).
1275
1389
  out(
1276
1390
  ctx,
1277
1391
  await callMutation(ctx, api.entries.update, {
1278
1392
  objectName,
1279
1393
  entryId: entryId as any,
1280
1394
  data,
1395
+ ...(allowUnknown ? { allowUnknown: true } : {}),
1281
1396
  }),
1282
1397
  );
1283
1398
  return;
1284
1399
  }
1285
1400
  case "update-many": {
1286
- const _objectName = shift(ctx.args, "object name");
1401
+ const objectName = shift(ctx.args, "object name");
1287
1402
  const updatesRaw = getUpdatesFlag(ctx.args);
1288
1403
  const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
1404
+ const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
1289
1405
  const updates = parseUpdateManyRows(
1290
1406
  await readJsonArrayInput({
1291
1407
  args: ctx.args,
@@ -1294,6 +1410,12 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1294
1410
  inlineValue: updatesRaw,
1295
1411
  }),
1296
1412
  );
1413
+ await assertKnownDataKeys(
1414
+ ctx,
1415
+ objectName,
1416
+ updates.map((update) => update.data),
1417
+ allowUnknown,
1418
+ );
1297
1419
  const chunks = await applyBatchOpsInChunks(
1298
1420
  ctx,
1299
1421
  updates.map((update) => ({
@@ -1302,6 +1424,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1302
1424
  data: update.data,
1303
1425
  })),
1304
1426
  idempotencyKey,
1427
+ allowUnknown,
1305
1428
  );
1306
1429
  out(ctx, { updated: updates.length, chunks });
1307
1430
  return;
@@ -2772,15 +2895,21 @@ Fields (columns):
2772
2895
  Entries (rows):
2773
2896
  dench crm entries list <object> [--limit N] [--with-meta] [--json]
2774
2897
  dench crm entries get <object> <entryId>
2775
- dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields)
2776
- dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>]
2777
- dench crm entries update <object> <entryId> --data '{...}' (alias: --fields)
2778
- dench crm entries update-many <object> --updates '[{"id":"...","data":{...}}]' [--file updates.json] [--idempotency-key <key>]
2898
+ dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields) [--allow-unknown]
2899
+ dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>] [--allow-unknown]
2900
+ dench crm entries update <object> <entryId> --data '{...}' (alias: --fields) [--allow-unknown]
2901
+ dench crm entries update-many <object> --updates '[{"id":"...","data":{...}}]' [--file updates.json] [--idempotency-key <key>] [--allow-unknown]
2779
2902
  dench crm entries delete <object> <entryId>
2780
2903
  dench crm entries bulk-delete <object> --ids id1,id2,id3
2781
2904
  Note: --data is REQUIRED on create/update and must include >=1 field.
2782
2905
  An empty --data '{}' is rejected — use 'crm cells set' for
2783
2906
  single-field tweaks instead of creating a placeholder blank row.
2907
+ Field names are validated against the object schema: an unknown
2908
+ field (e.g. "Name" on a task that uses "Title", or "Owner" instead
2909
+ of "Assignee") is REJECTED with a "did you mean …" hint instead of
2910
+ being silently dropped. Verify names with 'crm fields list <object>'
2911
+ first. Pass --allow-unknown to write the raw value anyway, and on
2912
+ create the object's required fields must be present.
2784
2913
 
2785
2914
  Cells (single-field updates):
2786
2915
  dench crm cells get <object> <entryId> <field>
@@ -2806,9 +2935,12 @@ Workspace members (for user-type fields):
2806
2935
  List \`{ userId, role, name, email }\` for every member of the
2807
2936
  current org. Use the userIds when setting \`Owner\`, \`Assignee\`,
2808
2937
  or any other \`user\`-type field so the UI shows a real linked
2809
- avatar instead of an unlinked literal-string badge:
2810
- dench crm cells set task --entry <id> --field Owner --value <userId>
2811
- dench crm entries create task --data '{"Name":"X","Owner":"<userId>"}'
2938
+ avatar instead of an unlinked literal-string badge. Check the
2939
+ object's actual field names first (\`crm fields list <object>\`)
2940
+ the \`task\` object uses \`Assignee\`, not \`Owner\`, and some
2941
+ workspaces rename \`Name\` to \`Title\`:
2942
+ dench crm cells set task <entryId> Assignee '"<userId>"'
2943
+ dench crm entries create task --data '{"Name":"X","Assignee":"<userId>"}'
2812
2944
 
2813
2945
  Batch:
2814
2946
  dench crm batch --file ops.jsonl [--idempotency-key <key>]
package/cron.ts CHANGED
@@ -33,6 +33,7 @@
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).
36
37
  * runs <jobId> [--limit N] [--status] Run history per trigger.
37
38
  * history [--limit N] Flat history across all crons.
38
39
  * help Print this help.
@@ -196,7 +197,9 @@ export type CronScheduleSpec =
196
197
  *
197
198
  * `--at` implicitly drops `--every` / `--cron` if both are passed.
198
199
  */
199
- export function consumeScheduleFlags(args: string[]): CronScheduleSpec | undefined {
200
+ export function consumeScheduleFlags(
201
+ args: string[],
202
+ ): CronScheduleSpec | undefined {
200
203
  const at = getFlag(args, "--at");
201
204
  if (at) {
202
205
  const ts = new Date(at).getTime();
@@ -335,10 +338,15 @@ async function runGet(ctx: CronCliContext): Promise<void> {
335
338
  }
336
339
 
337
340
  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)");
341
+ const name = shift(
342
+ ctx.args,
343
+ 'name (positional, e.g. dench cron create "Daily standup digest" --prompt … --every 1d)',
344
+ );
339
345
  const prompt = getFlag(ctx.args, "--prompt");
340
346
  if (!prompt || !prompt.trim()) {
341
- throw new CronCliError("--prompt is required (becomes the user message of each cron-fired chat thread)");
347
+ throw new CronCliError(
348
+ "--prompt is required (becomes the user message of each cron-fired chat thread)",
349
+ );
342
350
  }
343
351
  const description = getFlag(ctx.args, "--description");
344
352
  const overlapPolicy = getFlag(ctx.args, "--overlap") as
@@ -346,10 +354,7 @@ async function runCreate(ctx: CronCliContext): Promise<void> {
346
354
  | "queue"
347
355
  | "kill_old"
348
356
  | undefined;
349
- if (
350
- overlapPolicy &&
351
- !["skip", "queue", "kill_old"].includes(overlapPolicy)
352
- ) {
357
+ if (overlapPolicy && !["skip", "queue", "kill_old"].includes(overlapPolicy)) {
353
358
  throw new CronCliError(
354
359
  `--overlap must be one of skip|queue|kill_old (got "${overlapPolicy}")`,
355
360
  );
@@ -360,26 +365,86 @@ async function runCreate(ctx: CronCliContext): Promise<void> {
360
365
  if (target && target !== "chat_turn") {
361
366
  throw new CronCliError(`--target must be chat_turn (got "${target}")`);
362
367
  }
363
- const schedule = consumeScheduleFlags(ctx.args);
364
- if (!schedule) {
368
+ const triggerType = (getFlag(ctx.args, "--type") ?? "schedule") as
369
+ | "schedule"
370
+ | "webhook";
371
+ if (triggerType !== "schedule" && triggerType !== "webhook") {
365
372
  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',
373
+ `--type must be schedule|webhook (got "${triggerType}"). app-event / CRM automations are created from the dashboard.`,
370
374
  );
371
375
  }
372
- const result = await callMutation(ctx, createCronTriggerRef, {
376
+ const isWebhook = triggerType === "webhook";
377
+
378
+ let schedule: CronScheduleSpec | undefined;
379
+ if (isWebhook) {
380
+ // Webhook automations fire on demand / via their signed URL — no schedule.
381
+ if (consumeScheduleFlags(ctx.args)) {
382
+ throw new CronCliError(
383
+ "Webhook automations don't take a schedule. Omit --every/--cron/--at.",
384
+ );
385
+ }
386
+ } else {
387
+ schedule = consumeScheduleFlags(ctx.args);
388
+ if (!schedule) {
389
+ throw new CronCliError(
390
+ "One of --every / --cron / --at is required for schedule automations. Examples:\n" +
391
+ " dench cron create \"Hourly health check\" --prompt 'Check uptime…' --every 1h\n" +
392
+ ' dench cron create "Weekday digest" --prompt \'Pull yesterday…\' --cron "0 9 * * 1-5"\n' +
393
+ " dench cron create \"Inbound lead\" --prompt 'Triage the lead…' --type webhook",
394
+ );
395
+ }
396
+ }
397
+ const created = (await callMutation(ctx, createCronTriggerRef, {
373
398
  name,
374
399
  prompt,
400
+ triggerType,
375
401
  schedule,
376
402
  description,
377
403
  overlapPolicy,
378
404
  enabled: disabled ? false : undefined,
379
405
  deleteAfterRun: deleteAfterRun ? true : undefined,
380
406
  target,
407
+ })) as { jobId?: string };
408
+
409
+ // Webhook automations need a signing secret to be externally callable. Mint
410
+ // one now (unless --no-secret) so `create` hands back a working URL+secret.
411
+ if (isWebhook && !hasFlag(ctx.args, "--no-secret") && created.jobId) {
412
+ const secret = await mintAutomationSecret(ctx, created.jobId);
413
+ out(ctx, { ...created, ...secret });
414
+ return;
415
+ }
416
+ out(ctx, created);
417
+ }
418
+
419
+ /**
420
+ * Mint (or rotate) a signing secret for an automation via the Next REST route
421
+ * (secret encryption runs in the Node route, not Convex's V8 runtime). Returns
422
+ * the plaintext secret + callable URL — shown once.
423
+ */
424
+ async function mintAutomationSecret(
425
+ ctx: CronCliContext,
426
+ jobId: string,
427
+ ): Promise<{ webhookUrl: string | null; webhookSecret: string | null }> {
428
+ const url = `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}/regenerate-secret`;
429
+ const res = (await postJson(
430
+ url,
431
+ apiKeyHeaders(ctx.sessionToken ?? ""),
432
+ {},
433
+ )) as { webhookUrl?: string | null; webhookSecret?: string | null };
434
+ return {
435
+ webhookUrl: res.webhookUrl ?? null,
436
+ webhookSecret: res.webhookSecret ?? null,
437
+ };
438
+ }
439
+
440
+ async function runSecret(ctx: CronCliContext): Promise<void> {
441
+ const jobId = shift(ctx.args, "job id");
442
+ const secret = await mintAutomationSecret(ctx, jobId);
443
+ out(ctx, {
444
+ jobId,
445
+ ...secret,
446
+ note: "Secret shown once — store it now. Re-running rotates it (invalidates the old one).",
381
447
  });
382
- out(ctx, result);
383
448
  }
384
449
 
385
450
  async function runUpdate(ctx: CronCliContext): Promise<void> {
@@ -458,7 +523,11 @@ async function runRunNow(ctx: CronCliContext): Promise<void> {
458
523
  })) as { entries: Array<{ ts: number; status?: string }> };
459
524
  const matching = runs.entries.find(() => true);
460
525
  if (matching && matching.status) {
461
- out(ctx, { ...fired, finalStatus: matching.status, durationMs: Date.now() - start });
526
+ out(ctx, {
527
+ ...fired,
528
+ finalStatus: matching.status,
529
+ durationMs: Date.now() - start,
530
+ });
462
531
  return;
463
532
  }
464
533
  await new Promise((resolve) => setTimeout(resolve, 3000));
@@ -510,9 +579,10 @@ Browse:
510
579
  dench cron history [--limit 50] [--status ok|error|skipped] [--json]
511
580
  dench cron runs <jobId> [--limit 50] [--status ok|error|skipped] [--json]
512
581
 
513
- Create / update (one of --every / --cron / --at is required for create):
582
+ Create / update:
514
583
  dench cron create "Daily standup digest" \\
515
584
  --prompt "Pull yesterday's commits + Slack #standup msgs and post a summary…" \\
585
+ --type schedule # default; or --type webhook (no schedule)
516
586
  --every 1d # or --cron "0 9 * * 1-5" or --at 2026-05-04T09:00:00Z
517
587
  [--description "..."] # human-readable note shown in dashboard / list
518
588
  [--overlap skip|queue|kill_old] # default: skip
@@ -521,6 +591,10 @@ Create / update (one of --every / --cron / --at is required for create):
521
591
  [--target chat_turn] # default: chat_turn (fires a fresh chat thread)
522
592
  [--json]
523
593
 
594
+ # Webhook automation (fires on demand or via a signed URL). Mints + prints a
595
+ # signing secret unless --no-secret. The secret is shown ONCE.
596
+ dench cron create "Inbound lead triage" --prompt "Triage the lead…" --type webhook
597
+
524
598
  dench cron update <jobId> [--name N] [--prompt P] [--description D] \\
525
599
  [--every X | --cron EXPR | --at ISO] [--enabled true|false] [--overlap …]
526
600
 
@@ -529,6 +603,7 @@ Lifecycle:
529
603
  dench cron disable <jobId>
530
604
  dench cron delete <jobId>
531
605
  dench cron run <jobId> [--wait] [--json] # manual fire; --wait blocks until run finishes
606
+ dench cron secret <jobId> [--json] # mint/rotate a signing secret → callable signed URL (any automation type)
532
607
 
533
608
  Schedule formats:
534
609
  --every <duration> Repeat every N. Accepts 30s, 5m, 2h, 1d, 12h, etc.
@@ -599,6 +674,8 @@ export async function runCronCommand(opts: {
599
674
  return await runEnable(ctx, false);
600
675
  case "run":
601
676
  return await runRunNow(ctx);
677
+ case "secret":
678
+ return await runSecret(ctx);
602
679
  case "runs":
603
680
  return await runRuns(ctx);
604
681
  case "history":
package/members.ts CHANGED
@@ -181,12 +181,15 @@ Alias:
181
181
  Same surface, dispatched from inside the \`dench crm\` namespace
182
182
  since the canonical use case is assigning CRM \`user\` fields.
183
183
 
184
- After picking a userId:
185
- dench crm cells set task --entry <entryId> --field Owner --value <userId>
186
- dench crm entries create task --data '{"Name":"Ship CRM","Owner":"<userId>"}'
184
+ After picking a userId (confirm field names with
185
+ \`dench crm fields list <object>\` the default \`task\` uses
186
+ \`Assignee\`, not \`Owner\`, and some workspaces rename \`Name\` to \`Title\`):
187
+ dench crm cells set task <entryId> Assignee '"<userId>"'
188
+ dench crm entries create task --data '{"Name":"Ship CRM","Assignee":"<userId>"}'
187
189
 
188
190
  The server best-effort-resolves a name / email passed to a \`user\`
189
191
  field when the match against the roster is unique, but the explicit
190
- lookup via \`dench members list\` is the correct pattern.
192
+ lookup via \`dench members list\` is the correct pattern. Unknown field
193
+ names are rejected on write (pass --allow-unknown to bypass).
191
194
  `);
192
195
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.2.5",
3
+ "version": "2.2.7",
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": {