@dench.com/cli 0.4.0 → 0.4.2

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/chat-spawn.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * calls until the workflow finishes.
18
18
  */
19
19
 
20
+ import { getCurrentRuntimeTimeZone } from "@/lib/timezone";
20
21
  import { hasFlag } from "./lib/cli-args";
21
22
 
22
23
  class ChatSpawnError extends Error {}
@@ -64,12 +65,14 @@ async function postJson(
64
65
  bearer: string,
65
66
  body: unknown,
66
67
  ): Promise<{ ok: boolean; status: number; payload: unknown }> {
68
+ const timezone = getCurrentRuntimeTimeZone();
67
69
  const response = await fetch(url, {
68
70
  method: "POST",
69
71
  headers: {
70
72
  accept: "application/json",
71
73
  "content-type": "application/json",
72
74
  authorization: `Bearer ${bearer}`,
75
+ ...(timezone ? { "x-dench-user-timezone": timezone } : {}),
73
76
  },
74
77
  body: JSON.stringify(body),
75
78
  });
@@ -258,7 +261,10 @@ async function followStream(args: {
258
261
  }
259
262
 
260
263
  function joinPositionalsExceptFlags(args: string[]): string {
261
- return args.filter((arg) => !arg.startsWith("--")).join(" ").trim();
264
+ return args
265
+ .filter((arg) => !arg.startsWith("--"))
266
+ .join(" ")
267
+ .trim();
262
268
  }
263
269
 
264
270
  async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
@@ -286,6 +292,7 @@ async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
286
292
  visibility,
287
293
  title,
288
294
  yolo,
295
+ timezone: getCurrentRuntimeTimeZone(),
289
296
  },
290
297
  );
291
298
  if (!result.ok) {
@@ -341,6 +348,7 @@ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
341
348
  prompt,
342
349
  model,
343
350
  yolo,
351
+ timezone: getCurrentRuntimeTimeZone(),
344
352
  },
345
353
  );
346
354
  if (!result.ok) {
@@ -385,9 +393,7 @@ async function runChatFollow(ctx: ChatSpawnContext): Promise<void> {
385
393
  const positionals = ctx.args.filter((arg) => !arg.startsWith("--"));
386
394
  const threadId = positionals.shift();
387
395
  if (!threadId) {
388
- throw new ChatSpawnError(
389
- "Usage: dench chat follow <threadId> [--json]",
390
- );
396
+ throw new ChatSpawnError("Usage: dench chat follow <threadId> [--json]");
391
397
  }
392
398
  if (!ctx.jsonOutput) {
393
399
  console.error(`--- following stream for thread ${threadId} ---`);
package/crm.ts CHANGED
@@ -17,8 +17,8 @@
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
19
  * crm fields list / create / update / delete / reorder
20
- * crm entries list / get / create / update / delete / bulk-delete
21
- * crm cells get / set / append
20
+ * crm entries list / get / create / create-many / update / update-many / delete / bulk-delete
21
+ * crm cells get / set / set-many / append
22
22
  * crm query <object> --where … --select … --sort … --limit …
23
23
  * crm aggregate <object> --field …
24
24
  * crm batch --file ops.jsonl
@@ -40,6 +40,8 @@ import {
40
40
 
41
41
  type JsonRecord = Record<string, unknown>;
42
42
 
43
+ const CRM_BATCH_CHUNK_SIZE = 200;
44
+
43
45
  type CrmCliContext = {
44
46
  convex: ConvexHttpClient;
45
47
  args: string[];
@@ -97,6 +99,150 @@ function getDataFlag(args: string[]): string | undefined {
97
99
  return getFlagWithAliases(args, "--data", ["--fields"]);
98
100
  }
99
101
 
102
+ function getUpdatesFlag(args: string[]): string | undefined {
103
+ return getFlagWithAliases(args, "--updates", ["--data"]);
104
+ }
105
+
106
+ function isJsonRecord(value: unknown): value is JsonRecord {
107
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
108
+ }
109
+
110
+ function requireNonEmptyJsonRecord(value: unknown, label: string): JsonRecord {
111
+ if (!isJsonRecord(value) || Object.keys(value).length === 0) {
112
+ throw new CrmCliError(`${label} must be a non-empty JSON object`);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ async function readJsonArrayInput(opts: {
118
+ args: string[];
119
+ command: string;
120
+ inlineFlag: string;
121
+ inlineValue: string | undefined;
122
+ }): Promise<unknown[]> {
123
+ const filePath = getFlag(opts.args, "--file");
124
+ assertNoUnknownFlags(opts.args, opts.command);
125
+ if (opts.inlineValue !== undefined && filePath) {
126
+ throw new CrmCliError(
127
+ `${opts.command} accepts either ${opts.inlineFlag} or --file, not both`,
128
+ );
129
+ }
130
+ if (opts.inlineValue === undefined && !filePath) {
131
+ throw new CrmCliError(
132
+ `${opts.command} requires ${opts.inlineFlag} '<json-array>' or --file <path>`,
133
+ );
134
+ }
135
+
136
+ let raw: string;
137
+ if (filePath) {
138
+ const fs = await import("node:fs/promises");
139
+ raw = await fs.readFile(filePath, "utf-8");
140
+ } else {
141
+ raw = opts.inlineValue ?? "";
142
+ }
143
+
144
+ const parsed = parseJson(raw);
145
+ if (!Array.isArray(parsed)) {
146
+ throw new CrmCliError(`${opts.inlineFlag} must be a JSON array`);
147
+ }
148
+ if (parsed.length === 0) {
149
+ throw new CrmCliError(`${opts.inlineFlag} must include at least one item`);
150
+ }
151
+ return parsed;
152
+ }
153
+
154
+ function parseCreateManyRows(items: unknown[]): JsonRecord[] {
155
+ return items.map((item, index) =>
156
+ requireNonEmptyJsonRecord(item, `create-many item ${index + 1}`),
157
+ );
158
+ }
159
+
160
+ function parseUpdateManyRows(items: unknown[]): Array<{
161
+ id: string;
162
+ data: JsonRecord;
163
+ }> {
164
+ return items.map((item, index) => {
165
+ if (!isJsonRecord(item) || typeof item.id !== "string" || !item.id) {
166
+ throw new CrmCliError(
167
+ `update-many item ${index + 1} must include a string id`,
168
+ );
169
+ }
170
+ return {
171
+ id: item.id,
172
+ data: requireNonEmptyJsonRecord(
173
+ item.data,
174
+ `update-many item ${index + 1} data`,
175
+ ),
176
+ };
177
+ });
178
+ }
179
+
180
+ function parseCellSetManyRows(items: unknown[]): Array<{
181
+ entryId: string;
182
+ field: string;
183
+ value: unknown;
184
+ }> {
185
+ return items.map((item, index) => {
186
+ if (!isJsonRecord(item)) {
187
+ throw new CrmCliError(
188
+ `cells set-many item ${index + 1} must be a JSON object`,
189
+ );
190
+ }
191
+ if (typeof item.entryId !== "string" || !item.entryId) {
192
+ throw new CrmCliError(
193
+ `cells set-many item ${index + 1} must include a string entryId`,
194
+ );
195
+ }
196
+ if (typeof item.field !== "string" || !item.field) {
197
+ throw new CrmCliError(
198
+ `cells set-many item ${index + 1} must include a string field`,
199
+ );
200
+ }
201
+ return {
202
+ entryId: item.entryId,
203
+ field: item.field,
204
+ value: item.value ?? null,
205
+ };
206
+ });
207
+ }
208
+
209
+ type CrmBatchOp =
210
+ | { type: "entries.create"; objectName: string; data: JsonRecord }
211
+ | { type: "entries.update"; entryId: string; data: JsonRecord };
212
+
213
+ function chunkArray<T>(items: T[], size: number): T[][] {
214
+ const chunks: T[][] = [];
215
+ for (let index = 0; index < items.length; index += size) {
216
+ chunks.push(items.slice(index, index + size));
217
+ }
218
+ return chunks;
219
+ }
220
+
221
+ function chunkIdempotencyKey(
222
+ idempotencyKey: string | undefined,
223
+ chunkIndex: number,
224
+ ): string | undefined {
225
+ return idempotencyKey
226
+ ? `${idempotencyKey}:chunk-${chunkIndex + 1}`
227
+ : undefined;
228
+ }
229
+
230
+ async function applyBatchOpsInChunks(
231
+ ctx: CrmCliContext,
232
+ ops: CrmBatchOp[],
233
+ idempotencyKey: string | undefined,
234
+ ): Promise<number> {
235
+ const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
236
+ for (const [chunkIndex, chunk] of chunks.entries()) {
237
+ const chunkKey = chunkIdempotencyKey(idempotencyKey, chunkIndex);
238
+ await callMutation(ctx, api.batch.apply, {
239
+ ops: chunk,
240
+ ...(chunkKey ? { idempotencyKey: chunkKey } : {}),
241
+ });
242
+ }
243
+ return chunks.length;
244
+ }
245
+
100
246
  async function callQuery(
101
247
  ctx: CrmCliContext,
102
248
  fn: Parameters<ConvexHttpClient["query"]>[0],
@@ -139,6 +285,35 @@ function out(ctx: CrmCliContext, value: unknown): void {
139
285
  console.log(JSON.stringify(value, null, 2));
140
286
  }
141
287
 
288
+ function getFieldEnrichmentFlag(args: string[]): {
289
+ category: EnrichmentCategory;
290
+ key: string;
291
+ apolloPath: string;
292
+ inputFieldName: string;
293
+ } | undefined {
294
+ const category = parseCategoryFlag(getFlag(args, "--enrichment-category"));
295
+ const key = getFlag(args, "--enrichment-key");
296
+ const apolloPath = getFlag(args, "--apollo-path");
297
+ const inputFieldName = getFlag(args, "--input-field");
298
+ const sawAny =
299
+ category !== undefined ||
300
+ key !== undefined ||
301
+ apolloPath !== undefined ||
302
+ inputFieldName !== undefined;
303
+ if (!sawAny) return undefined;
304
+ if (!category || !apolloPath || !inputFieldName) {
305
+ throw new CrmCliError(
306
+ "Enrichment fields require --enrichment-category people|company, --apollo-path <path>, and --input-field <field>.",
307
+ );
308
+ }
309
+ return {
310
+ category,
311
+ key: key ?? apolloPath,
312
+ apolloPath,
313
+ inputFieldName,
314
+ };
315
+ }
316
+
142
317
  const api = {
143
318
  transaction: {
144
319
  begin: makeFunctionReference<"mutation">("functions/crm/transaction:begin"),
@@ -380,6 +555,7 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
380
555
  : hasFlag(ctx.args, "--no-indexed")
381
556
  ? false
382
557
  : undefined;
558
+ const enrichment = getFieldEnrichmentFlag(ctx.args);
383
559
  assertNoUnknownFlags(ctx.args, "crm fields create");
384
560
  out(
385
561
  ctx,
@@ -395,6 +571,7 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
395
571
  relatedObjectName,
396
572
  relationshipType,
397
573
  indexed,
574
+ enrichment,
398
575
  }),
399
576
  );
400
577
  return;
@@ -412,6 +589,8 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
412
589
  : hasFlag(ctx.args, "--no-indexed")
413
590
  ? false
414
591
  : undefined;
592
+ const enrichment = getFieldEnrichmentFlag(ctx.args);
593
+ const clearEnrichment = hasFlag(ctx.args, "--clear-enrichment");
415
594
  assertNoUnknownFlags(ctx.args, "crm fields update");
416
595
  out(
417
596
  ctx,
@@ -422,6 +601,8 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
422
601
  required,
423
602
  enumValues,
424
603
  indexed,
604
+ enrichment,
605
+ clearEnrichment,
425
606
  }),
426
607
  );
427
608
  return;
@@ -509,6 +690,30 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
509
690
  );
510
691
  return;
511
692
  }
693
+ case "create-many": {
694
+ const objectName = shift(ctx.args, "object name");
695
+ const dataRaw = getDataFlag(ctx.args);
696
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
697
+ const rows = parseCreateManyRows(
698
+ await readJsonArrayInput({
699
+ args: ctx.args,
700
+ command: "crm entries create-many",
701
+ inlineFlag: "--data",
702
+ inlineValue: dataRaw,
703
+ }),
704
+ );
705
+ const chunks = await applyBatchOpsInChunks(
706
+ ctx,
707
+ rows.map((data) => ({
708
+ type: "entries.create",
709
+ objectName,
710
+ data,
711
+ })),
712
+ idempotencyKey,
713
+ );
714
+ out(ctx, { created: rows.length, chunks });
715
+ return;
716
+ }
512
717
  case "update": {
513
718
  const objectName = shift(ctx.args, "object name");
514
719
  const entryId = shift(ctx.args, "entry id");
@@ -535,6 +740,30 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
535
740
  );
536
741
  return;
537
742
  }
743
+ case "update-many": {
744
+ const _objectName = shift(ctx.args, "object name");
745
+ const updatesRaw = getUpdatesFlag(ctx.args);
746
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
747
+ const updates = parseUpdateManyRows(
748
+ await readJsonArrayInput({
749
+ args: ctx.args,
750
+ command: "crm entries update-many",
751
+ inlineFlag: "--updates",
752
+ inlineValue: updatesRaw,
753
+ }),
754
+ );
755
+ const chunks = await applyBatchOpsInChunks(
756
+ ctx,
757
+ updates.map((update) => ({
758
+ type: "entries.update",
759
+ entryId: update.id,
760
+ data: update.data,
761
+ })),
762
+ idempotencyKey,
763
+ );
764
+ out(ctx, { updated: updates.length, chunks });
765
+ return;
766
+ }
538
767
  case "delete":
539
768
  case "remove": {
540
769
  const _objectName = shift(ctx.args, "object name");
@@ -569,11 +798,11 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
569
798
 
570
799
  async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
571
800
  const verb = ctx.args.shift();
572
- const objectName = shift(ctx.args, "object name");
573
- const entryId = shift(ctx.args, "entry id");
574
- const fieldName = shift(ctx.args, "field name");
575
801
  switch (verb) {
576
802
  case "get": {
803
+ const objectName = shift(ctx.args, "object name");
804
+ const entryId = shift(ctx.args, "entry id");
805
+ const fieldName = shift(ctx.args, "field name");
577
806
  assertNoUnknownFlags(ctx.args, "crm cells get");
578
807
  out(
579
808
  ctx,
@@ -586,6 +815,9 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
586
815
  return;
587
816
  }
588
817
  case "set": {
818
+ const objectName = shift(ctx.args, "object name");
819
+ const entryId = shift(ctx.args, "entry id");
820
+ const fieldName = shift(ctx.args, "field name");
589
821
  const value = parseJson(ctx.args.shift()) ?? null;
590
822
  assertNoUnknownFlags(ctx.args, "crm cells set");
591
823
  out(
@@ -599,7 +831,39 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
599
831
  );
600
832
  return;
601
833
  }
834
+ case "set-many": {
835
+ const _objectName = shift(ctx.args, "object name");
836
+ const updatesRaw = getUpdatesFlag(ctx.args);
837
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
838
+ const updates = parseCellSetManyRows(
839
+ await readJsonArrayInput({
840
+ args: ctx.args,
841
+ command: "crm cells set-many",
842
+ inlineFlag: "--updates",
843
+ inlineValue: updatesRaw,
844
+ }),
845
+ );
846
+ const grouped = new Map<string, JsonRecord>();
847
+ for (const update of updates) {
848
+ const data = grouped.get(update.entryId) ?? {};
849
+ data[update.field] = update.value;
850
+ grouped.set(update.entryId, data);
851
+ }
852
+ const ops: CrmBatchOp[] = Array.from(grouped.entries()).map(
853
+ ([entryId, data]) => ({
854
+ type: "entries.update",
855
+ entryId,
856
+ data,
857
+ }),
858
+ );
859
+ const chunks = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
860
+ out(ctx, { updated: updates.length, entriesUpdated: ops.length, chunks });
861
+ return;
862
+ }
602
863
  case "append": {
864
+ const objectName = shift(ctx.args, "object name");
865
+ const entryId = shift(ctx.args, "entry id");
866
+ const fieldName = shift(ctx.args, "field name");
603
867
  const value = parseJson(ctx.args.shift()) ?? null;
604
868
  assertNoUnknownFlags(ctx.args, "crm cells append");
605
869
  out(
@@ -1431,8 +1695,9 @@ Fields (columns):
1431
1695
  dench crm fields create <object> <name> --type text|email|phone|url|number|boolean|date|enum|relation|user|tags|action|file|richtext \\
1432
1696
  [--required] [--description ...] [--enum-values JSON] [--enum-colors JSON] \\
1433
1697
  [--enum-multiple] [--related-object <name>] [--relationship many_to_one|many_to_many] \\
1434
- [--indexed | --no-indexed]
1435
- dench crm fields update <object> <field> [--description ...] [--required] [--enum-values JSON] [--indexed | --no-indexed]
1698
+ [--indexed | --no-indexed] [--enrichment-category people|company --apollo-path <path> --input-field <field>]
1699
+ dench crm fields update <object> <field> [--description ...] [--required] [--enum-values JSON] [--indexed | --no-indexed] \\
1700
+ [--enrichment-category people|company --apollo-path <path> --input-field <field> | --clear-enrichment]
1436
1701
  dench crm fields delete <object> <field>
1437
1702
  dench crm fields reorder <object> <field1> <field2> <field3> ...
1438
1703
 
@@ -1440,7 +1705,9 @@ Entries (rows):
1440
1705
  dench crm entries list <object> [--limit N] [--json]
1441
1706
  dench crm entries get <object> <entryId>
1442
1707
  dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields)
1708
+ dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>]
1443
1709
  dench crm entries update <object> <entryId> --data '{...}' (alias: --fields)
1710
+ dench crm entries update-many <object> --updates '[{"id":"...","data":{...}}]' [--file updates.json] [--idempotency-key <key>]
1444
1711
  dench crm entries delete <object> <entryId>
1445
1712
  dench crm entries bulk-delete <object> --ids id1,id2,id3
1446
1713
  Note: --data is REQUIRED on create/update and must include >=1 field.
@@ -1450,6 +1717,7 @@ Entries (rows):
1450
1717
  Cells (single-field updates):
1451
1718
  dench crm cells get <object> <entryId> <field>
1452
1719
  dench crm cells set <object> <entryId> <field> '<JSON value>'
1720
+ dench crm cells set-many <object> --updates '[{"entryId":"...","field":"Status","value":"Done"}]' [--file updates.json] [--idempotency-key <key>]
1453
1721
  dench crm cells append <object> <entryId> <field> '<JSON value>'
1454
1722
 
1455
1723
  Query DSL:
@@ -1467,6 +1735,9 @@ Statuses (kanban columns):
1467
1735
 
1468
1736
  Batch:
1469
1737
  dench crm batch --file ops.jsonl [--idempotency-key <key>]
1738
+ For repeated writes, prefer entries create-many/update-many or cells set-many;
1739
+ for transformations, write a script in /tmp/work/<runId>/ that loops locally
1740
+ and calls a batch surface in chunks.
1470
1741
 
1471
1742
  Import / export:
1472
1743
  dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error]