@dench.com/cli 2.2.5 → 2.2.6

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
@@ -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 --cells '{"Name":"…","Status":"Open"}'`
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. For ad-hoc requests, work directly.
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, { objectName, data }),
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 _objectName = shift(ctx.args, "object name");
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
- dench crm cells set task --entry <id> --field Owner --value <userId>
2811
- dench crm entries create task --data '{"Name":"X","Owner":"<userId>"}'
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/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
- dench crm cells set task --entry <entryId> --field Owner --value <userId>
186
- dench crm entries create task --data '{"Name":"Ship CRM","Owner":"<userId>"}'
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.5",
3
+ "version": "2.2.6",
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": {