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.
- package/README.md +2 -0
- package/dist/index.js +196 -0
- 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
|
-
"description": "Command-line interface for Conduyt CRM
|
|
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
|
+
}
|