@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 +3 -2
- package/crm.ts +141 -9
- package/cron.ts +94 -17
- package/members.ts +7 -4
- package/package.json +1 -1
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 --
|
|
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
|
|
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, {
|
|
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
|
|
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
|
-
|
|
2811
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
364
|
-
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
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.
|
|
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": {
|