conduyt 1.4.0 → 1.6.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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/index.js +196 -0
  3. package/package.json +6 -4
package/README.md CHANGED
@@ -37,6 +37,8 @@ conduyt deals list --pipeline <id> # list deals
37
37
  conduyt pipelines # list pipelines and stages
38
38
  conduyt search "acme corp" # search across the CRM
39
39
  conduyt insights summary # run an AI insight query
40
+ conduyt privacy export <id> # GDPR data-portability export (owner/admin)
41
+ conduyt privacy forget <id> --confirm FORGET # GDPR erasure — IRREVERSIBLE, owner only
40
42
  conduyt api GET /api/v1/companies # raw authenticated request (escape hatch)
41
43
  conduyt config show # show resolved config (key masked)
42
44
  ```
package/dist/index.js CHANGED
@@ -82,6 +82,8 @@ contacts
82
82
  .option("--email <email>", "email")
83
83
  .option("--phone <phone>", "phone")
84
84
  .option("--company <company>", "company name (auto-creates/links a company)")
85
+ .option("--source <source>", "lead source (first-write-wins acquisition truth)")
86
+ .option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
85
87
  .option("--json <json>", "full JSON body, merged over the flags above (for any field)")
86
88
  .action(run(async (client, opts) => {
87
89
  const body = {};
@@ -95,8 +97,23 @@ contacts
95
97
  body.phone = opts.phone;
96
98
  if (opts.company !== undefined)
97
99
  body.company = opts.company;
100
+ if (opts.source !== undefined) {
101
+ if (opts.source.trim() === "")
102
+ throw new Error("--source was provided but is blank. Omit it or pass a source.");
103
+ if (opts.source.length > 100)
104
+ throw new Error("--source must be at most 100 characters.");
105
+ body.source = opts.source;
106
+ }
107
+ if (opts.masterStatus !== undefined) {
108
+ if (opts.masterStatus.trim() === "")
109
+ throw new Error("--master-status was provided but is blank. Omit it or pass a status.");
110
+ if (opts.masterStatus.length > 40)
111
+ throw new Error("--master-status must be at most 40 characters.");
112
+ body.masterStatus = opts.masterStatus;
113
+ }
98
114
  if (opts.json !== undefined)
99
115
  Object.assign(body, jsonArg(opts.json, "--json"));
116
+ assertContactSource(body.source, { allowNull: false });
100
117
  return client.post("/api/v1/contacts", body);
101
118
  }));
102
119
  contacts
@@ -107,6 +124,8 @@ contacts
107
124
  .option("--email <email>", "email")
108
125
  .option("--phone <phone>", "phone")
109
126
  .option("--company <company>", "company name (auto-creates/links a company; pass '' to clear)")
127
+ .option("--source <source>", "correct the lead's first-touch source (normally set once at creation — update only to fix bad attribution; pass 'null' to clear)")
128
+ .option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
110
129
  .option("--json <json>", "full JSON body, merged over the flags above (for any field)")
111
130
  .action(run(async (client, id, opts) => {
112
131
  assertUuid(id, "contact id");
@@ -121,10 +140,28 @@ contacts
121
140
  body.phone = opts.phone;
122
141
  if (opts.company !== undefined)
123
142
  body.company = opts.company;
143
+ if (opts.source !== undefined) {
144
+ if (opts.source !== "null") {
145
+ if (opts.source.trim() === "") {
146
+ throw new Error("--source was provided but is blank. Omit it, or pass 'null' to clear the source.");
147
+ }
148
+ if (opts.source.length > 100)
149
+ throw new Error("--source must be at most 100 characters.");
150
+ }
151
+ body.source = opts.source === "null" ? null : opts.source;
152
+ }
153
+ if (opts.masterStatus !== undefined) {
154
+ if (opts.masterStatus.trim() === "")
155
+ throw new Error("--master-status was provided but is blank. Omit it or pass a status.");
156
+ if (opts.masterStatus.length > 40)
157
+ throw new Error("--master-status must be at most 40 characters.");
158
+ body.masterStatus = opts.masterStatus;
159
+ }
124
160
  if (opts.json !== undefined)
125
161
  Object.assign(body, jsonArg(opts.json, "--json"));
126
162
  if (Object.keys(body).length === 0)
127
163
  throw new Error("Nothing to update. Pass at least one field flag or --json.");
164
+ assertContactSource(body.source, { allowNull: true });
128
165
  return client.patch(`/api/v1/contacts/${encodeURIComponent(id)}`, body);
129
166
  }));
130
167
  contacts
@@ -271,6 +308,7 @@ deals
271
308
  .option("--value <n>", "deal value (number)")
272
309
  .option("--currency <code>", "ISO currency, e.g. GBP")
273
310
  .option("--contact <id>", "contact UUID to link")
311
+ .option("--source <source>", "attribution source for THIS deal (the trigger that produced it, e.g. sms-reply, booking:demo)")
274
312
  .option("--json <json>", "full JSON body, merged over the flags")
275
313
  .action(run(async (client, opts) => {
276
314
  const body = {};
@@ -295,6 +333,13 @@ deals
295
333
  body.currency = opts.currency;
296
334
  if (opts.contact !== undefined)
297
335
  body.contactId = opts.contact;
336
+ if (opts.source !== undefined) {
337
+ if (opts.source.trim() === "")
338
+ throw new Error("--source was provided but is blank. Omit it or pass a source.");
339
+ if (opts.source.length > 500)
340
+ throw new Error("--source must be at most 500 characters.");
341
+ body.source = opts.source;
342
+ }
298
343
  if (opts.json !== undefined)
299
344
  Object.assign(body, jsonArg(opts.json, "--json"));
300
345
  assertFiniteValue(body.value);
@@ -317,6 +362,7 @@ deals
317
362
  .option("--pipeline <id>", "move to this pipeline UUID")
318
363
  .option("--contact <id>", "primary contact UUID")
319
364
  .option("--status <status>", "explicit status (open|won|lost); overrides the status auto-derived from --stage")
365
+ .option("--source <source>", "attribution source for THIS deal (pass 'null' to clear it)")
320
366
  .option("--json <json>", "full JSON body, merged over the flags")
321
367
  .action(run(async (client, id, opts) => {
322
368
  assertUuid(id, "deal id");
@@ -339,6 +385,16 @@ deals
339
385
  body.contactId = opts.contact;
340
386
  if (opts.status !== undefined)
341
387
  body.status = opts.status;
388
+ if (opts.source !== undefined) {
389
+ // Mirror the API validator (blank rejected, 500-char cap) so the CLI
390
+ // fails locally rather than shipping a guaranteed 422.
391
+ if (opts.source.trim() === "") {
392
+ throw new Error("--source was provided but is blank. Omit it, or pass 'null' to clear the source.");
393
+ }
394
+ if (opts.source.length > 500)
395
+ throw new Error("--source must be at most 500 characters.");
396
+ body.source = opts.source === "null" ? null : opts.source;
397
+ }
342
398
  if (opts.stage !== undefined) {
343
399
  if (opts.stage.trim() === "")
344
400
  throw new Error("--stage was provided but is blank. Omit it or pass a stage name/UUID.");
@@ -1480,6 +1536,36 @@ dnc
1480
1536
  assertUuid(id, "dnc id");
1481
1537
  return client.del(`/api/v1/dnc/${encodeURIComponent(id)}`);
1482
1538
  }));
1539
+ // ---- privacy (GDPR) ----
1540
+ const privacy = program
1541
+ .command("privacy")
1542
+ .description("GDPR data export and right-to-be-forgotten erasure");
1543
+ privacy
1544
+ .command("export <id>")
1545
+ .description("Export a contact's full portable data as JSON (GDPR data portability). Owner/admin key.")
1546
+ .action(run(async (client, id) => {
1547
+ assertUuid(id, "contact id");
1548
+ return client.get(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-export`);
1549
+ }));
1550
+ privacy
1551
+ .command("forget <id>")
1552
+ .description("IRREVERSIBLE GDPR right-to-be-forgotten erasure — permanently anonymizes the contact and erases their personal data everywhere it appears (messages, notes, deals, files, automations, exports) across the whole account. Suppression tombstones are kept. THERE IS NO UNDO. Owner key. Requires --confirm FORGET.")
1553
+ // Require an explicit VALUE (--confirm FORGET), not a bare boolean flag: a
1554
+ // boolean --confirm would treat `--confirm false` as true (leaving "false" as
1555
+ // an ignored extra arg) and erase anyway — a real automation footgun for an
1556
+ // irreversible command. Reject excess args so that stray token can't slip by.
1557
+ .allowExcessArguments(false)
1558
+ .option("--confirm <word>", "required — pass exactly FORGET to authorize the irreversible erasure")
1559
+ .action(run(async (client, id, opts) => {
1560
+ assertUuid(id, "contact id");
1561
+ if (opts.confirm !== "FORGET") {
1562
+ fail("Refusing to erase: pass --confirm FORGET to authorize this irreversible erasure. It permanently anonymizes the contact and erases their personal data everywhere — there is no undo.");
1563
+ }
1564
+ // The API demands { confirm: "FORGET" }; the CLI mirrors that literal so
1565
+ // the destructive intent is explicit at both layers. A 409 is retryable
1566
+ // (concurrent merge / in-flight automation).
1567
+ return client.post(`/api/v1/contacts/${encodeURIComponent(id)}/gdpr-forget`, { confirm: "FORGET" });
1568
+ }));
1483
1569
  // ---- ai ----
1484
1570
  const ai = program.command("ai").description("AI assistant and insight endpoints");
1485
1571
  ai
@@ -1574,6 +1660,99 @@ program
1574
1660
  p = `/api/v1${p}`;
1575
1661
  return client.request(method.toUpperCase(), p, body);
1576
1662
  }));
1663
+ // ---- dialer ----
1664
+ const dialer = program.command("dialer").description("Dialer operations");
1665
+ dialer
1666
+ .command("sync-local-presence")
1667
+ .description("Reconcile the Local Presence pool from Twilio. Only adopts numbers whose Twilio FriendlyName contains a standalone 'LP' (or 'LocalPres'), that are voice-capable, and that are US +1 E.164 — first 50 sorted matches; pool entries that no longer match are REMOVED. Name the number in Twilio first, then sync. Requires settings:edit")
1668
+ .action(run(async (client) => client.post("/api/v1/dialer/local-presence/sync", {})));
1669
+ // ---- lifecycle (master lead status + intake deals) ----
1670
+ const lifecycle = program
1671
+ .command("lifecycle")
1672
+ .description("Lifecycle v2 settings: lead master statuses and automatic intake-deal creation");
1673
+ lifecycle
1674
+ .command("settings")
1675
+ .description("Show the Lifecycle v2 configuration: masterStatuses.effective (the five defaults plus the account's customs) and intakeDeals (null can mean not configured OR not visible to your key — the setting is admin-visible only)")
1676
+ .action(run(async (client) => {
1677
+ const DEFAULTS = ["open", "won", "lost", "abandoned", "disqualified"];
1678
+ const result = (await client.get("/api/v1/settings"));
1679
+ const raw = result?.data ?? {};
1680
+ const customs = Array.isArray(raw.masterStatuses)
1681
+ ? raw.masterStatuses.filter((x) => typeof x === "string")
1682
+ : [];
1683
+ const intakeDeals = raw.intakeDeals && typeof raw.intakeDeals === "object" ? raw.intakeDeals : null;
1684
+ return {
1685
+ data: {
1686
+ masterStatuses: {
1687
+ defaults: DEFAULTS,
1688
+ customs,
1689
+ effective: [...DEFAULTS, ...customs.filter((c) => !DEFAULTS.includes(c.toLowerCase()))],
1690
+ },
1691
+ intakeDeals,
1692
+ ...(intakeDeals === null
1693
+ ? {
1694
+ intakeDealsNote: "null = not configured, OR your key's role cannot read this admin-visible setting",
1695
+ }
1696
+ : {}),
1697
+ },
1698
+ };
1699
+ }));
1700
+ lifecycle
1701
+ .command("set-intake-deals")
1702
+ .description("Configure automatic deal creation on live intake (webhooks, public forms, public bookings). Even under --re-inbound always: bulk imports and staff/calendar/dialer-created appointments never mint, exact replays and same-source events within 10 min are skipped, and terminal-status contacts get no new deals")
1703
+ .option("--enable", "turn intake deals ON")
1704
+ .option("--disable", "turn intake deals OFF")
1705
+ .option("--pipeline <id>", "pipeline UUID the intake deals land in")
1706
+ .option("--stage <id>", "starting stage UUID within that pipeline")
1707
+ .option("--re-inbound <policy>", "policy for existing contacts: always|if_no_open|never")
1708
+ .action(run(async (client, opts) => {
1709
+ if (opts.enable && opts.disable)
1710
+ throw new Error("Pass either --enable or --disable, not both.");
1711
+ if (opts.enable === undefined && opts.disable === undefined) {
1712
+ throw new Error("Pass --enable or --disable.");
1713
+ }
1714
+ if (!opts.pipeline || !opts.stage) {
1715
+ // The API requires the full object, so the CLI must too — a partial
1716
+ // PATCH would 422 with a less obvious message.
1717
+ throw new Error("--pipeline and --stage are required (see `conduyt pipelines list`).");
1718
+ }
1719
+ const body = {
1720
+ enabled: Boolean(opts.enable),
1721
+ pipelineId: opts.pipeline,
1722
+ stageId: opts.stage,
1723
+ };
1724
+ if (opts.reInbound !== undefined) {
1725
+ if (!["always", "if_no_open", "never"].includes(opts.reInbound)) {
1726
+ throw new Error("--re-inbound must be one of: always, if_no_open, never.");
1727
+ }
1728
+ body.reInbound = opts.reInbound;
1729
+ }
1730
+ return client.patch("/api/v1/settings", { intakeDeals: body });
1731
+ }));
1732
+ lifecycle
1733
+ .command("set-master-statuses [statuses...]")
1734
+ .description("Set the account's CUSTOM lead lifecycle statuses (the five defaults always exist). Pass --clear with no names to remove all customs")
1735
+ .option("--clear", "remove ALL custom statuses (restore defaults-only)")
1736
+ .action(run(async (client, statuses, opts) => {
1737
+ statuses = statuses ?? [];
1738
+ if (statuses.length === 0 && !opts.clear) {
1739
+ throw new Error("Pass at least one status name, or --clear to remove all customs.");
1740
+ }
1741
+ if (statuses.length > 0 && opts.clear) {
1742
+ throw new Error("--clear cannot be combined with status names.");
1743
+ }
1744
+ // Mirror of the API validator: max 20 names, 1-40 chars, letters/
1745
+ // numbers/spaces/-/_ only — fail fast locally instead of a late 422.
1746
+ const clean = statuses.map((s) => s.trim());
1747
+ if (clean.some((s) => !s))
1748
+ throw new Error("Status names must not be blank.");
1749
+ if (clean.length > 20)
1750
+ throw new Error("At most 20 custom statuses.");
1751
+ const bad = clean.find((s) => s.length > 40 || !/^[a-z0-9][a-z0-9 _-]*$/i.test(s));
1752
+ if (bad)
1753
+ throw new Error(`Invalid status name '${bad}' — 1-40 chars; letters, numbers, spaces, - and _ only.`);
1754
+ return client.patch("/api/v1/settings", { masterStatuses: clean });
1755
+ }));
1577
1756
  program.parseAsync(process.argv).catch(fail);
1578
1757
  // helpers
1579
1758
  // Parse a --json / --stages style argument, throwing a clear CLI error (caught
@@ -1731,6 +1910,23 @@ function run(handler) {
1731
1910
  }
1732
1911
  };
1733
1912
  }
1913
+ // Contact source is the first-write-wins acquisition truth: a blank value
1914
+ // must never reach storage, whether it arrived via a flag or --json (the
1915
+ // merge runs last, so this validates the FINAL body).
1916
+ function assertContactSource(value, opts) {
1917
+ if (value === undefined)
1918
+ return;
1919
+ if (value === null) {
1920
+ if (opts.allowNull)
1921
+ return;
1922
+ throw new Error("source cannot be null on create — omit it instead.");
1923
+ }
1924
+ if (typeof value !== "string" || value.trim() === "") {
1925
+ throw new Error("source must be a non-blank string (or omitted).");
1926
+ }
1927
+ if (value.length > 100)
1928
+ throw new Error("source must be at most 100 characters.");
1929
+ }
1734
1930
  function mask(key) {
1735
1931
  if (!key)
1736
1932
  return "(not set)";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.4.0",
4
- "description": "Command-line interface for Conduyt CRM manage contacts, deals, pipelines, and run insight queries from your terminal.",
3
+ "version": "1.6.0",
4
+ "description": "Command-line interface for Conduyt CRM \u2014 manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -18,7 +18,9 @@
18
18
  "build": "tsc",
19
19
  "typecheck": "tsc --noEmit",
20
20
  "start": "tsx src/index.ts",
21
- "prepublishOnly": "npm run build"
21
+ "prepublishOnly": "npm run check:version && npm run build && node --import tsx --experimental-test-module-mocks --test src/privacy-forget.test.ts src/contact-source-json.test.ts",
22
+ "test": "node --import tsx --test src/*.test.ts",
23
+ "check:version": "node -e \"const p=require('./package.json').version,l=require('./package-lock.json');if(l.version!==p||l.packages[''].version!==p){console.error('lockfile version != '+p);process.exit(1)}console.log('Version sync OK: '+p)\""
22
24
  },
23
25
  "keywords": [
24
26
  "conduyt",
@@ -46,4 +48,4 @@
46
48
  "tsx": "^4.19.0",
47
49
  "@types/node": "^22.0.0"
48
50
  }
49
- }
51
+ }