@zeegme/cli 0.3.0 → 0.4.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/README.md CHANGED
@@ -61,6 +61,7 @@ Plain `http` is accepted for `localhost` only. Each origin keeps its own login.
61
61
  | `zeeg crm list <people\|companies\|slug> [--search <text>]` | CRM records |
62
62
  | `zeeg crm get <object> <id>` | One CRM record |
63
63
  | `zeeg crm upsert <object> --match <attribute> --data <json>` | Create a record, or update the one that matches |
64
+ | `zeeg crm update <object> --data <json>` | Update many records by `id`, up to 50 per request |
64
65
  | `zeeg forms list` / `submissions [--form]` | Routing forms and their submissions |
65
66
  | `zeeg calls start <agentUuid> --phone [--name --email --data --at]` | Have an AI phone agent call someone |
66
67
  | `zeeg agents list`, `zeeg agents calls <agentUuid>`, `zeeg agents call <callId>` | AI phone agents, their calls, and one call with its transcript |
@@ -74,6 +75,7 @@ zeeg availability 5c7e… --from 2026-10-05 --to 2026-10-09 --tz Europe/Berlin
74
75
  zeeg bookings create 5c7e… --date 2026-10-06 --time 14:30 --tz Europe/Berlin \
75
76
  --name "Sophie Laurent" --email sophie@northwind.io
76
77
  zeeg crm upsert people --match emails --data '{"firstName":"Sophie","emails":["sophie@northwind.io"]}'
78
+ zeeg crm update people --data '[{"id":"9b1c…","jobTitle":"CTO"},{"id":"a77e…","contactOwnerMemberId":42}]'
77
79
  ```
78
80
 
79
81
  ## JSON output
@@ -1,6 +1,6 @@
1
1
  import { unwrap } from '../api/client.js';
2
- import { UsageError } from '../errors.js';
3
- import { asJson, jsonArgument, positiveInt } from './util.js';
2
+ import { ApiError, UsageError } from '../errors.js';
3
+ import { asJson, jsonArgument, positiveInt, readJsonText } from './util.js';
4
4
  /** `people` and `companies` have their own endpoints; anything else is a custom object slug. */
5
5
  async function list(api, object, query) {
6
6
  if (object === 'people') {
@@ -38,6 +38,94 @@ async function upsert(api, object, matchingAttribute, body) {
38
38
  body: body,
39
39
  })));
40
40
  }
41
+ /** The most records one bulk request takes; longer inputs are sent as several requests. */
42
+ const BULK_UPDATE_LIMIT = 50;
43
+ /** `[{ id, ...fields }]` or `{ records: [{ id, ...fields }] }`, as inline JSON or `@file.json`. */
44
+ async function parseUpdateRecords(raw) {
45
+ const text = await readJsonText(raw);
46
+ let value;
47
+ try {
48
+ value = JSON.parse(text);
49
+ }
50
+ catch {
51
+ throw new UsageError('--data must be JSON, inline or as @file.json.');
52
+ }
53
+ const records = Array.isArray(value) ? value : asJson(value).records;
54
+ if (!Array.isArray(records) || records.length === 0) {
55
+ throw new UsageError('--data must be a non-empty array of { "id": …, …fields } objects, or { "records": [...] }.');
56
+ }
57
+ records.forEach((record, index) => {
58
+ if (record === null || typeof record !== 'object' || Array.isArray(record) || typeof record.id !== 'string' || record.id === '') {
59
+ throw new UsageError(`Record ${index + 1} must be an object with the "id" of the record to update.`);
60
+ }
61
+ });
62
+ const seen = new Set();
63
+ // The API compares ids ignoring case and refuses the whole request on a repeat.
64
+ for (const { id } of records) {
65
+ if (seen.has(id.toLowerCase())) {
66
+ throw new UsageError(`The id ${id} appears more than once.`);
67
+ }
68
+ seen.add(id.toLowerCase());
69
+ }
70
+ return records;
71
+ }
72
+ function chunk(items, size) {
73
+ const chunks = [];
74
+ for (let start = 0; start < items.length; start += size) {
75
+ chunks.push(items.slice(start, start + size));
76
+ }
77
+ return chunks;
78
+ }
79
+ function sender(api, object) {
80
+ return async (batch) => {
81
+ const body = { records: batch };
82
+ return object === 'people'
83
+ ? unwrap(api.PATCH('/crm/people', { body }))
84
+ : object === 'companies'
85
+ ? unwrap(api.PATCH('/crm/companies', { body }))
86
+ : unwrap(api.PATCH('/crm/{objectSlug}', { params: { path: { objectSlug: object } }, body }));
87
+ };
88
+ }
89
+ /** The field messages of a validation failure, else the error's message. */
90
+ function errorText(error) {
91
+ const fields = Object.values(asJson(error.errors)).flat();
92
+ return fields.length > 0 ? fields.join(' ') : error.message;
93
+ }
94
+ /**
95
+ * Sends the records in requests of at most 50 and prints one row per record, in input order.
96
+ * When a request fails, the rows of the earlier requests are still printed and the records
97
+ * that were never sent are listed as `not sent`, so the caller knows what was saved. Throws
98
+ * when a request failed or any record was not updated.
99
+ */
100
+ export async function updateRecords(send, records, print) {
101
+ const rows = [];
102
+ let sent = 0;
103
+ let failure;
104
+ for (const batch of chunk(records, BULK_UPDATE_LIMIT)) {
105
+ try {
106
+ const results = (asJson(await send(batch)).results ?? []);
107
+ rows.push(...results.map((row) => ({ ...row, result: row.updated === true ? 'updated' : 'failed' })));
108
+ sent += batch.length;
109
+ }
110
+ catch (error) {
111
+ failure = error;
112
+ break;
113
+ }
114
+ }
115
+ rows.push(...records.slice(sent).map((record) => ({ id: record.id, result: 'not sent' })));
116
+ print.list(rows, [
117
+ { header: 'id', value: (row) => row.id },
118
+ { header: 'result', value: (row) => row.result },
119
+ { header: 'error', value: (row) => errorText(asJson(row.error)) },
120
+ ]);
121
+ if (failure !== undefined) {
122
+ throw failure;
123
+ }
124
+ const failed = rows.filter((row) => row.result !== 'updated').length;
125
+ if (failed > 0) {
126
+ throw new ApiError(`${failed} of ${rows.length} records were not updated.`);
127
+ }
128
+ }
41
129
  /** The record inside a get/upsert answer, whatever key the endpoint wraps it in. */
42
130
  function recordOf(data) {
43
131
  const inner = asJson(data.data ?? data);
@@ -93,4 +181,13 @@ export function registerCrm(program, context) {
93
181
  const record = recordOf(data);
94
182
  ctx.print.record(record, `Saved ${object} record ${record.id ?? ''}.`.replace(' .', '.'));
95
183
  });
184
+ crm
185
+ .command('update <object>')
186
+ .description(`Update many CRM records in one go, up to 50 per request (${objectHelp})`)
187
+ .requiredOption('--data <json>', 'a JSON array of { "id": …, …fields } objects, inline or as @file.json')
188
+ .action(async (object, options) => {
189
+ const records = await parseUpdateRecords(options.data);
190
+ const ctx = context();
191
+ await updateRecords(sender(await ctx.api(), object), records, ctx.print);
192
+ });
96
193
  }
@@ -31,11 +31,15 @@ export function clockTime(name) {
31
31
  export function collect(value, previous = []) {
32
32
  return [...previous, value];
33
33
  }
34
- /** `--data '{"a":1}'` or `--data @record.json`. */
35
- export async function jsonArgument(raw) {
36
- const text = raw.startsWith('@') ? await readFile(raw.slice(1), 'utf8').catch(() => {
34
+ /** The text of an inline argument, or of the file when it is written `@file.json`. */
35
+ export async function readJsonText(raw) {
36
+ return raw.startsWith('@') ? readFile(raw.slice(1), 'utf8').catch(() => {
37
37
  throw new UsageError(`Could not read ${raw.slice(1)}.`);
38
38
  }) : raw;
39
+ }
40
+ /** `--data '{"a":1}'` or `--data @record.json`. */
41
+ export async function jsonArgument(raw) {
42
+ const text = await readJsonText(raw);
39
43
  try {
40
44
  const value = JSON.parse(text);
41
45
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeegme/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Zeeg command line: bookings, availability, notes, CRM, routing forms and AI phone calls from your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",