@dench.com/cli 2.8.3 → 2.8.5-staging.175
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/apps.ts +31 -3
- package/crm.ts +609 -86
- package/dench.ts +126 -21
- package/email.ts +255 -41
- package/lib/cli-args.ts +41 -0
- package/lib/command-registry.ts +13 -3
- package/lib/levenshtein.ts +52 -0
- package/package.json +1 -1
- package/tools.ts +98 -8
package/crm.ts
CHANGED
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
getFlag,
|
|
36
36
|
getFlagWithAliases,
|
|
37
37
|
hasFlag,
|
|
38
|
+
type ParsedLimit,
|
|
38
39
|
parseJson as parseJsonRaw,
|
|
40
|
+
parseLimit as parseLimitRaw,
|
|
39
41
|
shift as shiftRaw,
|
|
40
42
|
} from "./lib/cli-args";
|
|
41
43
|
import {
|
|
@@ -57,6 +59,7 @@ import {
|
|
|
57
59
|
enrichCompany,
|
|
58
60
|
enrichPerson,
|
|
59
61
|
} from "./lib/enrichment-gateway";
|
|
62
|
+
import { closestMatch, levenshteinDistance } from "./lib/levenshtein";
|
|
60
63
|
|
|
61
64
|
type JsonRecord = Record<string, unknown>;
|
|
62
65
|
|
|
@@ -175,9 +178,18 @@ function withCrmEntryDeepLinks(
|
|
|
175
178
|
objectName,
|
|
176
179
|
entryId,
|
|
177
180
|
});
|
|
181
|
+
const record = value as JsonRecord;
|
|
178
182
|
return {
|
|
179
|
-
...
|
|
183
|
+
...record,
|
|
184
|
+
// Alias `id` ↔ `entryId`: server responses expose `entryId`, but
|
|
185
|
+
// agents overwhelmingly `jq .id` (the shape `entries list` returns).
|
|
186
|
+
// A null there caused retry-loops that created duplicate rows.
|
|
187
|
+
...(typeof record.id === "string" && record.id.trim()
|
|
188
|
+
? {}
|
|
189
|
+
: { id: entryId }),
|
|
180
190
|
href: links.href,
|
|
191
|
+
// `url` stays conditional on host+workspaceSlug being known — an
|
|
192
|
+
// absolute link with a guessed origin is worse than none.
|
|
181
193
|
...(links.url ? { url: links.url } : {}),
|
|
182
194
|
};
|
|
183
195
|
}
|
|
@@ -221,6 +233,71 @@ function shift(args: string[], expected: string): string {
|
|
|
221
233
|
}
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
/**
|
|
237
|
+
* shift() for name-bearing positionals (object/field/group names). A
|
|
238
|
+
* value starting with `--` is always a mis-ordered flag, never a real
|
|
239
|
+
* name — `dench crm fields create lead --name "Fit Score"` used to
|
|
240
|
+
* create a literal field named "--name" in production CRMs. Fail fast
|
|
241
|
+
* with the correct usage instead of writing the flag as data.
|
|
242
|
+
*/
|
|
243
|
+
function shiftName(args: string[], expected: string, usage: string): string {
|
|
244
|
+
const value = shift(args, expected);
|
|
245
|
+
if (value.startsWith("--")) {
|
|
246
|
+
const label = expected.charAt(0).toUpperCase() + expected.slice(1);
|
|
247
|
+
throw new CrmCliError(
|
|
248
|
+
`${label} cannot start with "--". The name is positional: ${usage}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Parse `--limit` against a server-side cap and make any clamping LOUD.
|
|
256
|
+
* The server clamps silently, which historically let agents dedupe /
|
|
257
|
+
* count over a subset believing it was the full dataset. The warning
|
|
258
|
+
* always goes to stderr so `--json` stdout stays valid JSON; callers
|
|
259
|
+
* whose output is a JSON object should also surface `limitClamped`.
|
|
260
|
+
*/
|
|
261
|
+
function resolveLimitFlag(
|
|
262
|
+
args: string[],
|
|
263
|
+
opts: {
|
|
264
|
+
defaultValue: number;
|
|
265
|
+
cap: number;
|
|
266
|
+
command: string;
|
|
267
|
+
fullDatasetHint?: string;
|
|
268
|
+
},
|
|
269
|
+
): ParsedLimit {
|
|
270
|
+
const raw = getFlag(args, "--limit");
|
|
271
|
+
let parsed: ParsedLimit;
|
|
272
|
+
try {
|
|
273
|
+
parsed = parseLimitRaw(raw, opts);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof CliArgError) throw new CrmCliError(error.message);
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
if (parsed.clamped) {
|
|
279
|
+
process.stderr.write(
|
|
280
|
+
`warning: --limit ${parsed.requested} exceeds the ${opts.cap}-row cap for ` +
|
|
281
|
+
`${opts.command}; returned rows are the newest ${parsed.effective}. ` +
|
|
282
|
+
`${opts.fullDatasetHint ?? "Use 'dench crm export' for full datasets."}\n`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return parsed;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Reject curl-style `--data @rows.json`. The CLI never reads files off
|
|
290
|
+
* an `@` prefix — the literal string "@rows.json" would be parsed as
|
|
291
|
+
* (invalid) JSON, or worse, silently written as a value.
|
|
292
|
+
*/
|
|
293
|
+
function rejectAtFileValue(raw: string | undefined, flag: string): void {
|
|
294
|
+
if (raw !== undefined && raw.trimStart().startsWith("@")) {
|
|
295
|
+
throw new CrmCliError(
|
|
296
|
+
`${flag}: "@file" syntax is not supported. Use --file <path>.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
224
301
|
function parseJson(value: string | undefined): unknown {
|
|
225
302
|
try {
|
|
226
303
|
return parseJsonRaw(value);
|
|
@@ -281,6 +358,7 @@ async function readJsonArrayInput(opts: {
|
|
|
281
358
|
`${opts.command} requires ${opts.inlineFlag} '<json-array>' or --file <path>`,
|
|
282
359
|
);
|
|
283
360
|
}
|
|
361
|
+
rejectAtFileValue(opts.inlineValue, opts.inlineFlag);
|
|
284
362
|
|
|
285
363
|
let raw: string;
|
|
286
364
|
if (filePath) {
|
|
@@ -306,6 +384,71 @@ function parseCreateManyRows(items: unknown[]): JsonRecord[] {
|
|
|
306
384
|
);
|
|
307
385
|
}
|
|
308
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Stable per-row dedupe key derived from a named column. Values are
|
|
389
|
+
* normalized (String → trim → lowercase) so `" Jane@X.com"` and
|
|
390
|
+
* `"jane@x.com"` collapse onto the same key. Throws when a row has no
|
|
391
|
+
* usable value — a blank dedupe cell would silently merge unrelated rows.
|
|
392
|
+
*/
|
|
393
|
+
function dedupeKeyFromColumn(opts: {
|
|
394
|
+
prefix: string;
|
|
395
|
+
column: string;
|
|
396
|
+
row: JsonRecord;
|
|
397
|
+
rowIndex: number; // 0-based
|
|
398
|
+
command: string;
|
|
399
|
+
}): string {
|
|
400
|
+
const raw = opts.row[opts.column];
|
|
401
|
+
const normalized =
|
|
402
|
+
raw === null || raw === undefined ? "" : String(raw).trim().toLowerCase();
|
|
403
|
+
if (!normalized) {
|
|
404
|
+
throw new CrmCliError(
|
|
405
|
+
`${opts.command}: row ${opts.rowIndex + 1} has no value for --dedupe-key column "${opts.column}"`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
return `${opts.prefix}:${normalized}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Deterministic JSON stringification (recursively sorted object keys).
|
|
413
|
+
* Used to derive content-hash dedupe keys for `crm import`: the same
|
|
414
|
+
* source row must map to the same key on every run — the previous
|
|
415
|
+
* implementation keyed on `Date.now()`, which made every re-run of an
|
|
416
|
+
* import duplicate all rows (a 42-row CSV became 84 rows).
|
|
417
|
+
*/
|
|
418
|
+
export function stableJsonStringify(value: unknown): string {
|
|
419
|
+
if (Array.isArray(value)) {
|
|
420
|
+
return `[${value.map(stableJsonStringify).join(",")}]`;
|
|
421
|
+
}
|
|
422
|
+
if (value && typeof value === "object") {
|
|
423
|
+
const record = value as JsonRecord;
|
|
424
|
+
const entries = Object.keys(record)
|
|
425
|
+
.sort()
|
|
426
|
+
.map(
|
|
427
|
+
(key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key])}`,
|
|
428
|
+
);
|
|
429
|
+
return `{${entries.join(",")}}`;
|
|
430
|
+
}
|
|
431
|
+
return JSON.stringify(value) ?? "null";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Count batch.apply result statuses (created / already_existed / updated). */
|
|
435
|
+
function countResultStatuses(results: unknown[]): {
|
|
436
|
+
created: number;
|
|
437
|
+
alreadyExisted: number;
|
|
438
|
+
updated: number;
|
|
439
|
+
} {
|
|
440
|
+
let created = 0;
|
|
441
|
+
let alreadyExisted = 0;
|
|
442
|
+
let updated = 0;
|
|
443
|
+
for (const result of results) {
|
|
444
|
+
const status = isJsonRecord(result) ? result.status : undefined;
|
|
445
|
+
if (status === "already_existed") alreadyExisted += 1;
|
|
446
|
+
else if (status === "updated") updated += 1;
|
|
447
|
+
else created += 1;
|
|
448
|
+
}
|
|
449
|
+
return { created, alreadyExisted, updated };
|
|
450
|
+
}
|
|
451
|
+
|
|
309
452
|
function parseUpdateManyRows(items: unknown[]): Array<{
|
|
310
453
|
id: string;
|
|
311
454
|
data: JsonRecord;
|
|
@@ -356,9 +499,29 @@ function parseCellSetManyRows(items: unknown[]): Array<{
|
|
|
356
499
|
}
|
|
357
500
|
|
|
358
501
|
type CrmBatchOp =
|
|
359
|
-
| {
|
|
502
|
+
| {
|
|
503
|
+
type: "entries.create";
|
|
504
|
+
objectName: string;
|
|
505
|
+
data: JsonRecord;
|
|
506
|
+
/** Stable per-row dedupe key (server upserts on conflict). */
|
|
507
|
+
seedKey?: string;
|
|
508
|
+
/** 1-based source row for server-side error messages. */
|
|
509
|
+
rowIndex?: number;
|
|
510
|
+
}
|
|
360
511
|
| { type: "entries.update"; entryId: string; data: JsonRecord };
|
|
361
512
|
|
|
513
|
+
type CrmOnConflictPolicy = "skip" | "update" | "error";
|
|
514
|
+
|
|
515
|
+
function parseOnConflictFlag(raw: string | undefined): CrmOnConflictPolicy {
|
|
516
|
+
const value = raw ?? "skip";
|
|
517
|
+
if (value === "skip" || value === "update" || value === "error") {
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
throw new CrmCliError(
|
|
521
|
+
`--on-conflict must be skip, update, or error (got "${raw}")`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
362
525
|
function chunkArray<T>(items: T[], size: number): T[][] {
|
|
363
526
|
const chunks: T[][] = [];
|
|
364
527
|
for (let index = 0; index < items.length; index += size) {
|
|
@@ -381,6 +544,7 @@ async function applyBatchOpsInChunks(
|
|
|
381
544
|
ops: CrmBatchOp[],
|
|
382
545
|
idempotencyKey: string | undefined,
|
|
383
546
|
allowUnknown = false,
|
|
547
|
+
onConflict?: CrmOnConflictPolicy,
|
|
384
548
|
): Promise<{ chunks: number; results: unknown[] }> {
|
|
385
549
|
const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
|
|
386
550
|
const results: unknown[] = [];
|
|
@@ -391,6 +555,7 @@ async function applyBatchOpsInChunks(
|
|
|
391
555
|
...runContextArgs(ctx),
|
|
392
556
|
...(chunkKey ? { idempotencyKey: chunkKey } : {}),
|
|
393
557
|
...(allowUnknown ? { allowUnknown: true } : {}),
|
|
558
|
+
...(onConflict ? { onConflict } : {}),
|
|
394
559
|
})) as { results?: unknown[] } | null | undefined;
|
|
395
560
|
if (Array.isArray(batchResult?.results)) {
|
|
396
561
|
results.push(...batchResult.results);
|
|
@@ -428,22 +593,8 @@ async function fetchFieldNames(
|
|
|
428
593
|
}
|
|
429
594
|
}
|
|
430
595
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (a.length === 0) return b.length;
|
|
434
|
-
if (b.length === 0) return a.length;
|
|
435
|
-
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
436
|
-
let curr = new Array<number>(b.length + 1);
|
|
437
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
438
|
-
curr[0] = i;
|
|
439
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
440
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
441
|
-
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
442
|
-
}
|
|
443
|
-
[prev, curr] = [curr, prev];
|
|
444
|
-
}
|
|
445
|
-
return prev[b.length];
|
|
446
|
-
}
|
|
596
|
+
// levenshteinDistance now lives in cli/lib/levenshtein.ts (shared with
|
|
597
|
+
// the `dench apps` dispatcher's "did you mean" suggestions).
|
|
447
598
|
|
|
448
599
|
function suggestKnownField(name: string, validNames: string[]): string | null {
|
|
449
600
|
const lower = name.toLowerCase();
|
|
@@ -719,6 +870,7 @@ const api = {
|
|
|
719
870
|
},
|
|
720
871
|
entries: {
|
|
721
872
|
list: makeFunctionReference<"query">("functions/crm/entries:list"),
|
|
873
|
+
listPage: makeFunctionReference<"query">("functions/crm/entries:listPage"),
|
|
722
874
|
get: makeFunctionReference<"query">("functions/crm/entries:get"),
|
|
723
875
|
create: makeFunctionReference<"mutation">("functions/crm/entries:create"),
|
|
724
876
|
update: makeFunctionReference<"mutation">("functions/crm/entries:update"),
|
|
@@ -874,11 +1026,48 @@ export async function runCrmCommand(opts: {
|
|
|
874
1026
|
// on. Delegates to the shared implementation in cli/members.ts
|
|
875
1027
|
// so payload formatting + flag handling stay in lockstep.
|
|
876
1028
|
return await runCrmMembersCommand(ctx);
|
|
877
|
-
default:
|
|
878
|
-
|
|
1029
|
+
default: {
|
|
1030
|
+
const suggestion = closestMatch(subcommand, CRM_SUBCOMMANDS);
|
|
1031
|
+
throw new CrmCliError(
|
|
1032
|
+
`Unknown crm subcommand: ${subcommand}` +
|
|
1033
|
+
`${suggestion ? `. Did you mean "${suggestion}"?` : ""}` +
|
|
1034
|
+
" Run `dench crm help` for the full list.",
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
879
1037
|
}
|
|
880
1038
|
}
|
|
881
1039
|
|
|
1040
|
+
/** Every verb the `runCrmCommand` switch accepts (for "did you mean"). */
|
|
1041
|
+
const CRM_SUBCOMMANDS = [
|
|
1042
|
+
"objects",
|
|
1043
|
+
"views",
|
|
1044
|
+
"sidebar",
|
|
1045
|
+
"fields",
|
|
1046
|
+
"entries",
|
|
1047
|
+
"history",
|
|
1048
|
+
"cells",
|
|
1049
|
+
"query",
|
|
1050
|
+
"sql",
|
|
1051
|
+
"aggregate",
|
|
1052
|
+
"search",
|
|
1053
|
+
"statuses",
|
|
1054
|
+
"batch",
|
|
1055
|
+
"import",
|
|
1056
|
+
"export",
|
|
1057
|
+
"transaction",
|
|
1058
|
+
"people",
|
|
1059
|
+
"companies",
|
|
1060
|
+
"enrich",
|
|
1061
|
+
"reports",
|
|
1062
|
+
"docs",
|
|
1063
|
+
"documents",
|
|
1064
|
+
"actions",
|
|
1065
|
+
"automations",
|
|
1066
|
+
"templates",
|
|
1067
|
+
"members",
|
|
1068
|
+
"help",
|
|
1069
|
+
] as const;
|
|
1070
|
+
|
|
882
1071
|
/**
|
|
883
1072
|
* `dench crm automations …` — deterministic CRM rules (installed by
|
|
884
1073
|
* templates, e.g. "qualified prospect → opportunity"). These fire inside
|
|
@@ -1026,7 +1215,11 @@ async function runSidebarCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1026
1215
|
const groupVerb = ctx.args.shift();
|
|
1027
1216
|
switch (groupVerb) {
|
|
1028
1217
|
case "create": {
|
|
1029
|
-
const name =
|
|
1218
|
+
const name = shiftName(
|
|
1219
|
+
ctx.args,
|
|
1220
|
+
"group name",
|
|
1221
|
+
'dench crm sidebar group create "<name>"',
|
|
1222
|
+
);
|
|
1030
1223
|
assertNoUnknownFlags(ctx.args, "crm sidebar group create");
|
|
1031
1224
|
out(ctx, await callMutation(ctx, api.sidebar.createGroup, { name }));
|
|
1032
1225
|
return;
|
|
@@ -1105,7 +1298,11 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1105
1298
|
return;
|
|
1106
1299
|
}
|
|
1107
1300
|
case "create": {
|
|
1108
|
-
const name =
|
|
1301
|
+
const name = shiftName(
|
|
1302
|
+
ctx.args,
|
|
1303
|
+
"object name",
|
|
1304
|
+
'dench crm objects create "<name>" [--description …] [--icon …]',
|
|
1305
|
+
);
|
|
1109
1306
|
const description = getFlag(ctx.args, "--description");
|
|
1110
1307
|
const defaultView = getFlag(ctx.args, "--default-view") as any;
|
|
1111
1308
|
const icon = getFlag(ctx.args, "--icon");
|
|
@@ -1147,7 +1344,11 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1147
1344
|
}
|
|
1148
1345
|
case "rename": {
|
|
1149
1346
|
const from = shift(ctx.args, "current name");
|
|
1150
|
-
const to =
|
|
1347
|
+
const to = shiftName(
|
|
1348
|
+
ctx.args,
|
|
1349
|
+
"new name",
|
|
1350
|
+
'dench crm objects rename <from> "<to>"',
|
|
1351
|
+
);
|
|
1151
1352
|
assertNoUnknownFlags(ctx.args, "crm objects rename");
|
|
1152
1353
|
out(ctx, await callMutation(ctx, api.objects.rename, { from, to }));
|
|
1153
1354
|
return;
|
|
@@ -1630,7 +1831,13 @@ async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1630
1831
|
case "rename": {
|
|
1631
1832
|
const objectName = shift(ctx.args, "object name");
|
|
1632
1833
|
const viewName = shift(ctx.args, "view name");
|
|
1633
|
-
const newName =
|
|
1834
|
+
const newName =
|
|
1835
|
+
getFlag(ctx.args, "--to") ??
|
|
1836
|
+
shiftName(
|
|
1837
|
+
ctx.args,
|
|
1838
|
+
"new name",
|
|
1839
|
+
'dench crm views rename <object> "<view>" "<new name>"',
|
|
1840
|
+
);
|
|
1634
1841
|
assertNoUnknownFlags(ctx.args, "crm views rename");
|
|
1635
1842
|
if (!newName.trim()) {
|
|
1636
1843
|
throw new CrmCliError(
|
|
@@ -1709,7 +1916,11 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1709
1916
|
}
|
|
1710
1917
|
case "create": {
|
|
1711
1918
|
const objectName = shift(ctx.args, "object name");
|
|
1712
|
-
const name =
|
|
1919
|
+
const name = shiftName(
|
|
1920
|
+
ctx.args,
|
|
1921
|
+
"field name",
|
|
1922
|
+
'dench crm fields create <object> "<Name>" --type <type>',
|
|
1923
|
+
);
|
|
1713
1924
|
const type = (getFlag(ctx.args, "--type") ?? "text") as any;
|
|
1714
1925
|
const required = hasFlag(ctx.args, "--required");
|
|
1715
1926
|
const description = getFlag(ctx.args, "--description");
|
|
@@ -1919,14 +2130,20 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1919
2130
|
switch (verb) {
|
|
1920
2131
|
case "list": {
|
|
1921
2132
|
const objectName = shift(ctx.args, "object name");
|
|
1922
|
-
const limit =
|
|
2133
|
+
const limit = resolveLimitFlag(ctx.args, {
|
|
2134
|
+
defaultValue: 100,
|
|
2135
|
+
cap: 1000,
|
|
2136
|
+
command: "crm entries list",
|
|
2137
|
+
});
|
|
1923
2138
|
const withMeta = hasFlag(ctx.args, "--with-meta");
|
|
1924
2139
|
assertNoUnknownFlags(ctx.args, "crm entries list");
|
|
1925
2140
|
const entries = await callQuery(ctx, api.entries.list, {
|
|
1926
2141
|
objectName,
|
|
1927
|
-
limit,
|
|
2142
|
+
limit: limit.effective,
|
|
1928
2143
|
});
|
|
1929
2144
|
if (!withMeta) {
|
|
2145
|
+
// Bare-array output: the clamp warning already went to stderr
|
|
2146
|
+
// (stdout must stay a valid JSON array in --json mode).
|
|
1930
2147
|
out(ctx, entries);
|
|
1931
2148
|
return;
|
|
1932
2149
|
}
|
|
@@ -1942,7 +2159,15 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1942
2159
|
objectName,
|
|
1943
2160
|
totalCount,
|
|
1944
2161
|
returnedCount,
|
|
1945
|
-
limit,
|
|
2162
|
+
limit: limit.effective,
|
|
2163
|
+
...(limit.clamped
|
|
2164
|
+
? {
|
|
2165
|
+
limitClamped: {
|
|
2166
|
+
requested: limit.requested,
|
|
2167
|
+
effective: limit.effective,
|
|
2168
|
+
},
|
|
2169
|
+
}
|
|
2170
|
+
: {}),
|
|
1946
2171
|
hasMore: totalCount > returnedCount,
|
|
1947
2172
|
entries,
|
|
1948
2173
|
});
|
|
@@ -1968,6 +2193,11 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1968
2193
|
case "create": {
|
|
1969
2194
|
const objectName = shift(ctx.args, "object name");
|
|
1970
2195
|
const dataRaw = getDataFlag(ctx.args);
|
|
2196
|
+
// Optional idempotency: the server upserts on (object, key), so a
|
|
2197
|
+
// retried create (agent saw a null `.id`, ran the command again)
|
|
2198
|
+
// returns the existing row with `alreadyExisted: true` instead of
|
|
2199
|
+
// duplicating it.
|
|
2200
|
+
const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
|
|
1971
2201
|
const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
|
|
1972
2202
|
assertNoUnknownFlags(ctx.args, "crm entries create");
|
|
1973
2203
|
// Refuse silent blank-row creation. The agent that triggered this
|
|
@@ -1980,12 +2210,18 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1980
2210
|
'dench crm entries create requires --data \'{"Field":"Value",...}\' (alias: --fields)',
|
|
1981
2211
|
);
|
|
1982
2212
|
}
|
|
2213
|
+
rejectAtFileValue(dataRaw, "--data");
|
|
1983
2214
|
const data = (parseJson(dataRaw) as JsonRecord | undefined) ?? {};
|
|
1984
2215
|
if (!data || Object.keys(data).length === 0) {
|
|
1985
2216
|
throw new CrmCliError(
|
|
1986
2217
|
'dench crm entries create: --data must include at least one field. Pass --data \'{"Name":"…"}\' or use cells set after creation.',
|
|
1987
2218
|
);
|
|
1988
2219
|
}
|
|
2220
|
+
if (idempotencyKey !== undefined && !idempotencyKey.trim()) {
|
|
2221
|
+
throw new CrmCliError(
|
|
2222
|
+
"dench crm entries create: --idempotency-key must be a non-empty string",
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
1989
2225
|
// Single-row writes rely on the server's schema validation (which
|
|
1990
2226
|
// returns the same "did you mean …" hint the CLI would). No local
|
|
1991
2227
|
// pre-fetch — that would add a round-trip to every create.
|
|
@@ -1999,6 +2235,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
1999
2235
|
data,
|
|
2000
2236
|
...runContextArgs(ctx),
|
|
2001
2237
|
...(allowUnknown ? { allowUnknown: true } : {}),
|
|
2238
|
+
...(idempotencyKey ? { seedKey: idempotencyKey.trim() } : {}),
|
|
2002
2239
|
}),
|
|
2003
2240
|
),
|
|
2004
2241
|
);
|
|
@@ -2008,6 +2245,10 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2008
2245
|
const objectName = shift(ctx.args, "object name");
|
|
2009
2246
|
const dataRaw = getDataFlag(ctx.args);
|
|
2010
2247
|
const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
|
|
2248
|
+
// Per-row idempotency: derive a stable seedKey from this column so
|
|
2249
|
+
// re-running the same create-many cannot duplicate rows. Rows whose
|
|
2250
|
+
// key already exists are skipped (`status: "already_existed"`).
|
|
2251
|
+
const dedupeColumn = getFlag(ctx.args, "--dedupe-key");
|
|
2011
2252
|
const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
|
|
2012
2253
|
const rows = parseCreateManyRows(
|
|
2013
2254
|
await readJsonArrayInput({
|
|
@@ -2018,23 +2259,43 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2018
2259
|
}),
|
|
2019
2260
|
);
|
|
2020
2261
|
await assertKnownDataKeys(ctx, objectName, rows, allowUnknown);
|
|
2262
|
+
const seedKeys = dedupeColumn
|
|
2263
|
+
? rows.map((row, index) =>
|
|
2264
|
+
dedupeKeyFromColumn({
|
|
2265
|
+
prefix: `many:${objectName}:${dedupeColumn}`,
|
|
2266
|
+
column: dedupeColumn,
|
|
2267
|
+
row,
|
|
2268
|
+
rowIndex: index,
|
|
2269
|
+
command: "crm entries create-many",
|
|
2270
|
+
}),
|
|
2271
|
+
)
|
|
2272
|
+
: null;
|
|
2021
2273
|
const { chunks, results } = await applyBatchOpsInChunks(
|
|
2022
2274
|
ctx,
|
|
2023
|
-
rows.map((data) => ({
|
|
2275
|
+
rows.map((data, index) => ({
|
|
2024
2276
|
type: "entries.create",
|
|
2025
2277
|
objectName,
|
|
2026
2278
|
data,
|
|
2279
|
+
...(seedKeys
|
|
2280
|
+
? { seedKey: seedKeys[index], rowIndex: index + 1 }
|
|
2281
|
+
: {}),
|
|
2027
2282
|
})),
|
|
2028
2283
|
idempotencyKey,
|
|
2029
2284
|
allowUnknown,
|
|
2285
|
+
seedKeys ? "skip" : undefined,
|
|
2030
2286
|
);
|
|
2031
2287
|
// Only claim per-row links when we actually got a result per row. A
|
|
2032
2288
|
// silently-empty `results` is indistinguishable from "this object has
|
|
2033
2289
|
// no links" and sends the agent right back to inventing URLs — the
|
|
2034
2290
|
// exact failure this command's `url`/`href` output exists to prevent.
|
|
2035
2291
|
const linked = results.length;
|
|
2292
|
+
const statusCounts =
|
|
2293
|
+
seedKeys && results.length > 0 ? countResultStatuses(results) : null;
|
|
2036
2294
|
out(ctx, {
|
|
2037
|
-
created: rows.length,
|
|
2295
|
+
created: statusCounts ? statusCounts.created : rows.length,
|
|
2296
|
+
...(statusCounts
|
|
2297
|
+
? { alreadyExisted: statusCounts.alreadyExisted }
|
|
2298
|
+
: {}),
|
|
2038
2299
|
chunks,
|
|
2039
2300
|
...(linked > 0
|
|
2040
2301
|
? {
|
|
@@ -2076,6 +2337,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2076
2337
|
'dench crm entries update requires --data \'{"Field":"Value",...}\' (alias: --fields)',
|
|
2077
2338
|
);
|
|
2078
2339
|
}
|
|
2340
|
+
rejectAtFileValue(dataRaw, "--data");
|
|
2079
2341
|
const data = (parseJson(dataRaw) as JsonRecord | undefined) ?? {};
|
|
2080
2342
|
if (!data || Object.keys(data).length === 0) {
|
|
2081
2343
|
throw new CrmCliError(
|
|
@@ -2144,17 +2406,36 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2144
2406
|
}
|
|
2145
2407
|
case "bulk-delete":
|
|
2146
2408
|
case "bulkDelete": {
|
|
2147
|
-
const
|
|
2409
|
+
const objectName = shift(ctx.args, "object name");
|
|
2148
2410
|
const idsCsv = getFlag(ctx.args, "--ids") ?? "";
|
|
2149
2411
|
assertNoUnknownFlags(ctx.args, "crm entries bulk-delete");
|
|
2150
|
-
const entryIds = idsCsv
|
|
2151
|
-
|
|
2412
|
+
const entryIds = idsCsv
|
|
2413
|
+
.split(",")
|
|
2414
|
+
.map((id) => id.trim())
|
|
2415
|
+
.filter(Boolean);
|
|
2416
|
+
if (entryIds.length === 0) {
|
|
2417
|
+
throw new CrmCliError(
|
|
2418
|
+
"dench crm entries bulk-delete requires --ids id1,id2,…",
|
|
2419
|
+
);
|
|
2420
|
+
}
|
|
2421
|
+
// Automatic undo file: snapshot the target rows to JSONL BEFORE
|
|
2422
|
+
// deleting. A bulk-delete driven by a buggy dedupe script is only
|
|
2423
|
+
// recoverable if the pre-delete state exists somewhere — this file
|
|
2424
|
+
// is that somewhere. Snapshot failure aborts the delete.
|
|
2425
|
+
const snapshot = await snapshotEntriesBeforeBulkDelete(
|
|
2152
2426
|
ctx,
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
...runContextArgs(ctx),
|
|
2156
|
-
}),
|
|
2427
|
+
objectName,
|
|
2428
|
+
entryIds,
|
|
2157
2429
|
);
|
|
2430
|
+
const result = await callMutation(ctx, api.entries.bulkDelete, {
|
|
2431
|
+
entryIds: entryIds as any,
|
|
2432
|
+
...runContextArgs(ctx),
|
|
2433
|
+
});
|
|
2434
|
+
out(ctx, {
|
|
2435
|
+
...(isJsonRecord(result) ? result : { result }),
|
|
2436
|
+
snapshotPath: snapshot.path,
|
|
2437
|
+
snapshotRows: snapshot.rows,
|
|
2438
|
+
});
|
|
2158
2439
|
return;
|
|
2159
2440
|
}
|
|
2160
2441
|
default:
|
|
@@ -2162,6 +2443,56 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2162
2443
|
}
|
|
2163
2444
|
}
|
|
2164
2445
|
|
|
2446
|
+
/**
|
|
2447
|
+
* Fetch every target row and write it to a JSONL snapshot under the OS
|
|
2448
|
+
* temp dir ($TMPDIR or /tmp). This is an automatic undo file, not an
|
|
2449
|
+
* interactive gate: the delete proceeds after a successful snapshot and
|
|
2450
|
+
* ABORTS if the snapshot cannot be captured or written.
|
|
2451
|
+
*/
|
|
2452
|
+
async function snapshotEntriesBeforeBulkDelete(
|
|
2453
|
+
ctx: CrmCliContext,
|
|
2454
|
+
objectName: string,
|
|
2455
|
+
entryIds: string[],
|
|
2456
|
+
): Promise<{ path: string; rows: number }> {
|
|
2457
|
+
const lines: string[] = [];
|
|
2458
|
+
try {
|
|
2459
|
+
for (const entryId of entryIds) {
|
|
2460
|
+
const entry = await callQuery(ctx, api.entries.get, {
|
|
2461
|
+
objectName,
|
|
2462
|
+
entryId: entryId as any,
|
|
2463
|
+
});
|
|
2464
|
+
lines.push(JSON.stringify(entry));
|
|
2465
|
+
}
|
|
2466
|
+
} catch (error) {
|
|
2467
|
+
throw new CrmCliError(
|
|
2468
|
+
"bulk-delete aborted: could not snapshot the target rows before " +
|
|
2469
|
+
`deleting (${error instanceof Error ? error.message : String(error)}). ` +
|
|
2470
|
+
"No entries were deleted.",
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
const os = await import("node:os");
|
|
2474
|
+
const path = await import("node:path");
|
|
2475
|
+
const fs = await import("node:fs/promises");
|
|
2476
|
+
const snapshotPath = path.join(
|
|
2477
|
+
os.tmpdir(),
|
|
2478
|
+
`dench-bulk-delete-${objectName.replace(/[^A-Za-z0-9._-]+/g, "_")}-${Date.now()}.jsonl`,
|
|
2479
|
+
);
|
|
2480
|
+
try {
|
|
2481
|
+
await fs.writeFile(
|
|
2482
|
+
snapshotPath,
|
|
2483
|
+
lines.length > 0 ? `${lines.join("\n")}\n` : "",
|
|
2484
|
+
"utf-8",
|
|
2485
|
+
);
|
|
2486
|
+
} catch (error) {
|
|
2487
|
+
throw new CrmCliError(
|
|
2488
|
+
`bulk-delete aborted: failed to write snapshot file ${snapshotPath} ` +
|
|
2489
|
+
`(${error instanceof Error ? error.message : String(error)}). ` +
|
|
2490
|
+
"No entries were deleted.",
|
|
2491
|
+
);
|
|
2492
|
+
}
|
|
2493
|
+
return { path: snapshotPath, rows: lines.length };
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2165
2496
|
async function runHistoryCommand(ctx: CrmCliContext): Promise<void> {
|
|
2166
2497
|
const objectName = shift(ctx.args, "object name");
|
|
2167
2498
|
const entryId = shift(ctx.args, "entry id");
|
|
@@ -2280,12 +2611,19 @@ async function runQueryCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2280
2611
|
| undefined;
|
|
2281
2612
|
const select = getFlag(ctx.args, "--select")?.split(",").filter(Boolean);
|
|
2282
2613
|
const sort = getFlag(ctx.args, "--sort");
|
|
2283
|
-
|
|
2614
|
+
// The server clamps at 1000 silently; surface the clamp on stderr so
|
|
2615
|
+
// callers never mistake a truncated page for the full dataset. Output
|
|
2616
|
+
// here is a bare JSON array, so stderr is the only safe channel.
|
|
2617
|
+
const limit = resolveLimitFlag(ctx.args, {
|
|
2618
|
+
defaultValue: 100,
|
|
2619
|
+
cap: 1000,
|
|
2620
|
+
command: "crm query",
|
|
2621
|
+
});
|
|
2284
2622
|
assertNoUnknownFlags(ctx.args, "crm query");
|
|
2285
2623
|
// Convex's wire serializer rejects $-prefixed object keys, so we
|
|
2286
2624
|
// route the DSL through the dslJson string field whenever the filter
|
|
2287
2625
|
// has any operator-shaped clause.
|
|
2288
|
-
const dsl = { filter, select, sort, limit };
|
|
2626
|
+
const dsl = { filter, select, sort, limit: limit.effective };
|
|
2289
2627
|
const hasDollarKey = filter ? containsDollarKey(filter) : false;
|
|
2290
2628
|
out(
|
|
2291
2629
|
ctx,
|
|
@@ -2324,14 +2662,27 @@ async function runAggregateCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2324
2662
|
|
|
2325
2663
|
async function runSearchCommand(ctx: CrmCliContext): Promise<void> {
|
|
2326
2664
|
const objectName = getFlag(ctx.args, "--object");
|
|
2327
|
-
|
|
2665
|
+
// Search caps at 100 server-side. Warn on stderr when clamped (the
|
|
2666
|
+
// output is a bare JSON array, so stdout must stay untouched).
|
|
2667
|
+
const limit = resolveLimitFlag(ctx.args, {
|
|
2668
|
+
defaultValue: 50,
|
|
2669
|
+
cap: 100,
|
|
2670
|
+
command: "crm search",
|
|
2671
|
+
});
|
|
2328
2672
|
// Drain known flags before joining the rest into the search text so a
|
|
2329
2673
|
// typo like `--objct` surfaces instead of being concatenated into the
|
|
2330
2674
|
// query string.
|
|
2331
2675
|
assertNoUnknownFlags(ctx.args, "crm search");
|
|
2332
2676
|
const text = ctx.args.join(" ").trim();
|
|
2333
2677
|
if (!text) throw new CrmCliError("Search text required");
|
|
2334
|
-
out(
|
|
2678
|
+
out(
|
|
2679
|
+
ctx,
|
|
2680
|
+
await callQuery(ctx, api.query.search, {
|
|
2681
|
+
text,
|
|
2682
|
+
objectName,
|
|
2683
|
+
limit: limit.effective,
|
|
2684
|
+
}),
|
|
2685
|
+
);
|
|
2335
2686
|
}
|
|
2336
2687
|
|
|
2337
2688
|
async function runStatusesCommand(ctx: CrmCliContext): Promise<void> {
|
|
@@ -2681,15 +3032,44 @@ async function runTransactionCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2681
3032
|
|
|
2682
3033
|
// ── Import / export ──────────────────────────────────────────────────────
|
|
2683
3034
|
|
|
3035
|
+
/**
|
|
3036
|
+
* Compute the stable per-row dedupe key for `crm import`.
|
|
3037
|
+
*
|
|
3038
|
+
* Default: content hash — `import:<object>:<sha256(stable JSON of row)>`.
|
|
3039
|
+
* With `--dedupe-key <column>`: `import:<object>:<column>:<normalized
|
|
3040
|
+
* value>` (trim + lowercase).
|
|
3041
|
+
*
|
|
3042
|
+
* Both are deterministic across runs — re-running the same import file
|
|
3043
|
+
* hits the same keys and the server's on-conflict policy applies instead
|
|
3044
|
+
* of duplicating every row (the old key embedded `Date.now()`, which
|
|
3045
|
+
* turned a re-run of a 42-row CSV into 84 rows).
|
|
3046
|
+
*/
|
|
3047
|
+
export function importSeedKeyForRow(opts: {
|
|
3048
|
+
objectName: string;
|
|
3049
|
+
row: Record<string, unknown>;
|
|
3050
|
+
rowIndex: number; // 0-based
|
|
3051
|
+
dedupeColumn?: string;
|
|
3052
|
+
sha256: (input: string) => string;
|
|
3053
|
+
}): string {
|
|
3054
|
+
if (opts.dedupeColumn) {
|
|
3055
|
+
return dedupeKeyFromColumn({
|
|
3056
|
+
prefix: `import:${opts.objectName}:${opts.dedupeColumn}`,
|
|
3057
|
+
column: opts.dedupeColumn,
|
|
3058
|
+
row: opts.row,
|
|
3059
|
+
rowIndex: opts.rowIndex,
|
|
3060
|
+
command: "crm import",
|
|
3061
|
+
});
|
|
3062
|
+
}
|
|
3063
|
+
return `import:${opts.objectName}:${opts.sha256(stableJsonStringify(opts.row))}`;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
2684
3066
|
async function runImportCommand(ctx: CrmCliContext): Promise<void> {
|
|
2685
3067
|
const objectName = shift(ctx.args, "object name");
|
|
2686
3068
|
const csvPath = getFlag(ctx.args, "--csv");
|
|
2687
3069
|
const jsonlPath = getFlag(ctx.args, "--jsonl");
|
|
2688
3070
|
const mapJson = getFlag(ctx.args, "--map");
|
|
2689
|
-
const
|
|
2690
|
-
|
|
2691
|
-
| "update"
|
|
2692
|
-
| "error";
|
|
3071
|
+
const dedupeColumn = getFlag(ctx.args, "--dedupe-key");
|
|
3072
|
+
const onConflict = parseOnConflictFlag(getFlag(ctx.args, "--on-conflict"));
|
|
2693
3073
|
assertNoUnknownFlags(ctx.args, "crm import");
|
|
2694
3074
|
if (!csvPath && !jsonlPath) {
|
|
2695
3075
|
throw new CrmCliError(
|
|
@@ -2712,41 +3092,98 @@ async function runImportCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2712
3092
|
rows.push(remapKeys(parsed, colMap));
|
|
2713
3093
|
}
|
|
2714
3094
|
}
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
3095
|
+
const { createHash } = await import("node:crypto");
|
|
3096
|
+
const sha256 = (input: string) =>
|
|
3097
|
+
createHash("sha256").update(input).digest("hex");
|
|
3098
|
+
const ops: CrmBatchOp[] = rows.map((data, index) => ({
|
|
3099
|
+
type: "entries.create",
|
|
3100
|
+
objectName,
|
|
3101
|
+
data,
|
|
3102
|
+
seedKey: importSeedKeyForRow({
|
|
2721
3103
|
objectName,
|
|
2722
|
-
data,
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
3104
|
+
row: data,
|
|
3105
|
+
rowIndex: index,
|
|
3106
|
+
dedupeColumn,
|
|
3107
|
+
sha256,
|
|
3108
|
+
}),
|
|
3109
|
+
rowIndex: index + 1,
|
|
3110
|
+
}));
|
|
3111
|
+
// batch.apply enforces the on-conflict policy per row against the
|
|
3112
|
+
// (objectId, seedKey) index; chunking is handled by the shared helper.
|
|
3113
|
+
const { results } = await applyBatchOpsInChunks(
|
|
3114
|
+
ctx,
|
|
3115
|
+
ops,
|
|
3116
|
+
undefined,
|
|
3117
|
+
false,
|
|
3118
|
+
onConflict,
|
|
3119
|
+
);
|
|
3120
|
+
const counts = countResultStatuses(results);
|
|
3121
|
+
out(ctx, {
|
|
3122
|
+
objectName,
|
|
3123
|
+
sourceRowsParsed: rows.length,
|
|
3124
|
+
imported: counts.created,
|
|
3125
|
+
skipped: counts.alreadyExisted,
|
|
3126
|
+
updated: counts.updated,
|
|
3127
|
+
errors: 0,
|
|
3128
|
+
onConflict,
|
|
3129
|
+
...(dedupeColumn ? { dedupeKey: dedupeColumn } : {}),
|
|
3130
|
+
});
|
|
2731
3131
|
}
|
|
2732
3132
|
|
|
2733
3133
|
async function runExportCommand(ctx: CrmCliContext): Promise<void> {
|
|
2734
3134
|
const objectName = shift(ctx.args, "object name");
|
|
2735
3135
|
const format = (getFlag(ctx.args, "--format") ?? "jsonl") as "csv" | "jsonl";
|
|
2736
|
-
const limit =
|
|
3136
|
+
const limit = resolveLimitFlag(ctx.args, {
|
|
3137
|
+
defaultValue: 10000,
|
|
3138
|
+
cap: Number.MAX_SAFE_INTEGER,
|
|
3139
|
+
command: "crm export",
|
|
3140
|
+
}).effective;
|
|
2737
3141
|
assertNoUnknownFlags(ctx.args, "crm export");
|
|
2738
|
-
// Page through entries.
|
|
3142
|
+
// Page through entries.listPage in 1000-row chunks using a real
|
|
3143
|
+
// creation-time cursor. The previous loop called `entries.list` with
|
|
3144
|
+
// no cursor at all, so every iteration re-fetched the newest 1000 rows
|
|
3145
|
+
// — a 10,000-row export was the same 1,000 rows ten times, and the
|
|
3146
|
+
// dedupe script that consumed it deleted ~8,000 real accounts.
|
|
2739
3147
|
const all: any[] = [];
|
|
2740
|
-
let cursor =
|
|
3148
|
+
let cursor: string | null = null;
|
|
2741
3149
|
while (all.length < limit) {
|
|
2742
|
-
const
|
|
3150
|
+
const result = (await callQuery(ctx, api.entries.listPage, {
|
|
2743
3151
|
objectName,
|
|
2744
3152
|
limit: Math.min(1000, limit - all.length),
|
|
2745
|
-
|
|
2746
|
-
|
|
3153
|
+
...(cursor !== null ? { cursor } : {}),
|
|
3154
|
+
})) as {
|
|
3155
|
+
page?: unknown[];
|
|
3156
|
+
isDone?: boolean;
|
|
3157
|
+
continueCursor?: string | null;
|
|
3158
|
+
truncated?: boolean;
|
|
3159
|
+
} | null;
|
|
3160
|
+
const page = Array.isArray(result?.page) ? result.page : [];
|
|
2747
3161
|
all.push(...page);
|
|
2748
|
-
|
|
2749
|
-
|
|
3162
|
+
if (result?.truncated) {
|
|
3163
|
+
process.stderr.write(
|
|
3164
|
+
`warning: crm export of "${objectName}" is capped at ${page.length} rows ` +
|
|
3165
|
+
"(personal objects load single-shot); the export may be incomplete.\n",
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
if (!result || result.isDone !== false) break;
|
|
3169
|
+
const next = result.continueCursor ?? null;
|
|
3170
|
+
if (!next || next === cursor) break; // defensive: never loop in place
|
|
3171
|
+
cursor = next;
|
|
3172
|
+
}
|
|
3173
|
+
// Invariant guard: one export must never contain the same entry twice.
|
|
3174
|
+
// Duplicates mean pagination is broken and the file is unsafe to feed
|
|
3175
|
+
// into any dedupe/migration script — fail hard rather than emit it.
|
|
3176
|
+
const ids = all.map((entry) => String(entry?.id ?? ""));
|
|
3177
|
+
const uniqueIds = new Set(ids).size;
|
|
3178
|
+
if (uniqueIds !== all.length) {
|
|
3179
|
+
process.stderr.write(
|
|
3180
|
+
`error: crm export produced duplicate rows (${all.length} rows, ` +
|
|
3181
|
+
`${uniqueIds} unique entry ids). Pagination invariant violated — ` +
|
|
3182
|
+
"no output was written.\n",
|
|
3183
|
+
);
|
|
3184
|
+
throw new CrmCliError(
|
|
3185
|
+
`crm export: duplicate entry ids detected (${all.length} rows, ${uniqueIds} unique). Export aborted.`,
|
|
3186
|
+
);
|
|
2750
3187
|
}
|
|
2751
3188
|
if (format === "csv") {
|
|
2752
3189
|
const fields = Object.keys(all[0]?.fields ?? {});
|
|
@@ -2766,21 +3203,34 @@ async function runExportCommand(ctx: CrmCliContext): Promise<void> {
|
|
|
2766
3203
|
);
|
|
2767
3204
|
}
|
|
2768
3205
|
}
|
|
3206
|
+
// Verification trailer (stderr — stdout is the data stream): row count
|
|
3207
|
+
// and unique-id count so callers can assert integrity in scripts.
|
|
3208
|
+
process.stderr.write(
|
|
3209
|
+
`crm export ${objectName}: exportedCount=${all.length} uniqueIds=${uniqueIds}\n`,
|
|
3210
|
+
);
|
|
2769
3211
|
}
|
|
2770
3212
|
|
|
2771
3213
|
export function parseCsv(
|
|
2772
3214
|
text: string,
|
|
2773
3215
|
colMap: Record<string, string>,
|
|
2774
3216
|
): Record<string, unknown>[] {
|
|
2775
|
-
//
|
|
2776
|
-
//
|
|
2777
|
-
//
|
|
3217
|
+
// Full-stream CSV parser: comma separator, double-quote escaping,
|
|
3218
|
+
// QUOTED CELLS SPANNING MULTIPLE LINES, and CRLF endings. The previous
|
|
3219
|
+
// implementation split on newlines before parsing quotes, so any CSV
|
|
3220
|
+
// with a multiline Notes cell was silently corrupted (a 217-row file
|
|
3221
|
+
// collapsed to 42 parsed rows). Header names are trimmed (" Email" →
|
|
3222
|
+
// "Email"); both the raw and the trimmed header resolve via colMap.
|
|
3223
|
+
const records = parseCsvRecords(text).filter((cells) =>
|
|
3224
|
+
cells.some((cell) => cell.trim().length > 0),
|
|
3225
|
+
);
|
|
3226
|
+
if (records.length === 0) return [];
|
|
3227
|
+
const header = records[0].map((col) => {
|
|
3228
|
+
const trimmed = col.trim();
|
|
3229
|
+
return colMap[trimmed] ?? colMap[col] ?? trimmed;
|
|
3230
|
+
});
|
|
2778
3231
|
const rows: Record<string, unknown>[] = [];
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
const header = parseCsvLine(lines[0]).map((col) => colMap[col] ?? col);
|
|
2782
|
-
for (let i = 1; i < lines.length; i++) {
|
|
2783
|
-
const cols = parseCsvLine(lines[i]);
|
|
3232
|
+
for (let i = 1; i < records.length; i++) {
|
|
3233
|
+
const cols = records[i];
|
|
2784
3234
|
const row: Record<string, unknown> = {};
|
|
2785
3235
|
for (let j = 0; j < header.length; j++) {
|
|
2786
3236
|
row[header[j]] = cols[j] ?? null;
|
|
@@ -2790,6 +3240,61 @@ export function parseCsv(
|
|
|
2790
3240
|
return rows;
|
|
2791
3241
|
}
|
|
2792
3242
|
|
|
3243
|
+
/**
|
|
3244
|
+
* Split raw CSV text into records of cells, walking the text as ONE
|
|
3245
|
+
* character stream (never line-splitting first) so quoted cells can
|
|
3246
|
+
* contain literal newlines. Quote semantics match `parseCsvLine`: a
|
|
3247
|
+
* quote only opens a quoted section at the start of a cell, and `""`
|
|
3248
|
+
* inside quotes is an escaped quote.
|
|
3249
|
+
*/
|
|
3250
|
+
export function parseCsvRecords(text: string): string[][] {
|
|
3251
|
+
const records: string[][] = [];
|
|
3252
|
+
let record: string[] = [];
|
|
3253
|
+
let buf = "";
|
|
3254
|
+
let inQuotes = false;
|
|
3255
|
+
let sawAny = false; // any char consumed into the current record
|
|
3256
|
+
for (let i = 0; i < text.length; i++) {
|
|
3257
|
+
const ch = text[i];
|
|
3258
|
+
if (inQuotes) {
|
|
3259
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
3260
|
+
buf += '"';
|
|
3261
|
+
i++;
|
|
3262
|
+
} else if (ch === '"') {
|
|
3263
|
+
inQuotes = false;
|
|
3264
|
+
} else {
|
|
3265
|
+
// Includes \n / \r — this is what makes multiline cells work.
|
|
3266
|
+
buf += ch;
|
|
3267
|
+
}
|
|
3268
|
+
continue;
|
|
3269
|
+
}
|
|
3270
|
+
if (ch === ",") {
|
|
3271
|
+
record.push(buf);
|
|
3272
|
+
buf = "";
|
|
3273
|
+
sawAny = true;
|
|
3274
|
+
} else if (ch === '"' && buf === "") {
|
|
3275
|
+
inQuotes = true;
|
|
3276
|
+
sawAny = true;
|
|
3277
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
3278
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
3279
|
+
record.push(buf);
|
|
3280
|
+
records.push(record);
|
|
3281
|
+
record = [];
|
|
3282
|
+
buf = "";
|
|
3283
|
+
sawAny = false;
|
|
3284
|
+
} else {
|
|
3285
|
+
buf += ch;
|
|
3286
|
+
sawAny = true;
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
// Trailing record without a final newline (or an unterminated quote —
|
|
3290
|
+
// best-effort: keep what was read).
|
|
3291
|
+
if (sawAny || buf.length > 0 || record.length > 0) {
|
|
3292
|
+
record.push(buf);
|
|
3293
|
+
records.push(record);
|
|
3294
|
+
}
|
|
3295
|
+
return records;
|
|
3296
|
+
}
|
|
3297
|
+
|
|
2793
3298
|
export function parseCsvLine(line: string): string[] {
|
|
2794
3299
|
const out: string[] = [];
|
|
2795
3300
|
let buf = "";
|
|
@@ -3721,6 +4226,9 @@ Fields (columns):
|
|
|
3721
4226
|
dench crm fields enum-add <object> <field> <value> [--color HEX] [--position N]
|
|
3722
4227
|
Appends <value> to the field's enumValues. --color is a hex like
|
|
3723
4228
|
"#22c55e"; --position is a 0-based insertion index (defaults to append).
|
|
4229
|
+
Without --color the option is auto-colored: semantic keyword match
|
|
4230
|
+
(Done/Won → green, Working → blue, At Risk/Pending → yellow,
|
|
4231
|
+
Churned/Lost → red, Todo/New → gray), else a rotating palette.
|
|
3724
4232
|
Idempotent (case-insensitive): re-adding "won" when "Won" exists does
|
|
3725
4233
|
NOT create a twin option — the response's "value" echoes the canonical
|
|
3726
4234
|
casing; a given --color still updates the existing option's color.
|
|
@@ -3728,16 +4236,19 @@ Fields (columns):
|
|
|
3728
4236
|
kanban column (positioned to match), so you do NOT need a separate
|
|
3729
4237
|
"statuses set" call.
|
|
3730
4238
|
dench crm fields enum-remove <object> <field> <value>
|
|
3731
|
-
Removes the value from the option list
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
4239
|
+
Removes the value from the option list AND unsets it on every entry
|
|
4240
|
+
that held it (complete for indexed enum fields — the default). A
|
|
4241
|
+
matching kanban column is removed too. Use enum-rename first if the
|
|
4242
|
+
entries should migrate to another option instead of losing the value.
|
|
3735
4243
|
dench crm fields enum-rename <object> <field> <oldValue> <newValue>
|
|
3736
4244
|
Renames the option AND migrates every entry that referenced the old
|
|
3737
4245
|
value (complete for indexed enum fields — the default — via the
|
|
3738
|
-
projection index) AND renames matching crmStatuses.
|
|
3739
|
-
|
|
3740
|
-
|
|
4246
|
+
projection index) AND renames matching crmStatuses. When <newValue>
|
|
4247
|
+
matches an EXISTING option case-insensitively, the rename MERGES onto
|
|
4248
|
+
that option's canonical casing (entries migrate, the duplicate is
|
|
4249
|
+
dropped) — the canonical way to dedupe two same-meaning options.
|
|
4250
|
+
NOTE: saved views whose filters pin the OLD label are not rewritten;
|
|
4251
|
+
update those view filters after a rename or they silently go empty.
|
|
3741
4252
|
dench crm fields enum-set-color <object> <field> <value> <hex>
|
|
3742
4253
|
Update the color (e.g. "#3b82f6") for an existing enum option.
|
|
3743
4254
|
|
|
@@ -3745,12 +4256,19 @@ Entries (rows):
|
|
|
3745
4256
|
dench crm entries list <object> [--limit N] [--with-meta] [--json]
|
|
3746
4257
|
dench crm entries get <object> <entryId>
|
|
3747
4258
|
dench crm history <object> <entryId> [--include-system] [--limit N] [--cursor CURSOR] [--json]
|
|
3748
|
-
dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields) [--allow-unknown]
|
|
3749
|
-
|
|
4259
|
+
dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields) [--allow-unknown] [--idempotency-key <key>]
|
|
4260
|
+
--idempotency-key upserts on (object, key): a retried create returns
|
|
4261
|
+
the existing row with alreadyExisted: true instead of duplicating it.
|
|
4262
|
+
dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>] [--dedupe-key <column>] [--allow-unknown]
|
|
4263
|
+
--dedupe-key <column> makes each row idempotent by that column's
|
|
4264
|
+
value; rows that already exist are skipped (status: already_existed).
|
|
3750
4265
|
dench crm entries update <object> <entryId> --data '{...}' (alias: --fields) [--allow-unknown]
|
|
3751
4266
|
dench crm entries update-many <object> --updates '[{"id":"...","data":{...}}]' [--file updates.json] [--idempotency-key <key>] [--allow-unknown]
|
|
3752
4267
|
dench crm entries delete <object> <entryId>
|
|
3753
4268
|
dench crm entries bulk-delete <object> --ids id1,id2,id3
|
|
4269
|
+
Automatically snapshots the target rows to a JSONL undo file in the
|
|
4270
|
+
temp dir first (snapshotPath/snapshotRows in the output); the delete
|
|
4271
|
+
aborts if the snapshot cannot be written.
|
|
3754
4272
|
Note: --data is REQUIRED on create/update and must include >=1 field.
|
|
3755
4273
|
An empty --data '{}' is rejected — use 'crm cells set' for
|
|
3756
4274
|
single-field tweaks instead of creating a placeholder blank row.
|
|
@@ -3814,9 +4332,14 @@ Batch:
|
|
|
3814
4332
|
and calls a batch surface in chunks.
|
|
3815
4333
|
|
|
3816
4334
|
Import / export:
|
|
3817
|
-
dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error]
|
|
4335
|
+
dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error] [--dedupe-key <column>]
|
|
3818
4336
|
dench crm import <object> --jsonl rows.jsonl
|
|
4337
|
+
Imports are idempotent: every row gets a stable dedupe key (content
|
|
4338
|
+
hash, or --dedupe-key <column>), so re-running the same file skips /
|
|
4339
|
+
updates / errors per --on-conflict instead of duplicating rows.
|
|
3819
4340
|
dench crm export <object> --format csv|jsonl [--limit N]
|
|
4341
|
+
Pages with a real cursor and verifies no duplicate ids before
|
|
4342
|
+
emitting; a summary (exportedCount/uniqueIds) is printed to stderr.
|
|
3820
4343
|
|
|
3821
4344
|
Transactions (multi-step, all-or-nothing):
|
|
3822
4345
|
TXN=$(dench crm transaction begin)
|