@dench.com/cli 2.5.1 → 2.6.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]
@@ -3024,7 +3330,7 @@ Fields (columns):
3024
3330
  Appends <value> to the field's enumValues. --color is a hex like
3025
3331
  "#22c55e"; --position is a 0-based insertion index (defaults to append).
3026
3332
  Idempotent (case-insensitive): re-adding "won" when "Won" exists does
3027
- NOT create a twin option — the response's `value` echoes the canonical
3333
+ NOT create a twin option — the response's "value" echoes the canonical
3028
3334
  casing; a given --color still updates the existing option's color.
3029
3335
  For a board-backed Status/Stage field this also creates the matching
3030
3336
  kanban column (positioned to match), so you do NOT need a separate
package/email.ts CHANGED
@@ -196,6 +196,7 @@ 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]
199
200
  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
201
  dench email message status --message <messageId> [--json]
201
202
  dench email message list [--limit 25] [--json]
@@ -505,13 +506,15 @@ async function runIdentity(ctx: EmailCliContext) {
505
506
  out(ctx, { ok: true, identities }, text);
506
507
  return;
507
508
  }
508
- if (sub === "disable" || sub === "restore") {
509
+ if (sub === "disable" || sub === "restore" || sub === "delete") {
509
510
  const identity = requiredFlag(ctx.args, "--identity");
510
511
  const payload = (await callMutation(
511
512
  ctx,
512
513
  sub === "disable"
513
514
  ? "functions/emailCampaigns:disableIdentity"
514
- : "functions/emailCampaigns:restoreIdentity",
515
+ : sub === "restore"
516
+ ? "functions/emailCampaigns:restoreIdentity"
517
+ : "functions/emailCampaigns:deleteIdentity",
515
518
  { identity },
516
519
  )) as JsonRecord;
517
520
  const rows = (
@@ -519,15 +522,26 @@ async function runIdentity(ctx: EmailCliContext) {
519
522
  ? payload.disabled
520
523
  : Array.isArray(payload.restored)
521
524
  ? payload.restored
522
- : []
525
+ : Array.isArray(payload.deleted)
526
+ ? payload.deleted
527
+ : []
523
528
  ) as JsonRecord[];
524
- const verb = sub === "disable" ? "Disabled" : "Restored";
529
+ const verb =
530
+ sub === "disable"
531
+ ? "Disabled"
532
+ : sub === "restore"
533
+ ? "Restored"
534
+ : "Deleted";
535
+ const suffix =
536
+ sub === "delete" && rows.length > 0
537
+ ? "\nDeleted senders must be re-verified before they can send again."
538
+ : "";
525
539
  const text =
526
540
  rows.length === 0
527
541
  ? `${verb} 0 identities.`
528
542
  : `${verb} ${rows.length} sender(s):\n${rows
529
543
  .map((row) => ` ${row.fromEmail} (${row.identityId})`)
530
- .join("\n")}`;
544
+ .join("\n")}${suffix}`;
531
545
  out(ctx, payload, text);
532
546
  return;
533
547
  }
@@ -627,7 +641,7 @@ async function runIdentity(ctx: EmailCliContext) {
627
641
  return;
628
642
  }
629
643
  throw new EmailCliError(
630
- "Usage: dench email identity verify|list|dns|status|disable|restore",
644
+ "Usage: dench email identity verify|list|dns|status|disable|restore|delete",
631
645
  );
632
646
  }
633
647
 
@@ -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",
@@ -583,6 +583,75 @@ const rawApiOperations = [
583
583
  backend: convex("mutation", "functions/crm/objects:remove"),
584
584
  }),
585
585
 
586
+ op({
587
+ id: "crm.views.list",
588
+ group: "crm",
589
+ summary: "List the saved views on a CRM object (plus the active view).",
590
+ cli: "dench crm views list",
591
+ method: "GET",
592
+ path: "/crm/objects/{objectName}/views",
593
+ requestSchema: noBody,
594
+ responseSchema: anyResponse,
595
+ backend: convex("query", "functions/crm/objects:listViews"),
596
+ }),
597
+ op({
598
+ id: "crm.views.get",
599
+ group: "crm",
600
+ summary: "Get one saved view's definition by name.",
601
+ cli: "dench crm views get",
602
+ method: "GET",
603
+ path: "/crm/objects/{objectName}/views/{viewName}",
604
+ requestSchema: noBody,
605
+ responseSchema: anyResponse,
606
+ backend: convex("query", "functions/crm/objects:getView"),
607
+ }),
608
+ op({
609
+ id: "crm.views.entries",
610
+ group: "crm",
611
+ summary:
612
+ "Materialize a saved view's rows (applies its filters/sort/pins), paginated.",
613
+ cli: "dench crm views entries",
614
+ method: "GET",
615
+ path: "/crm/objects/{objectName}/views/{viewName}/entries",
616
+ requestSchema: noBody,
617
+ responseSchema: anyResponse,
618
+ backend: convex("query", "functions/crm/objects:materializeView"),
619
+ }),
620
+ op({
621
+ id: "crm.views.create",
622
+ group: "crm",
623
+ summary:
624
+ "Create a saved view (named filters/sort/columns combo) on a CRM object.",
625
+ cli: "dench crm views create",
626
+ method: "POST",
627
+ path: "/crm/objects/{objectName}/views",
628
+ requestSchema: anyObject,
629
+ responseSchema: anyResponse,
630
+ backend: convex("mutation", "functions/crm/objects:saveView"),
631
+ }),
632
+ op({
633
+ id: "crm.views.update",
634
+ group: "crm",
635
+ summary: "Replace a saved view's definition (upsert by name).",
636
+ cli: "dench crm views update",
637
+ method: "PATCH",
638
+ path: "/crm/objects/{objectName}/views/{viewName}",
639
+ requestSchema: anyObject,
640
+ responseSchema: anyResponse,
641
+ backend: convex("mutation", "functions/crm/objects:saveView"),
642
+ }),
643
+ op({
644
+ id: "crm.views.delete",
645
+ group: "crm",
646
+ summary: "Delete a saved view by name (idempotent).",
647
+ cli: "dench crm views delete",
648
+ method: "DELETE",
649
+ path: "/crm/objects/{objectName}/views/{viewName}",
650
+ requestSchema: anyObject,
651
+ responseSchema: anyResponse,
652
+ backend: convex("mutation", "functions/crm/objects:deleteView"),
653
+ }),
654
+
586
655
  op({
587
656
  id: "crm.fields.list",
588
657
  group: "crm",
@@ -1366,6 +1435,18 @@ const rawApiOperations = [
1366
1435
  responseSchema: anyResponse,
1367
1436
  backend: convex("mutation", "functions/emailCampaigns:restoreIdentity"),
1368
1437
  }),
1438
+ op({
1439
+ id: "email.identity.delete",
1440
+ group: "email",
1441
+ summary:
1442
+ "Permanently delete a sender (or every sender on a domain) from the workspace; re-adding it requires verification from scratch.",
1443
+ cli: "dench email identity delete",
1444
+ method: "DELETE",
1445
+ path: "/email/sending-identities/{identity}",
1446
+ requestSchema: anyObject,
1447
+ responseSchema: anyResponse,
1448
+ backend: convex("mutation", "functions/emailCampaigns:deleteIdentity"),
1449
+ }),
1369
1450
  op({
1370
1451
  id: "email.send",
1371
1452
  group: "email",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.5.1",
3
+ "version": "2.6.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": {