conduyt 1.77.0 → 1.78.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
@@ -35,7 +35,8 @@ conduyt contacts list --limit 25 # list contacts (supports --cursor, --qu
35
35
  conduyt contacts get <id> # fetch one contact (carries `revision`, `hasOutboundSms` and `hasOutboundEmail`: a proven send only)
36
36
  conduyt contacts update <id> --first Ann --expected-revision <n> # PATCH; a contact changed since revision n is refused: 409 contact_changed, exit 1, code and current revision printed
37
37
  conduyt contacts merge <primaryId> <secondaryId...> [--confirm [--yes]] # DESTRUCTIVE: a dry run by default (prints the impact); --confirm shows it, asks y/N, then merges (the losers are deleted); --yes skips the question; moved counts 409 and 503 merge_contacts_busy / merge_budget_exhausted + Retry-After exit 1, never retried
38
- conduyt contacts duplicates <id> # GET /contacts/{id}/duplicate-proposals; prints { proposals, heldIdentifiers } (open and not_sure possible duplicates; read-only)
38
+ conduyt contacts duplicates <id> [--cursor <c>] [--limit <n>] [--held-cursor <c>] [--held-limit <n>] # GET /contacts/{id}/duplicate-proposals; prints { proposals, heldIdentifiers, total, nextCursor, membershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor, heldIdentifiersMembershipRevision } (open and not_sure possible duplicates; keyset paged; read-only)
39
+ conduyt imports duplicates <importId> [--page <n>] [--per-page <n>] # GET /imports/{id}/duplicate-proposals; the proposals one import raised on the records it created, every status, oldest first; prints { rows, meta: { page, per_page, total } }
39
40
  conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge # POST the decision; not_sure keeps both visible, different is final, merge only records a pair ALREADY merged (never merges); 409 already_decided / not_merged and 403 other_record_not_editable exit 1
40
41
  conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
41
42
  conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
package/dist/index.js CHANGED
@@ -259,14 +259,28 @@ contacts
259
259
  const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
260
260
  const duplicates = contacts
261
261
  .command("duplicates <id>")
262
- .description("List the possible duplicates of contact <id> (GET /contacts/{id}/duplicate-proposals, contacts:view, read-only): prints { proposals, heldIdentifiers }. Each proposal has id (the <proposalId> for `contacts duplicates decide`), status (open | not_sure), rule, raisedHere and `other` { id, name, createdAt, visible, editable }; a record you cannot view is \"a contact you cannot view\" with no id. heldIdentifiers are emails or phones this record was not given because another record owns them (value-free). Decide a pair with `conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge`")
263
- .action(run(async (client, id) => {
262
+ .description("List the possible duplicates of contact <id> (GET /contacts/{id}/duplicate-proposals, contacts:view, read-only): prints { proposals, heldIdentifiers }. Each proposal has id (the <proposalId> for `contacts duplicates decide`), status (open | not_sure), rule, raisedHere and `other` { id, name, createdAt, visible, editable }; a record you cannot view is \"a contact you cannot view\" with no id. heldIdentifiers are emails or phones this record was not given because another record owns them (value-free). Decide a pair with `conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge`. Paging: both lists are keyset paged, newest first; --cursor / --limit page the proposals, --held-cursor / --held-limit the held identifiers. The printed object also carries total and heldIdentifiersTotal (every line of each list, all pages), nextCursor and heldIdentifiersNextCursor (pass back as --cursor / --held-cursor; null on the last page) and membershipRevision / heldIdentifiersMembershipRevision (opaque digests of each list: when a later page answers another revision than page one, discard it and read page one again). A cursor this list did not issue is a 400 invalid_cursor (exit 1)")
263
+ .option("--cursor <cursor>", "the nextCursor of the previous proposals page, passed back UNCHANGED (opaque); omit for the first page")
264
+ .option("--limit <n>", "proposals per page, integer 1-100 (default 50); above 100 is refused, not capped")
265
+ .option("--held-cursor <cursor>", "the heldIdentifiersNextCursor of the previous held identifiers page, passed back UNCHANGED (opaque); omit for the first page")
266
+ .option("--held-limit <n>", "held identifiers per page, integer 1-100 (default 20)")
267
+ .action(run(async (client, id, opts) => {
264
268
  assertUuid(id, "contact id");
265
- return client.get(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals`);
269
+ const qs = new URLSearchParams();
270
+ if (opts.cursor !== undefined)
271
+ qs.set("cursor", opaqueCursorFlag(opts.cursor, "--cursor"));
272
+ if (opts.limit !== undefined)
273
+ qs.set("limit", String(strictIntFlag(opts.limit, "--limit", 1, 100)));
274
+ if (opts.heldCursor !== undefined)
275
+ qs.set("held_cursor", opaqueCursorFlag(opts.heldCursor, "--held-cursor"));
276
+ if (opts.heldLimit !== undefined)
277
+ qs.set("held_limit", String(strictIntFlag(opts.heldLimit, "--held-limit", 1, 100)));
278
+ const q = qs.toString();
279
+ return client.get(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals${q ? `?${q}` : ""}`);
266
280
  }));
267
281
  duplicates
268
282
  .command("decide <id> <proposalId>")
269
- .description("Record a decision on one possible-duplicate pair (POST /contacts/{id}/duplicate-proposals/{proposalId}; contacts:edit on BOTH records). --as is required, exactly one of: not_sure (keep both records visible; the pair stays listed and can be decided again), different (the two are different people: never shown again, FINAL), merge (record that the pair is ALREADY merged; this command NEVER merges: run `conduyt contacts merge` first, else 409 not_merged). Prints { id, status, decidedAt }. Refusals exit 1 with the status, message and code and are never retried: 409 already_decided, 409 not_merged, 403 other_record_not_editable, 404, 400")
283
+ .description("Record a decision on one possible-duplicate pair (POST /contacts/{id}/duplicate-proposals/{proposalId}; contacts:edit on BOTH records). --as is required, exactly one of: not_sure (keep both records visible; the pair stays listed and can be decided again), different (the two are different people: never shown again, FINAL), merge (record that the pair is ALREADY merged; this command NEVER merges: run `conduyt contacts merge` first, else 409 not_merged). Prints { id, status, decidedAt, previousMembershipRevision, membershipRevision, total }: membershipRevision and total are the proposals list AFTER the decision (the same digest and count `contacts duplicates` answers), previousMembershipRevision the digest just BEFORE it (equal on an idempotent repeat); keep loaded pages only when previousMembershipRevision equals the revision you hold. Refusals exit 1 with the status, message and code and are never retried: 409 already_decided, 409 not_merged, 403 other_record_not_editable, 404, 400")
270
284
  .option("--as <decision>", "not_sure | different | merge (required)")
271
285
  .action(run(async (client, id, proposalId, opts) => {
272
286
  assertUuid(id, "contact id");
@@ -277,6 +291,42 @@ duplicates
277
291
  throw new Error(`--as must be exactly one of ${PROPOSAL_DECISIONS.join(", ")} (got "${opts.as}").`);
278
292
  return client.post(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision: opts.as });
279
293
  }));
294
+ /** a whole-number flag in [min, max]: digits only (no sign, decimal, exponent or hex), so "1e1" or "0x10" never pass as 10 or 16 */
295
+ function strictIntFlag(raw, flag, min, max) {
296
+ const t = raw.trim();
297
+ if (!/^\d+$/.test(t))
298
+ throw new Error(`${flag} must be a whole number ${min}-${max} (got ${JSON.stringify(raw)}).`);
299
+ const n = Number(t);
300
+ if (n < min || n > max)
301
+ throw new Error(`${flag} must be a whole number ${min}-${max} (got ${JSON.stringify(raw)}).`);
302
+ return n;
303
+ }
304
+ /** an opaque cursor is sent exactly as given; a blank one is refused (the API would answer page one, silently restarting) */
305
+ function opaqueCursorFlag(raw, flag) {
306
+ if (raw.trim() === "")
307
+ throw new Error(`${flag} was provided but is blank: pass the cursor from the previous page unchanged, or omit it for the first page.`);
308
+ return raw;
309
+ }
310
+ // Duplicate proposals look (board #95, app f2d638db6): the import results rows "Created, similar record exists".
311
+ const imports = program.command("imports").description("Import jobs (contacts / opportunities / companies imports)");
312
+ imports
313
+ .command("duplicates <importId>")
314
+ .description("List the duplicate proposals one import raised on the records it created (GET /imports/{id}/duplicate-proposals, contacts:view, read-only): one row per proposal, every status (open | not_sure | different | merged; a merged pair reads merged), oldest first. Prints { rows, meta: { page, per_page, total } } (total counts every row of the import). Each row: proposalId, status, rowNum (the import file's row number), createdAt, decidedAt, decidedByName, newRecord (the record the import created), similar (the existing record it may duplicate), matchedOn (kinds only, such as phone or phone + company, never a value), keptName; newRecord and similar are { id, name, visible, editable, live }. A record you cannot view has no id and the name \"A contact you cannot view\". Decide a row with `conduyt contacts duplicates decide <newRecord.id> <proposalId> --as ...` (contacts:edit on both records). An owner, admin or view-all key reads any import of the account, any other key only the imports its user ran (404 otherwise). The route is page based: --page / --per-page, not a cursor")
315
+ .option("--page <n>", "page number, integer 1-100000 (default 1)")
316
+ .option("--per-page <n>", "rows per page, integer 1-100 (default 50); above 100 is refused, not capped")
317
+ .option("--cursor <cursor>", "not supported (the import rows page with --page); passing it exits 1 and sends no request")
318
+ .action(run(async (client, importId, opts) => {
319
+ if (opts.cursor !== undefined)
320
+ throw new Error("imports duplicates does not support --cursor (the API pages the import rows with page and per_page); use --page <n> and --per-page <n>.");
321
+ assertUuid(importId, "import id");
322
+ const qs = new URLSearchParams();
323
+ if (opts.page !== undefined)
324
+ qs.set("page", String(strictIntFlag(opts.page, "--page", 1, 100000)));
325
+ if (opts.perPage !== undefined)
326
+ qs.set("per_page", String(strictIntFlag(opts.perPage, "--per-page", 1, 100)));
327
+ const q = qs.toString();
328
+ return client.get(`/api/v1/imports/${encodeURIComponent(importId)}/duplicate-proposals${q ? `?${q}` : ""}`);
329
+ }));
280
330
  contacts
281
331
  .command("merge <primaryId> <secondaryIds...>")
282
332
  .description("DESTRUCTIVE merge of duplicate contacts (POST /contacts/merge; owner or admin): <primaryId> survives and receives the records of every <secondaryIds> contact, which are then DELETED (no undelete). By default a DRY RUN: nothing changes, it prints the impact { primary, secondaries, counts, totalRecords, guardCounts, filledFields, identifierOverflow, blockers }. --confirm repeats the dry run, shows the impact (the records that move, and separately the guard counts: chat messages, quick notes, identifiers, outbox rows, which are not in the records total), asks y/N and then merges, sending the values that dry run returned (expectedCounts, expectedGuardCounts, expectedIdentifiers). LOSER ORDER: losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled. WHAT IS PINNED: the server refuses (409) if the record counts, guard counts or identifier plan moved; other field edits made after this preview (a loser's name, company, source, address) are not detected; --yes skips the question (scripts; needs --confirm). A refusal exits 1 with the status, the API's message and its code and is NEVER retried: 409 \"Merge impact changed\" (preview again), 409 queued work, 400 / 404, and 503 merge_contacts_busy / merge_budget_exhausted with `[Retry-After: <N>s]` (nothing changed: wait that long, then run again).")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.77.0",
3
+ "version": "1.78.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,78 +0,0 @@
1
- /**
2
- * Local typo guard for automation action configs (card ownership per lane, Codex r1/r2). The server validator is the authority
3
- * and there the option names are not enforced yet, so a typoed `laneSoruce` on assign_to_user would silently assign the run's
4
- * deal instead of the lane card. Only the options below are known here.
5
- *
6
- * Traversal rule: ONLY the real action positions of the graph are visited, exactly as the graph is shaped: a v1 `actions`
7
- * array's elements `{type, config}`, and a v2 graph's `nodes[].action` `{type, config}`. For each action of a known type the
8
- * IMMEDIATE keys of `config` are checked; the config VALUES are opaque and never descended into (a webhook payload may hold any
9
- * object, including one shaped like an action), and nothing outside those positions (request metadata, other node fields) is
10
- * scanned. A key that is not a known option but is a case-insensitive match or ONE edit (substitution, insertion, deletion or
11
- * adjacent transposition) from one is refused; keys further away, and every other action type, pass through.
12
- * Source of the names: lib/automation-action-registry.ts in the app (assign_to_user, create_deal, close_other_open_deals).
13
- */
14
- const KNOWN_ACTION_OPTIONS = {
15
- assign_to_user: ["runWindow", "mode", "userId", "userIds", "groupId", "target", "applyTo", "overwriteExisting", "laneSource", "lanePipelineId", "laneStageId"],
16
- create_deal: ["pipelineId", "stageId", "dealName", "dealValue", "allowDuplicates", "status", "source", "assignedTo", "leaveUnassigned"],
17
- close_other_open_deals: ["lostReason"],
18
- };
19
- /** Damerau-Levenshtein (optimal string alignment) distance, case-insensitive */
20
- function distance(a, b) {
21
- const x = a.toLowerCase(), y = b.toLowerCase();
22
- const d = Array.from({ length: x.length + 1 }, (_, i) => [i, ...Array(y.length).fill(0)]);
23
- for (let j = 1; j <= y.length; j++)
24
- d[0][j] = j;
25
- for (let i = 1; i <= x.length; i++) {
26
- for (let j = 1; j <= y.length; j++) {
27
- d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1));
28
- if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1])
29
- d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
30
- }
31
- }
32
- return d[x.length][y.length];
33
- }
34
- const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
35
- function checkAction(action) {
36
- if (!isObject(action) || typeof action.type !== "string" || !isObject(action.config))
37
- return null;
38
- const known = KNOWN_ACTION_OPTIONS[action.type];
39
- if (!known)
40
- return null;
41
- for (const key of Object.keys(action.config)) {
42
- if (known.includes(key))
43
- continue;
44
- const near = known.find((k) => distance(key, k) <= 1);
45
- if (near)
46
- return { actionType: action.type, key, suggestion: near };
47
- }
48
- return null;
49
- }
50
- /** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
51
- export function findActionConfigTypo(graph) {
52
- if (Array.isArray(graph)) {
53
- for (const action of graph) {
54
- const t = checkAction(action);
55
- if (t)
56
- return t;
57
- }
58
- return null;
59
- }
60
- if (isObject(graph) && Array.isArray(graph.nodes)) {
61
- for (const node of graph.nodes) {
62
- const t = isObject(node) ? checkAction(node.action) : null;
63
- if (t)
64
- return t;
65
- }
66
- }
67
- return null;
68
- }
69
- /** The refusal text; the details object is the final token */
70
- export function actionConfigTypoMessage(t) {
71
- return `Unknown option "${t.key}" in ${t.actionType} config: did you mean "${t.suggestion}"? Nothing was sent. ${JSON.stringify(t)}`;
72
- }
73
- /** Throws (a plain Error) when the graph carries a misspelled option */
74
- export function assertNoActionConfigTypo(graph) {
75
- const t = findActionConfigTypo(graph);
76
- if (t)
77
- throw new Error(actionConfigTypoMessage(t));
78
- }