@dench.com/cli 2.5.2 → 2.7.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.
package/crm.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * The full surface mirrors the plan's "dench-cli CRM surface" section:
18
18
  * crm objects list / get / create / update / delete / rename
19
- * crm views list / get / entries
19
+ * crm views list / get / entries / create / update / delete
20
20
  * crm fields list / create / update / delete / reorder
21
21
  * crm entries list / get / create / create-many / update / update-many / delete / bulk-delete
22
22
  * crm cells get / set / set-many / append
@@ -562,6 +562,12 @@ const api = {
562
562
  update: makeFunctionReference<"mutation">("functions/crm/objects:update"),
563
563
  rename: makeFunctionReference<"mutation">("functions/crm/objects:rename"),
564
564
  remove: makeFunctionReference<"mutation">("functions/crm/objects:remove"),
565
+ saveView: makeFunctionReference<"mutation">(
566
+ "functions/crm/objects:saveView",
567
+ ),
568
+ deleteView: makeFunctionReference<"mutation">(
569
+ "functions/crm/objects:deleteView",
570
+ ),
565
571
  },
566
572
  fields: {
567
573
  list: makeFunctionReference<"query">("functions/crm/fields:list"),
@@ -968,6 +974,216 @@ function summarizeSavedView(view: SavedViewForCli): Record<string, unknown> {
968
974
  };
969
975
  }
970
976
 
977
+ const VIEW_TYPES = [
978
+ "table",
979
+ "kanban",
980
+ "calendar",
981
+ "timeline",
982
+ "gallery",
983
+ "list",
984
+ ] as const;
985
+
986
+ type SortRuleForCli = { field: string; direction: "asc" | "desc" };
987
+
988
+ /**
989
+ * Parse `--sort` for views. Accepts either the canonical JSON array
990
+ * (`[{"field":"Score","direction":"desc"}]`) or the compact syntax the
991
+ * rest of the CRM CLI uses (`-Score,Name` — leading `-` means desc).
992
+ */
993
+ function parseViewSortFlag(
994
+ raw: string | undefined,
995
+ ): SortRuleForCli[] | undefined {
996
+ if (raw === undefined) return undefined;
997
+ const trimmed = raw.trim();
998
+ if (!trimmed) return undefined;
999
+ if (trimmed.startsWith("[")) {
1000
+ const parsed = parseJson(trimmed);
1001
+ if (!Array.isArray(parsed)) {
1002
+ throw new CrmCliError("--sort must be a JSON array of sort rules");
1003
+ }
1004
+ return parsed.map((rule, index) => {
1005
+ if (
1006
+ !isJsonRecord(rule) ||
1007
+ typeof rule.field !== "string" ||
1008
+ !rule.field ||
1009
+ (rule.direction !== "asc" && rule.direction !== "desc")
1010
+ ) {
1011
+ throw new CrmCliError(
1012
+ `--sort rule ${index + 1} must look like {"field":"Score","direction":"desc"}`,
1013
+ );
1014
+ }
1015
+ return { field: rule.field, direction: rule.direction };
1016
+ });
1017
+ }
1018
+ return trimmed
1019
+ .split(",")
1020
+ .map((token) => token.trim())
1021
+ .filter(Boolean)
1022
+ .map((token) =>
1023
+ token.startsWith("-")
1024
+ ? { field: token.slice(1), direction: "desc" as const }
1025
+ : { field: token, direction: "asc" as const },
1026
+ );
1027
+ }
1028
+
1029
+ /**
1030
+ * Turn the `--where '{"Status":"Hot"}'` equality-map sugar into a real
1031
+ * FilterGroup. The operator per rule is chosen from the object's field
1032
+ * schema (enum → is / is_any_of, boolean → is_true / is_false, number
1033
+ * → eq, date → on, relation → has_any, everything else → equals) so
1034
+ * the saved view round-trips through the savedViewValidator.
1035
+ */
1036
+ async function buildFiltersFromWhere(
1037
+ ctx: CrmCliContext,
1038
+ objectName: string,
1039
+ where: JsonRecord,
1040
+ ): Promise<JsonRecord> {
1041
+ const fields = (await callQuery(ctx, api.fields.list, { objectName })) as
1042
+ | Array<{ name?: unknown; type?: unknown }>
1043
+ | null
1044
+ | undefined;
1045
+ const knownFields = Array.isArray(fields)
1046
+ ? fields.filter(
1047
+ (field): field is { name: string; type?: string } =>
1048
+ typeof field?.name === "string",
1049
+ )
1050
+ : [];
1051
+ const rules = Object.entries(where).map(([rawName, value], index) => {
1052
+ const field =
1053
+ knownFields.find((candidate) => candidate.name === rawName) ??
1054
+ knownFields.find(
1055
+ (candidate) => candidate.name.toLowerCase() === rawName.toLowerCase(),
1056
+ );
1057
+ if (!field && knownFields.length > 0) {
1058
+ const suggestion = suggestKnownField(
1059
+ rawName,
1060
+ knownFields.map((candidate) => candidate.name),
1061
+ );
1062
+ throw new CrmCliError(
1063
+ `Unknown field "${rawName}" on ${objectName}` +
1064
+ `${suggestion ? ` (did you mean "${suggestion}"?)` : ""}. ` +
1065
+ `Valid fields: ${knownFields.map((candidate) => candidate.name).join(", ")}`,
1066
+ );
1067
+ }
1068
+ const fieldName = field?.name ?? rawName;
1069
+ const type = field?.type;
1070
+ const id = `w${index + 1}`;
1071
+ if (type === "boolean") {
1072
+ const isFalsy =
1073
+ !value ||
1074
+ value === "false" ||
1075
+ value === "0" ||
1076
+ value === "no" ||
1077
+ value === "off";
1078
+ return {
1079
+ id,
1080
+ field: fieldName,
1081
+ operator: isFalsy ? "is_false" : "is_true",
1082
+ };
1083
+ }
1084
+ if (type === "enum") {
1085
+ return Array.isArray(value)
1086
+ ? {
1087
+ id,
1088
+ field: fieldName,
1089
+ operator: "is_any_of",
1090
+ value: value.map(String),
1091
+ }
1092
+ : { id, field: fieldName, operator: "is", value: String(value) };
1093
+ }
1094
+ if (type === "number") {
1095
+ return { id, field: fieldName, operator: "eq", value: Number(value) };
1096
+ }
1097
+ if (type === "date") {
1098
+ return { id, field: fieldName, operator: "on", value: String(value) };
1099
+ }
1100
+ if (type === "relation") {
1101
+ return {
1102
+ id,
1103
+ field: fieldName,
1104
+ operator: "has_any",
1105
+ value: Array.isArray(value) ? value.map(String) : [String(value)],
1106
+ };
1107
+ }
1108
+ return { id, field: fieldName, operator: "equals", value: String(value) };
1109
+ });
1110
+ return { id: "where", conjunction: "and", rules };
1111
+ }
1112
+
1113
+ /**
1114
+ * Shared flag parsing for `crm views create` / `crm views update`.
1115
+ * Returns only the keys the caller actually passed so `update` can
1116
+ * merge over the existing view without clobbering untouched keys.
1117
+ */
1118
+ async function collectViewFlags(
1119
+ ctx: CrmCliContext,
1120
+ objectName: string,
1121
+ command: string,
1122
+ ): Promise<JsonRecord> {
1123
+ const filtersRaw = getFlag(ctx.args, "--filters");
1124
+ const whereRaw = getFlag(ctx.args, "--where");
1125
+ const sortRaw = getFlag(ctx.args, "--sort");
1126
+ const columnsRaw = getFlag(ctx.args, "--columns");
1127
+ const typeRaw = getFlag(ctx.args, "--type");
1128
+ const settingsRaw = getFlag(ctx.args, "--settings");
1129
+ const pinnedRaw = getFlag(ctx.args, "--pinned-entry-ids");
1130
+
1131
+ if (filtersRaw !== undefined && whereRaw !== undefined) {
1132
+ throw new CrmCliError(
1133
+ `${command} accepts either --filters (full FilterGroup JSON) or --where (equality map), not both`,
1134
+ );
1135
+ }
1136
+
1137
+ const patch: JsonRecord = {};
1138
+ if (filtersRaw !== undefined) {
1139
+ const filters = parseJson(filtersRaw);
1140
+ if (
1141
+ !isJsonRecord(filters) ||
1142
+ typeof filters.conjunction !== "string" ||
1143
+ !Array.isArray(filters.rules)
1144
+ ) {
1145
+ throw new CrmCliError(
1146
+ '--filters must be a FilterGroup JSON object: {"id":"g1","conjunction":"and","rules":[{"id":"r1","field":"Status","operator":"is","value":"Hot"}]}',
1147
+ );
1148
+ }
1149
+ if (typeof filters.id !== "string" || !filters.id) filters.id = "g1";
1150
+ patch.filters = filters;
1151
+ }
1152
+ if (whereRaw !== undefined) {
1153
+ const where = requireNonEmptyJsonRecord(parseJson(whereRaw), "--where");
1154
+ patch.filters = await buildFiltersFromWhere(ctx, objectName, where);
1155
+ }
1156
+ const sort = parseViewSortFlag(sortRaw);
1157
+ if (sort !== undefined) patch.sort = sort;
1158
+ if (columnsRaw !== undefined) {
1159
+ patch.columns = columnsRaw
1160
+ .split(",")
1161
+ .map((column) => column.trim())
1162
+ .filter(Boolean);
1163
+ }
1164
+ if (typeRaw !== undefined) {
1165
+ if (!(VIEW_TYPES as readonly string[]).includes(typeRaw)) {
1166
+ throw new CrmCliError(
1167
+ `--type must be one of: ${VIEW_TYPES.join(", ")} (got "${typeRaw}")`,
1168
+ );
1169
+ }
1170
+ patch.view_type = typeRaw;
1171
+ }
1172
+ if (settingsRaw !== undefined) {
1173
+ patch.settings = requireNonEmptyJsonRecord(
1174
+ parseJson(settingsRaw),
1175
+ "--settings",
1176
+ );
1177
+ }
1178
+ if (pinnedRaw !== undefined) {
1179
+ patch.pinnedEntryIds = pinnedRaw
1180
+ .split(",")
1181
+ .map((id) => id.trim())
1182
+ .filter(Boolean);
1183
+ }
1184
+ return patch;
1185
+ }
1186
+
971
1187
  async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
972
1188
  const verb = ctx.args.shift();
973
1189
  switch (verb) {
@@ -1071,6 +1287,82 @@ async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
1071
1287
  });
1072
1288
  return;
1073
1289
  }
1290
+ case "create": {
1291
+ const objectName = shift(ctx.args, "object name");
1292
+ // View name is positional, but `--name` is accepted too because
1293
+ // that's the flag agents reach for first.
1294
+ const nameFlag = getFlag(ctx.args, "--name");
1295
+ const viewName =
1296
+ nameFlag ??
1297
+ (ctx.args[0] && !ctx.args[0].startsWith("--")
1298
+ ? shift(ctx.args, "view name")
1299
+ : undefined);
1300
+ if (!viewName?.trim()) {
1301
+ throw new CrmCliError(
1302
+ 'crm views create requires a view name: dench crm views create <object> "<view name>" [--where …]',
1303
+ );
1304
+ }
1305
+ const replace = hasFlag(ctx.args, "--replace");
1306
+ const setActive = hasFlag(ctx.args, "--set-active");
1307
+ const patch = await collectViewFlags(ctx, objectName, "crm views create");
1308
+ assertNoUnknownFlags(ctx.args, "crm views create");
1309
+ out(
1310
+ ctx,
1311
+ await callMutation(ctx, api.objects.saveView, {
1312
+ objectName,
1313
+ view: { name: viewName.trim(), ...patch },
1314
+ replaceExisting: replace,
1315
+ setActive,
1316
+ }),
1317
+ );
1318
+ return;
1319
+ }
1320
+ case "update": {
1321
+ const objectName = shift(ctx.args, "object name");
1322
+ const viewName = shift(ctx.args, "view name");
1323
+ const setActive = hasFlag(ctx.args, "--set-active");
1324
+ const patch = await collectViewFlags(ctx, objectName, "crm views update");
1325
+ assertNoUnknownFlags(ctx.args, "crm views update");
1326
+ if (Object.keys(patch).length === 0 && !setActive) {
1327
+ throw new CrmCliError(
1328
+ "crm views update needs at least one change: --filters/--where, --sort, --columns, --type, --settings, --pinned-entry-ids, or --set-active",
1329
+ );
1330
+ }
1331
+ // Merge over the existing view so untouched keys survive. The
1332
+ // existing (exact-cased) name is reused, which also makes a
1333
+ // case-insensitive <view name> argument update the right view.
1334
+ const object = await callQuery(ctx, api.objects.get, {
1335
+ name: objectName,
1336
+ });
1337
+ const existing = resolveSavedView(
1338
+ objectName,
1339
+ savedViewsFromObject(objectName, object),
1340
+ viewName,
1341
+ );
1342
+ out(
1343
+ ctx,
1344
+ await callMutation(ctx, api.objects.saveView, {
1345
+ objectName,
1346
+ view: { ...existing, ...patch, name: readViewName(existing) },
1347
+ replaceExisting: true,
1348
+ setActive,
1349
+ }),
1350
+ );
1351
+ return;
1352
+ }
1353
+ case "delete": {
1354
+ const objectName = shift(ctx.args, "object name");
1355
+ const viewName = shift(ctx.args, "view name");
1356
+ assertNoUnknownFlags(ctx.args, "crm views delete");
1357
+ out(
1358
+ ctx,
1359
+ await callMutation(ctx, api.objects.deleteView, {
1360
+ objectName,
1361
+ viewName,
1362
+ }),
1363
+ );
1364
+ return;
1365
+ }
1074
1366
  default:
1075
1367
  throw new CrmCliError(`Unknown crm views verb: ${verb ?? "<none>"}`);
1076
1368
  }
@@ -3000,6 +3292,20 @@ Views (saved CRM subsets):
3000
3292
  dench crm views list <object> [--json]
3001
3293
  dench crm views get <object> <view-name> [--json]
3002
3294
  dench crm views entries <object> <view-name> [--limit N] [--page N] [--with-meta] [--json]
3295
+ dench crm views create <object> <view-name> [--where '{"Status":"Hot"}']
3296
+ [--filters '<FilterGroup JSON>'] [--sort '-Score' | --sort '[{"field":"Score","direction":"desc"}]']
3297
+ [--columns Name,Email] [--type table|kanban|calendar|timeline|gallery|list]
3298
+ [--settings '{"kanbanField":"Status"}'] [--pinned-entry-ids id1,id2]
3299
+ [--replace] [--set-active] [--json]
3300
+ --where is equality-map sugar (operators picked from the field
3301
+ schema); --filters takes a full FilterGroup for AND/OR logic.
3302
+ Fails on a name collision unless --replace is passed.
3303
+ dench crm views update <object> <view-name> [same flags as create] [--set-active]
3304
+ Merges the provided flags over the existing view definition;
3305
+ untouched keys survive.
3306
+ dench crm views delete <object> <view-name>
3307
+ Idempotent; also clears the active-view pointer if it referenced
3308
+ the deleted view.
3003
3309
 
3004
3310
  Fields (columns):
3005
3311
  dench crm fields list <object> [--json]
package/cron.ts CHANGED
@@ -18,11 +18,9 @@
18
18
  * both formats; we just pass whichever bearer the parent runtime
19
19
  * resolved through `sessionToken`.
20
20
  *
21
- * Convex calls go direct via `ConvexHttpClient` (same pattern as
22
- * `cli/crm.ts`); `run-now` is the one operation that goes through
23
- * the REST API hop (`POST /api/cron/jobs/<id>/run-now`) because it
24
- * needs to start a Vercel Workflow which can only be done from a
25
- * Next.js route.
21
+ * Convex calls generally go direct via `ConvexHttpClient` (same pattern as
22
+ * `cli/crm.ts`). Run-now and delete go through REST: run-now starts a Vercel
23
+ * Workflow, while delete coordinates upstream app-event cleanup.
26
24
  *
27
25
  * Subcommands:
28
26
  * list List all cron jobs in the org.
@@ -166,6 +164,28 @@ async function getJson(
166
164
  return json;
167
165
  }
168
166
 
167
+ async function deleteJson(
168
+ url: string,
169
+ headers: Record<string, string>,
170
+ ): Promise<unknown> {
171
+ const response = await fetch(url, { method: "DELETE", headers });
172
+ const text = await response.text();
173
+ let json: unknown;
174
+ try {
175
+ json = text ? JSON.parse(text) : null;
176
+ } catch {
177
+ json = { raw: text };
178
+ }
179
+ if (!response.ok) {
180
+ const detail =
181
+ json && typeof json === "object" && "error" in json
182
+ ? String((json as Record<string, unknown>).error)
183
+ : JSON.stringify(json);
184
+ throw new CronCliError(`${url} failed (${response.status}): ${detail}`);
185
+ }
186
+ return json;
187
+ }
188
+
169
189
  async function callQuery(
170
190
  ctx: CronCliContext,
171
191
  fn: Parameters<ConvexHttpClient["query"]>[0],
@@ -339,9 +359,6 @@ const createCronTriggerRef = makeFunctionReference<"mutation">(
339
359
  const updateCronTriggerRef = makeFunctionReference<"mutation">(
340
360
  "functions/triggers:updateCronTrigger",
341
361
  );
342
- const deleteCronTriggerRef = makeFunctionReference<"mutation">(
343
- "functions/triggers:deleteCronTrigger",
344
- );
345
362
  const setCronEnabledRef = makeFunctionReference<"mutation">(
346
363
  "functions/triggers:setCronEnabled",
347
364
  );
@@ -709,7 +726,10 @@ async function runUpdate(ctx: CronCliContext): Promise<void> {
709
726
 
710
727
  async function runDelete(ctx: CronCliContext): Promise<void> {
711
728
  const jobId = shift(ctx.args, "job id");
712
- const result = await callMutation(ctx, deleteCronTriggerRef, { jobId });
729
+ const result = await deleteJson(
730
+ `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}`,
731
+ apiKeyHeaders(ctx.sessionToken ?? ""),
732
+ );
713
733
  out(ctx, result);
714
734
  }
715
735
 
package/email.ts CHANGED
@@ -196,6 +196,9 @@ Usage:
196
196
  dench email identity status --identity-id <identityId> [--json]
197
197
  dench email identity disable --identity <identityId|email|domain> [--json]
198
198
  dench email identity restore --identity <identityId|email|domain> [--json]
199
+ dench email identity delete --identity <identityId|email|domain> [--json]
200
+ dench email identity forward --identity <identityId|email> --to inbox@you.com [--json] # forward this sender's sequence replies
201
+ dench email identity forward --identity <identityId|email> --off [--json] # turn forwarding off
199
202
  dench email send --from founder@example.com --to "Ada <ada@x.com>,bob@y.com" --subject "Hi" (--html-file body.html | --text "...") [--cc ...] [--bcc ...] [--from-name "Ada"] [--reply-to support@x.com] [--reply-message-id <messageId|rfc-id>] [--json]
200
203
  dench email message status --message <messageId> [--json]
201
204
  dench email message list [--limit 25] [--json]
@@ -499,19 +502,22 @@ async function runIdentity(ctx: EmailCliContext) {
499
502
  `${identity.identityId} ${identity.fromEmail} type=${identity.type}` +
500
503
  `${identity.domain ? ` domain=${identity.domain}` : ""}` +
501
504
  ` delivery=${identity.deliveryVerificationStatus} workspace=${identity.denchVerificationStatus}` +
505
+ `${identity.forwardRepliesTo ? ` forward=${identity.forwardRepliesTo}` : ""}` +
502
506
  `${identity.disabled ? " DISABLED" : ""}`,
503
507
  )
504
508
  .join("\n");
505
509
  out(ctx, { ok: true, identities }, text);
506
510
  return;
507
511
  }
508
- if (sub === "disable" || sub === "restore") {
512
+ if (sub === "disable" || sub === "restore" || sub === "delete") {
509
513
  const identity = requiredFlag(ctx.args, "--identity");
510
514
  const payload = (await callMutation(
511
515
  ctx,
512
516
  sub === "disable"
513
517
  ? "functions/emailCampaigns:disableIdentity"
514
- : "functions/emailCampaigns:restoreIdentity",
518
+ : sub === "restore"
519
+ ? "functions/emailCampaigns:restoreIdentity"
520
+ : "functions/emailCampaigns:deleteIdentity",
515
521
  { identity },
516
522
  )) as JsonRecord;
517
523
  const rows = (
@@ -519,18 +525,57 @@ async function runIdentity(ctx: EmailCliContext) {
519
525
  ? payload.disabled
520
526
  : Array.isArray(payload.restored)
521
527
  ? payload.restored
522
- : []
528
+ : Array.isArray(payload.deleted)
529
+ ? payload.deleted
530
+ : []
523
531
  ) as JsonRecord[];
524
- const verb = sub === "disable" ? "Disabled" : "Restored";
532
+ const verb =
533
+ sub === "disable"
534
+ ? "Disabled"
535
+ : sub === "restore"
536
+ ? "Restored"
537
+ : "Deleted";
538
+ const suffix =
539
+ sub === "delete" && rows.length > 0
540
+ ? "\nDeleted senders must be re-verified before they can send again."
541
+ : "";
525
542
  const text =
526
543
  rows.length === 0
527
544
  ? `${verb} 0 identities.`
528
545
  : `${verb} ${rows.length} sender(s):\n${rows
529
546
  .map((row) => ` ${row.fromEmail} (${row.identityId})`)
530
- .join("\n")}`;
547
+ .join("\n")}${suffix}`;
531
548
  out(ctx, payload, text);
532
549
  return;
533
550
  }
551
+ if (sub === "forward") {
552
+ // Configure where a sender's managed sequence replies are forwarded.
553
+ // `--to <email>` turns it on (composed from the verified sender, Reply-To =
554
+ // the prospect); `--off` clears it. Replies are always still captured
555
+ // in-app regardless — forwarding is an extra copy to a chosen inbox.
556
+ const identity = requiredFlag(ctx.args, "--identity");
557
+ const off = hasFlag(ctx.args, "--off");
558
+ const to = off ? "" : (getFlag(ctx.args, "--to")?.trim() ?? "");
559
+ if (!off && !to) {
560
+ throw new Error(
561
+ "Provide --to <email> to forward this sender's replies, or --off to turn forwarding off.",
562
+ );
563
+ }
564
+ const payload = (await callMutation(
565
+ ctx,
566
+ "functions/emailCampaigns:setSenderReplyForward",
567
+ { identity, forwardRepliesTo: to },
568
+ )) as JsonRecord;
569
+ const forwardTo =
570
+ typeof payload.forwardRepliesTo === "string"
571
+ ? payload.forwardRepliesTo
572
+ : null;
573
+ const text = forwardTo
574
+ ? `Replies to ${identity}'s sequences will be forwarded to ${forwardTo}.`
575
+ : `Reply forwarding turned off for ${identity}.`;
576
+ out(ctx, { ok: true, ...payload }, text);
577
+ return;
578
+ }
534
579
  if (sub === "dns") {
535
580
  const identity = requiredFlag(ctx.args, "--identity");
536
581
  if (!identity.includes("@") && !identity.includes(".")) {
@@ -627,7 +672,7 @@ async function runIdentity(ctx: EmailCliContext) {
627
672
  return;
628
673
  }
629
674
  throw new EmailCliError(
630
- "Usage: dench email identity verify|list|dns|status|disable|restore",
675
+ "Usage: dench email identity verify|list|dns|status|disable|restore|delete",
631
676
  );
632
677
  }
633
678
 
@@ -440,6 +440,51 @@ const chatSpawnResponse = z.object({
440
440
 
441
441
  // -- crm ---------------------------------------------------------------------
442
442
 
443
+ const savedViewSortRule = z.object({
444
+ field: z.string().describe("Field name to sort by."),
445
+ direction: z.enum(["asc", "desc"]),
446
+ });
447
+
448
+ /**
449
+ * SavedView definition as stored in `crmObjects.savedViews` and
450
+ * validated by `convex/lib/savedView.ts`. `filters` is a FilterGroup:
451
+ * `{id, conjunction: "and"|"or", rules: [{id, field, operator,
452
+ * value?}]}` with one level of nested groups allowed.
453
+ */
454
+ const savedViewDefinition = z
455
+ .object({
456
+ name: z
457
+ .string()
458
+ .min(1)
459
+ .max(100)
460
+ .describe("View name (upsert key; shows in the view dropdown)."),
461
+ view_type: viewKind.optional(),
462
+ filters: z
463
+ .record(z.string(), z.unknown())
464
+ .optional()
465
+ .describe(
466
+ 'FilterGroup, e.g. {"id":"g1","conjunction":"and","rules":[{"id":"r1","field":"Status","operator":"is","value":"Hot"}]}.',
467
+ ),
468
+ sort: z.array(savedViewSortRule).optional(),
469
+ columns: z
470
+ .array(z.string())
471
+ .optional()
472
+ .describe("Visible-column ordering. Omit to keep the default set."),
473
+ settings: z
474
+ .record(z.string(), z.unknown())
475
+ .optional()
476
+ .describe(
477
+ "View-type settings (kanbanField, calendarDateField, timelineStartField, …).",
478
+ ),
479
+ pinnedEntryIds: z
480
+ .array(z.string())
481
+ .optional()
482
+ .describe(
483
+ "Pinned-list mode: show EXACTLY these entry ids (mutually exclusive with filters).",
484
+ ),
485
+ })
486
+ .passthrough();
487
+
443
488
  const crmObjectDoc = z
444
489
  .object({
445
490
  _id: z.string(),
@@ -1338,6 +1383,46 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
1338
1383
  }),
1339
1384
  "crm.objects.delete": z.object({ name: z.string() }),
1340
1385
 
1386
+ // CRM saved views
1387
+ "crm.views.list": z.object({ objectName: z.string() }),
1388
+ "crm.views.get": z.object({
1389
+ objectName: z.string(),
1390
+ viewName: z.string(),
1391
+ }),
1392
+ "crm.views.entries": z.object({
1393
+ objectName: z.string(),
1394
+ viewName: z.string(),
1395
+ page: z.number().int().min(1).optional(),
1396
+ pageSize: z.number().int().min(1).optional(),
1397
+ }),
1398
+ "crm.views.create": z.object({
1399
+ objectName: z.string(),
1400
+ view: savedViewDefinition,
1401
+ replaceExisting: z
1402
+ .boolean()
1403
+ .optional()
1404
+ .describe("Overwrite an existing view with the same name."),
1405
+ setActive: z
1406
+ .boolean()
1407
+ .optional()
1408
+ .describe("Also switch the object's active view to this one."),
1409
+ }),
1410
+ "crm.views.update": z.object({
1411
+ objectName: z.string(),
1412
+ viewName: z.string().describe("Name of the view to replace (path)."),
1413
+ view: savedViewDefinition
1414
+ .partial()
1415
+ .optional()
1416
+ .describe(
1417
+ "Full replacement definition; the path viewName is used as the name.",
1418
+ ),
1419
+ setActive: z.boolean().optional(),
1420
+ }),
1421
+ "crm.views.delete": z.object({
1422
+ objectName: z.string(),
1423
+ viewName: z.string(),
1424
+ }),
1425
+
1341
1426
  // CRM fields
1342
1427
  "crm.fields.list": z.object({ objectName: z.string() }),
1343
1428
  "crm.fields.create": z.object({
@@ -1701,6 +1786,13 @@ export const requestSchemas: Record<string, ZodTypeAny> = {
1701
1786
  "email.identity.restore": z.object({
1702
1787
  identity: z.string().describe("Identity id, sender email, or domain."),
1703
1788
  }),
1789
+ "email.identity.delete": z.object({
1790
+ identity: z
1791
+ .string()
1792
+ .describe(
1793
+ "Identity id, sender email, or domain (deletes every sender on the domain). Permanent: re-adding requires re-verification.",
1794
+ ),
1795
+ }),
1704
1796
  "email.send": z.object({
1705
1797
  fromEmail: z.string().describe("Verified sender mailbox."),
1706
1798
  fromName: z.string().optional().describe("Display name override."),
@@ -2078,6 +2170,34 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
2078
2170
  "crm.objects.rename": okResponse,
2079
2171
  "crm.objects.delete": okResponse,
2080
2172
 
2173
+ // CRM saved views
2174
+ "crm.views.list": z.object({
2175
+ objectName: z.string(),
2176
+ activeView: z.string().nullable(),
2177
+ views: z.array(z.record(z.string(), z.unknown())),
2178
+ }),
2179
+ "crm.views.get": z.record(z.string(), z.unknown()),
2180
+ "crm.views.create": z.object({
2181
+ ok: z.boolean(),
2182
+ objectName: z.string(),
2183
+ viewName: z.string(),
2184
+ replaced: z.boolean(),
2185
+ activeView: z.string().nullable(),
2186
+ }),
2187
+ "crm.views.update": z.object({
2188
+ ok: z.boolean(),
2189
+ objectName: z.string(),
2190
+ viewName: z.string(),
2191
+ replaced: z.boolean(),
2192
+ activeView: z.string().nullable(),
2193
+ }),
2194
+ "crm.views.delete": z.object({
2195
+ ok: z.boolean(),
2196
+ objectName: z.string(),
2197
+ viewName: z.string(),
2198
+ removed: z.boolean(),
2199
+ }),
2200
+
2081
2201
  // CRM fields
2082
2202
  "crm.fields.list": z.array(crmFieldDoc),
2083
2203
  "crm.fields.create": z.object({ id: z.string() }),
@@ -2411,6 +2531,10 @@ export const responseSchemas: Record<string, ZodTypeAny> = {
2411
2531
  ok: z.boolean(),
2412
2532
  restored: z.array(identitySummaryShape),
2413
2533
  }),
2534
+ "email.identity.delete": z.object({
2535
+ ok: z.boolean(),
2536
+ deleted: z.array(identitySummaryShape),
2537
+ }),
2414
2538
  "email.send": z.object({
2415
2539
  ok: z.boolean(),
2416
2540
  messageId: z
@@ -2583,6 +2707,27 @@ export type ConvexExtras = {
2583
2707
  export const convexExtras: Record<string, ConvexExtras> = {
2584
2708
  "cron.enable": { fixedArgs: { enabled: true } },
2585
2709
  "cron.disable": { fixedArgs: { enabled: false } },
2710
+ // PATCH /crm/objects/{objectName}/views/{viewName} — the path's
2711
+ // viewName is the upsert key: fold it into `view.name` and force
2712
+ // replaceExisting so the mutation overwrites instead of erroring on
2713
+ // the (guaranteed) name collision.
2714
+ "crm.views.update": {
2715
+ transformArgs: (input) => {
2716
+ const { viewName, view, ...rest } = input as {
2717
+ viewName?: unknown;
2718
+ view?: unknown;
2719
+ } & Record<string, unknown>;
2720
+ const baseView =
2721
+ view && typeof view === "object" && !Array.isArray(view)
2722
+ ? (view as Record<string, unknown>)
2723
+ : {};
2724
+ return {
2725
+ ...rest,
2726
+ view: { ...baseView, name: String(viewName ?? baseView.name ?? "") },
2727
+ replaceExisting: true,
2728
+ };
2729
+ },
2730
+ },
2586
2731
  "crm.entries.updateMany": {
2587
2732
  transformArgs: (input) => {
2588
2733
  const updates = Array.isArray(input.updates) ? input.updates : [];
@@ -2629,6 +2774,25 @@ export const requestExamples: Record<string, unknown> = {
2629
2774
  enumColors: ["#94a3b8", "#3b82f6", "#22c55e", "#ef4444"],
2630
2775
  indexed: true,
2631
2776
  },
2777
+ "crm.views.create": {
2778
+ view: {
2779
+ name: "Hot leads",
2780
+ view_type: "table",
2781
+ filters: {
2782
+ id: "g1",
2783
+ conjunction: "and",
2784
+ rules: [{ id: "r1", field: "Status", operator: "is", value: "Hot" }],
2785
+ },
2786
+ sort: [{ field: "created_at", direction: "desc" }],
2787
+ },
2788
+ setActive: true,
2789
+ },
2790
+ "crm.views.update": {
2791
+ view: {
2792
+ sort: [{ field: "Score", direction: "desc" }],
2793
+ columns: ["Name", "Email", "Status", "Score"],
2794
+ },
2795
+ },
2632
2796
  "crm.fields.enum.color": { color: "#22c55e" },
2633
2797
  "crm.fields.enum.add": { value: "Nurturing", color: "#eab308", position: 2 },
2634
2798
  "crm.entries.create": {
@@ -3048,6 +3212,42 @@ export const responseExamples: Record<string, unknown> = {
3048
3212
  "crm.objects.update": { ok: true },
3049
3213
  "crm.objects.rename": { ok: true },
3050
3214
  "crm.objects.delete": { ok: true },
3215
+ "crm.views.list": {
3216
+ objectName: "lead",
3217
+ activeView: "Hot leads",
3218
+ views: [
3219
+ {
3220
+ name: "Hot leads",
3221
+ view_type: "table",
3222
+ filters: {
3223
+ id: "g1",
3224
+ conjunction: "and",
3225
+ rules: [{ id: "r1", field: "Status", operator: "is", value: "Hot" }],
3226
+ },
3227
+ sort: [{ field: "created_at", direction: "desc" }],
3228
+ },
3229
+ ],
3230
+ },
3231
+ "crm.views.create": {
3232
+ ok: true,
3233
+ objectName: "lead",
3234
+ viewName: "Hot leads",
3235
+ replaced: false,
3236
+ activeView: "Hot leads",
3237
+ },
3238
+ "crm.views.update": {
3239
+ ok: true,
3240
+ objectName: "lead",
3241
+ viewName: "Hot leads",
3242
+ replaced: true,
3243
+ activeView: null,
3244
+ },
3245
+ "crm.views.delete": {
3246
+ ok: true,
3247
+ objectName: "lead",
3248
+ viewName: "Hot leads",
3249
+ removed: true,
3250
+ },
3051
3251
  "crm.fields.list": [
3052
3252
  {
3053
3253
  _id: "fld_status01",
@@ -3431,6 +3631,16 @@ export const responseExamples: Record<string, unknown> = {
3431
3631
  },
3432
3632
  ],
3433
3633
  },
3634
+ "email.identity.delete": {
3635
+ ok: true,
3636
+ deleted: [
3637
+ {
3638
+ identityId: "esi_abc123",
3639
+ fromEmail: "founder@example.com",
3640
+ disabled: false,
3641
+ },
3642
+ ],
3643
+ },
3434
3644
  "email.send": {
3435
3645
  ok: true,
3436
3646
  messageId: "emsg_abc123",
@@ -29,6 +29,7 @@ export type CustomOperation = {
29
29
  | "commandsList"
30
30
  | "chatSpawn"
31
31
  | "chatFollow"
32
+ | "cronDelete"
32
33
  | "cronRunNow"
33
34
  | "billingTopup"
34
35
  | "upgrade"
@@ -583,6 +584,75 @@ const rawApiOperations = [
583
584
  backend: convex("mutation", "functions/crm/objects:remove"),
584
585
  }),
585
586
 
587
+ op({
588
+ id: "crm.views.list",
589
+ group: "crm",
590
+ summary: "List the saved views on a CRM object (plus the active view).",
591
+ cli: "dench crm views list",
592
+ method: "GET",
593
+ path: "/crm/objects/{objectName}/views",
594
+ requestSchema: noBody,
595
+ responseSchema: anyResponse,
596
+ backend: convex("query", "functions/crm/objects:listViews"),
597
+ }),
598
+ op({
599
+ id: "crm.views.get",
600
+ group: "crm",
601
+ summary: "Get one saved view's definition by name.",
602
+ cli: "dench crm views get",
603
+ method: "GET",
604
+ path: "/crm/objects/{objectName}/views/{viewName}",
605
+ requestSchema: noBody,
606
+ responseSchema: anyResponse,
607
+ backend: convex("query", "functions/crm/objects:getView"),
608
+ }),
609
+ op({
610
+ id: "crm.views.entries",
611
+ group: "crm",
612
+ summary:
613
+ "Materialize a saved view's rows (applies its filters/sort/pins), paginated.",
614
+ cli: "dench crm views entries",
615
+ method: "GET",
616
+ path: "/crm/objects/{objectName}/views/{viewName}/entries",
617
+ requestSchema: noBody,
618
+ responseSchema: anyResponse,
619
+ backend: convex("query", "functions/crm/objects:materializeView"),
620
+ }),
621
+ op({
622
+ id: "crm.views.create",
623
+ group: "crm",
624
+ summary:
625
+ "Create a saved view (named filters/sort/columns combo) on a CRM object.",
626
+ cli: "dench crm views create",
627
+ method: "POST",
628
+ path: "/crm/objects/{objectName}/views",
629
+ requestSchema: anyObject,
630
+ responseSchema: anyResponse,
631
+ backend: convex("mutation", "functions/crm/objects:saveView"),
632
+ }),
633
+ op({
634
+ id: "crm.views.update",
635
+ group: "crm",
636
+ summary: "Replace a saved view's definition (upsert by name).",
637
+ cli: "dench crm views update",
638
+ method: "PATCH",
639
+ path: "/crm/objects/{objectName}/views/{viewName}",
640
+ requestSchema: anyObject,
641
+ responseSchema: anyResponse,
642
+ backend: convex("mutation", "functions/crm/objects:saveView"),
643
+ }),
644
+ op({
645
+ id: "crm.views.delete",
646
+ group: "crm",
647
+ summary: "Delete a saved view by name (idempotent).",
648
+ cli: "dench crm views delete",
649
+ method: "DELETE",
650
+ path: "/crm/objects/{objectName}/views/{viewName}",
651
+ requestSchema: anyObject,
652
+ responseSchema: anyResponse,
653
+ backend: convex("mutation", "functions/crm/objects:deleteView"),
654
+ }),
655
+
586
656
  op({
587
657
  id: "crm.fields.list",
588
658
  group: "crm",
@@ -1366,6 +1436,18 @@ const rawApiOperations = [
1366
1436
  responseSchema: anyResponse,
1367
1437
  backend: convex("mutation", "functions/emailCampaigns:restoreIdentity"),
1368
1438
  }),
1439
+ op({
1440
+ id: "email.identity.delete",
1441
+ group: "email",
1442
+ summary:
1443
+ "Permanently delete a sender (or every sender on a domain) from the workspace; re-adding it requires verification from scratch.",
1444
+ cli: "dench email identity delete",
1445
+ method: "DELETE",
1446
+ path: "/email/sending-identities/{identity}",
1447
+ requestSchema: anyObject,
1448
+ responseSchema: anyResponse,
1449
+ backend: convex("mutation", "functions/emailCampaigns:deleteIdentity"),
1450
+ }),
1369
1451
  op({
1370
1452
  id: "email.send",
1371
1453
  group: "email",
@@ -1814,7 +1896,7 @@ const rawApiOperations = [
1814
1896
  path: "/routines/{jobId}",
1815
1897
  requestSchema: anyObject,
1816
1898
  responseSchema: anyResponse,
1817
- backend: convex("mutation", "functions/triggers:deleteCronTrigger"),
1899
+ backend: custom("cronDelete", "bearer"),
1818
1900
  }),
1819
1901
  op({
1820
1902
  id: "cron.enable",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.5.2",
3
+ "version": "2.7.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": {