conduyt 1.5.0 → 1.8.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 +4 -0
  2. package/dist/index.js +214 -0
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -39,6 +39,10 @@ conduyt search "acme corp" # search across the CRM
39
39
  conduyt insights summary # run an AI insight query
40
40
  conduyt privacy export <id> # GDPR data-portability export (owner/admin)
41
41
  conduyt privacy forget <id> --confirm FORGET # GDPR erasure — IRREVERSIBLE, owner only
42
+ conduyt reply-capture status # inbound reply capture: state, DNS records, provider health
43
+ conduyt reply-capture setup # create the receiving subdomain (--domain optional)
44
+ conduyt reply-capture verify # promote it to live once DNS is confirmed
45
+ conduyt reply-capture remove --confirm REMOVE # remove capture — replies to already-sent email are lost
42
46
  conduyt api GET /api/v1/companies # raw authenticated request (escape hatch)
43
47
  conduyt config show # show resolved config (key masked)
44
48
  ```
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.");
@@ -1604,6 +1660,147 @@ program
1604
1660
  p = `/api/v1${p}`;
1605
1661
  return client.request(method.toUpperCase(), p, body);
1606
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
+ // ---- reply capture (inbound email) ----
1670
+ // The receiving subdomain that makes replies to CRM email record and thread
1671
+ // against the contact. Same lifecycle as the sending domain: setup -> DNS ->
1672
+ // verify -> live, and the name MUST be a strict subdomain of the account's own
1673
+ // VERIFIED sending domain (that rule is the cross-tenant fence, not
1674
+ // formatting). Several non-2xx outcomes here are NORMAL, not retry loops:
1675
+ // 409 "already configured" (remove first), 409 "contact support to reconcile"
1676
+ // (an operator-repair state reachable only through a super-admin session
1677
+ // endpoint — an API key deliberately cannot fix it), 429 (per-account budget:
1678
+ // DNS takes minutes), and 503 (the shared provider allowance is busy with
1679
+ // customer sends — says nothing about the domain).
1680
+ const replyCapture = program
1681
+ .command("reply-capture")
1682
+ .description("Inbound reply capture: the receiving subdomain that threads email replies to contacts");
1683
+ replyCapture
1684
+ .command("status")
1685
+ .description("Show reply-capture state: configured/live, the capture domain and reply address, pending DNS records, and provider health. health='checking' means the provider is re-checking DNS and capture stays active; degraded=true means it can no longer receive and must be removed and set up again; needsSupport=true means an operator must reconcile it; cleanupPending=true means a previous removal did not finish at the provider — re-run `remove` to complete it. After a removal, draining=true (with drainEndsAt) means the drain window is open — a domain that was live at removal keeps receiving replies until it closes (one that was degraded has no such guarantee); parked=true means it is retired and inbound-inert. Either way that name is permanently unavailable — use suggestedDomain when setting up again")
1686
+ .action(run(async (client) => client.get("/api/v1/email-domains/reply-capture")));
1687
+ replyCapture
1688
+ .command("setup")
1689
+ .description("Create the receiving subdomain at the email provider and return the DNS records to add. Requires a verified sending domain; the capture name must be a subdomain of it. Omit --domain to use the suggested reply.<sending-domain>. Add the records, then run `reply-capture verify`. A 409 can also mean the requested name belongs to a retired (drained/parked) domain — retired names are permanently unavailable, so use suggestedDomain from `reply-capture status` (e.g. reply2.<domain>) instead of retrying the old name")
1690
+ .option("--domain <domain>", "capture subdomain, e.g. reply.acme.com (must be a subdomain of your verified sending domain)")
1691
+ .action(run(async (client, opts) => {
1692
+ const domain = opts.domain?.trim();
1693
+ if (opts.domain !== undefined && !domain) {
1694
+ fail("--domain must not be blank. Omit it entirely to use the suggested reply.<sending-domain>.");
1695
+ }
1696
+ // Sent only when supplied — an absent domain is how the API is told to
1697
+ // use its suggestion.
1698
+ return client.post("/api/v1/email-domains/reply-capture", domain ? { domain } : {});
1699
+ }));
1700
+ replyCapture
1701
+ .command("verify")
1702
+ .description("Check the pending capture domain's DNS and promote it to live once the provider confirms inbound receiving. verified=false with DNS record states is the expected answer while records propagate — poll sparingly, this is budgeted per account")
1703
+ .action(run(async (client) => client.post("/api/v1/email-domains/reply-capture/verify", {})));
1704
+ replyCapture
1705
+ .command("remove")
1706
+ .description("Remove reply capture. Outcome depends on the domain's state. LIVE with a provider-backed setup: it DRAINS — the capture Reply-To stops on new email immediately, but the address keeps receiving for 30 days (drainEndsAt in the response) so late replies to already-sent emails still reach the contact's timeline; after that it parks, and the name is permanently retired (no account can ever set it up again — resuming capture means a NEW subdomain, see suggestedDomain on `reply-capture status`). LIVE legacy row with no stored provider id: removed immediately with NO drain — reply threading stops at once and the response says providerCleanup='manual' (an operator finishes the provider side); the name stays reusable. DEGRADED: drain lifecycle and permanent retirement, but receiving was already broken, so no 30-day delivery guarantee. PENDING setup, a domain the provider has already lost, or an interrupted-removal retry (cleanupPending): torn down immediately, the row is deleted, and the name stays reusable. Sending-domain removal stays blocked by every capture row except a released tombstone — pending/cleanup rows clear right here, but a draining/parked row keeps blocking until support releases it after the drain. Requires --confirm REMOVE")
1707
+ // A VALUE, not a boolean flag: `--confirm false` on a boolean would read as
1708
+ // true and delete anyway (same footgun the GDPR forget command guards).
1709
+ .allowExcessArguments(false)
1710
+ .option("--confirm <word>", "required — pass exactly REMOVE to authorize the removal (a provider-backed live/degraded name is permanently retired)")
1711
+ .action(run(async (client, opts) => {
1712
+ if (opts.confirm !== "REMOVE") {
1713
+ fail("Refusing to remove: pass --confirm REMOVE to authorize this. Sending stops immediately, and a provider-backed live or degraded domain's name is permanently retired — there is no undo on the retirement (a legacy id-less live row instead stops threading at once).");
1714
+ }
1715
+ return client.del("/api/v1/email-domains/reply-capture");
1716
+ }));
1717
+ // ---- lifecycle (master lead status + intake deals) ----
1718
+ const lifecycle = program
1719
+ .command("lifecycle")
1720
+ .description("Lifecycle v2 settings: lead master statuses and automatic intake-deal creation");
1721
+ lifecycle
1722
+ .command("settings")
1723
+ .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)")
1724
+ .action(run(async (client) => {
1725
+ const DEFAULTS = ["open", "won", "lost", "abandoned", "disqualified"];
1726
+ const result = (await client.get("/api/v1/settings"));
1727
+ const raw = result?.data ?? {};
1728
+ const customs = Array.isArray(raw.masterStatuses)
1729
+ ? raw.masterStatuses.filter((x) => typeof x === "string")
1730
+ : [];
1731
+ const intakeDeals = raw.intakeDeals && typeof raw.intakeDeals === "object" ? raw.intakeDeals : null;
1732
+ return {
1733
+ data: {
1734
+ masterStatuses: {
1735
+ defaults: DEFAULTS,
1736
+ customs,
1737
+ effective: [...DEFAULTS, ...customs.filter((c) => !DEFAULTS.includes(c.toLowerCase()))],
1738
+ },
1739
+ intakeDeals,
1740
+ ...(intakeDeals === null
1741
+ ? {
1742
+ intakeDealsNote: "null = not configured, OR your key's role cannot read this admin-visible setting",
1743
+ }
1744
+ : {}),
1745
+ },
1746
+ };
1747
+ }));
1748
+ lifecycle
1749
+ .command("set-intake-deals")
1750
+ .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")
1751
+ .option("--enable", "turn intake deals ON")
1752
+ .option("--disable", "turn intake deals OFF")
1753
+ .option("--pipeline <id>", "pipeline UUID the intake deals land in")
1754
+ .option("--stage <id>", "starting stage UUID within that pipeline")
1755
+ .option("--re-inbound <policy>", "policy for existing contacts: always|if_no_open|never")
1756
+ .action(run(async (client, opts) => {
1757
+ if (opts.enable && opts.disable)
1758
+ throw new Error("Pass either --enable or --disable, not both.");
1759
+ if (opts.enable === undefined && opts.disable === undefined) {
1760
+ throw new Error("Pass --enable or --disable.");
1761
+ }
1762
+ if (!opts.pipeline || !opts.stage) {
1763
+ // The API requires the full object, so the CLI must too — a partial
1764
+ // PATCH would 422 with a less obvious message.
1765
+ throw new Error("--pipeline and --stage are required (see `conduyt pipelines list`).");
1766
+ }
1767
+ const body = {
1768
+ enabled: Boolean(opts.enable),
1769
+ pipelineId: opts.pipeline,
1770
+ stageId: opts.stage,
1771
+ };
1772
+ if (opts.reInbound !== undefined) {
1773
+ if (!["always", "if_no_open", "never"].includes(opts.reInbound)) {
1774
+ throw new Error("--re-inbound must be one of: always, if_no_open, never.");
1775
+ }
1776
+ body.reInbound = opts.reInbound;
1777
+ }
1778
+ return client.patch("/api/v1/settings", { intakeDeals: body });
1779
+ }));
1780
+ lifecycle
1781
+ .command("set-master-statuses [statuses...]")
1782
+ .description("Set the account's CUSTOM lead lifecycle statuses (the five defaults always exist). Pass --clear with no names to remove all customs")
1783
+ .option("--clear", "remove ALL custom statuses (restore defaults-only)")
1784
+ .action(run(async (client, statuses, opts) => {
1785
+ statuses = statuses ?? [];
1786
+ if (statuses.length === 0 && !opts.clear) {
1787
+ throw new Error("Pass at least one status name, or --clear to remove all customs.");
1788
+ }
1789
+ if (statuses.length > 0 && opts.clear) {
1790
+ throw new Error("--clear cannot be combined with status names.");
1791
+ }
1792
+ // Mirror of the API validator: max 20 names, 1-40 chars, letters/
1793
+ // numbers/spaces/-/_ only — fail fast locally instead of a late 422.
1794
+ const clean = statuses.map((s) => s.trim());
1795
+ if (clean.some((s) => !s))
1796
+ throw new Error("Status names must not be blank.");
1797
+ if (clean.length > 20)
1798
+ throw new Error("At most 20 custom statuses.");
1799
+ const bad = clean.find((s) => s.length > 40 || !/^[a-z0-9][a-z0-9 _-]*$/i.test(s));
1800
+ if (bad)
1801
+ throw new Error(`Invalid status name '${bad}' — 1-40 chars; letters, numbers, spaces, - and _ only.`);
1802
+ return client.patch("/api/v1/settings", { masterStatuses: clean });
1803
+ }));
1607
1804
  program.parseAsync(process.argv).catch(fail);
1608
1805
  // helpers
1609
1806
  // Parse a --json / --stages style argument, throwing a clear CLI error (caught
@@ -1761,6 +1958,23 @@ function run(handler) {
1761
1958
  }
1762
1959
  };
1763
1960
  }
1961
+ // Contact source is the first-write-wins acquisition truth: a blank value
1962
+ // must never reach storage, whether it arrived via a flag or --json (the
1963
+ // merge runs last, so this validates the FINAL body).
1964
+ function assertContactSource(value, opts) {
1965
+ if (value === undefined)
1966
+ return;
1967
+ if (value === null) {
1968
+ if (opts.allowNull)
1969
+ return;
1970
+ throw new Error("source cannot be null on create — omit it instead.");
1971
+ }
1972
+ if (typeof value !== "string" || value.trim() === "") {
1973
+ throw new Error("source must be a non-blank string (or omitted).");
1974
+ }
1975
+ if (value.length > 100)
1976
+ throw new Error("source must be at most 100 characters.");
1977
+ }
1764
1978
  function mask(key) {
1765
1979
  if (!key)
1766
1980
  return "(not set)";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.5.0",
4
- "description": "Command-line interface for Conduyt CRM \u2014 manage contacts, deals, pipelines, and run insight queries from your terminal.",
3
+ "version": "1.8.0",
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",
7
7
  "bin": {
@@ -18,8 +18,9 @@
18
18
  "build": "tsc",
19
19
  "typecheck": "tsc --noEmit",
20
20
  "start": "tsx src/index.ts",
21
- "prepublishOnly": "npm run build",
22
- "test": "node --import tsx --test src/*.test.ts"
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)\""
23
24
  },
24
25
  "keywords": [
25
26
  "conduyt",