conduyt 1.10.0 → 1.13.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/dist/client.js +3 -0
- package/dist/index.js +355 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -81,6 +81,7 @@ contacts
|
|
|
81
81
|
.option("--last <name>", "last name")
|
|
82
82
|
.option("--email <email>", "email")
|
|
83
83
|
.option("--phone <phone>", "phone")
|
|
84
|
+
.option("--timezone <iana>", "IANA timezone of the lead (e.g. America/Chicago); omit to let Conduyt derive it from ZIP → phone area code → state. On update pass '' to clear the override")
|
|
84
85
|
.option("--company <company>", "company name (auto-creates/links a company)")
|
|
85
86
|
.option("--source <source>", "lead source (first-write-wins acquisition truth)")
|
|
86
87
|
.option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
|
|
@@ -95,6 +96,8 @@ contacts
|
|
|
95
96
|
body.email = opts.email;
|
|
96
97
|
if (opts.phone !== undefined)
|
|
97
98
|
body.phone = opts.phone;
|
|
99
|
+
if (opts.timezone !== undefined)
|
|
100
|
+
body.timezone = opts.timezone === "" ? null : opts.timezone;
|
|
98
101
|
if (opts.company !== undefined)
|
|
99
102
|
body.company = opts.company;
|
|
100
103
|
if (opts.source !== undefined) {
|
|
@@ -123,6 +126,7 @@ contacts
|
|
|
123
126
|
.option("--last <name>", "last name")
|
|
124
127
|
.option("--email <email>", "email")
|
|
125
128
|
.option("--phone <phone>", "phone")
|
|
129
|
+
.option("--timezone <iana>", "IANA timezone of the lead (e.g. America/Chicago); omit to let Conduyt derive it from ZIP → phone area code → state. On update pass '' to clear the override")
|
|
126
130
|
.option("--company <company>", "company name (auto-creates/links a company; pass '' to clear)")
|
|
127
131
|
.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
132
|
.option("--master-status <status>", "lead lifecycle status: open|won|lost|abandoned|disqualified or an account custom (terminal statuses silence all outbound automation)")
|
|
@@ -138,6 +142,8 @@ contacts
|
|
|
138
142
|
body.email = opts.email;
|
|
139
143
|
if (opts.phone !== undefined)
|
|
140
144
|
body.phone = opts.phone;
|
|
145
|
+
if (opts.timezone !== undefined)
|
|
146
|
+
body.timezone = opts.timezone === "" ? null : opts.timezone;
|
|
141
147
|
if (opts.company !== undefined)
|
|
142
148
|
body.company = opts.company;
|
|
143
149
|
if (opts.source !== undefined) {
|
|
@@ -769,11 +775,13 @@ messages
|
|
|
769
775
|
}));
|
|
770
776
|
messages
|
|
771
777
|
.command("send-sms")
|
|
772
|
-
.description("Send an outbound SMS
|
|
778
|
+
.description("Send an outbound SMS (POST /messages/sms/send) — via Twilio, or from your own Project Blue iPhone line with --transport project_blue")
|
|
773
779
|
.option("--contact <id>", "contact UUID (required)")
|
|
774
780
|
.option("--body <text>", "SMS body, 1-1600 chars (required)")
|
|
775
781
|
.option("--from <number>", "from number — must be an account-owned Twilio number/agent DID")
|
|
776
782
|
.option("--provider <id>", "smsProviderId UUID")
|
|
783
|
+
.option("--transport <transport>", "twilio (default) | project_blue — send from your assigned Project Blue iPhone line (iMessage when the lead is on Apple, SMS otherwise)")
|
|
784
|
+
.option("--idempotency-key <key>", "operation key (8-200 chars) for at-most-once sends: reuse the SAME key when retrying a send whose outcome you did not see")
|
|
777
785
|
.option("--json <json>", "full JSON body, merged over the flags")
|
|
778
786
|
.action(run(async (client, opts) => {
|
|
779
787
|
const body = {};
|
|
@@ -785,6 +793,10 @@ messages
|
|
|
785
793
|
body.fromNumber = opts.from;
|
|
786
794
|
if (opts.provider !== undefined)
|
|
787
795
|
body.smsProviderId = opts.provider;
|
|
796
|
+
if (opts.transport !== undefined)
|
|
797
|
+
body.transport = opts.transport;
|
|
798
|
+
if (opts.idempotencyKey !== undefined)
|
|
799
|
+
body.idempotencyKey = opts.idempotencyKey;
|
|
788
800
|
if (opts.json !== undefined)
|
|
789
801
|
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
790
802
|
return client.post("/api/v1/messages/sms/send", body);
|
|
@@ -972,6 +984,10 @@ automations
|
|
|
972
984
|
.command("schema")
|
|
973
985
|
.description("Get the node/field/operator schema catalog (node types, action rules, graph shape)")
|
|
974
986
|
.action(run(async (client) => client.get("/api/v1/automations/schema")));
|
|
987
|
+
automations
|
|
988
|
+
.command("events")
|
|
989
|
+
.description("List trigger events with their labels, payload fields and documented scalar conditions (e.g. contact.untouched → untouchedDays: 1-365)")
|
|
990
|
+
.action(run(async (client) => client.get("/api/v1/automations/events")));
|
|
975
991
|
automations
|
|
976
992
|
.command("resolve")
|
|
977
993
|
.description("Resolve human-readable names to UUIDs. Pass comma lists via flags and/or a full JSON body")
|
|
@@ -1827,6 +1843,273 @@ lifecycle
|
|
|
1827
1843
|
throw new Error(`Invalid status name '${bad}' — 1-40 chars; letters, numbers, spaces, - and _ only.`);
|
|
1828
1844
|
return client.patch("/api/v1/settings", { masterStatuses: clean });
|
|
1829
1845
|
}));
|
|
1846
|
+
// ---- account default columns for the Contacts list (tri-surface parity, 2026-09-03) ----
|
|
1847
|
+
const CONTACT_COLUMN_IDS = ["name", "email", "phone", "company", "source", "tags", "score", "intent", "stage", "createdAt", "lastActivity", "assignedTo", "address", "city", "state", "zip"];
|
|
1848
|
+
lifecycle
|
|
1849
|
+
.command("get-contact-columns")
|
|
1850
|
+
.description("Show the account-level DEFAULT COLUMNS for the Contacts list (every user gets them merged into their own layout on their next visit; each version once). null = no account default.")
|
|
1851
|
+
.action(run(async (client) => {
|
|
1852
|
+
const result = (await client.get("/api/v1/settings"));
|
|
1853
|
+
const raw = result?.data?.contactTableDefaults;
|
|
1854
|
+
return { data: raw && typeof raw === "object" ? raw : null, columnIds: [...CONTACT_COLUMN_IDS, "custom:<fieldKey>"] };
|
|
1855
|
+
}));
|
|
1856
|
+
lifecycle
|
|
1857
|
+
.command("set-contact-columns [ids...]")
|
|
1858
|
+
.description("Set the account-level DEFAULT COLUMNS for the Contacts list (settings scope). Every user's saved layout gains the ids it lacks on their next visit (appended; nothing moves); the version bumps only when the set changes. Ids: the standard column ids or custom:<fieldKey>; up to 10. --clear removes the default.")
|
|
1859
|
+
.option("--clear", "remove the account default (users keep what they already show)")
|
|
1860
|
+
.action(run(async (client, ids, opts) => {
|
|
1861
|
+
ids = ids ?? [];
|
|
1862
|
+
if (ids.length === 0 && !opts.clear)
|
|
1863
|
+
throw new Error("Pass at least one column id, or --clear to remove the account default.");
|
|
1864
|
+
if (ids.length > 0 && opts.clear)
|
|
1865
|
+
throw new Error("--clear cannot be combined with column ids.");
|
|
1866
|
+
const clean = ids.map((s) => s.trim());
|
|
1867
|
+
if (clean.length > 8)
|
|
1868
|
+
throw new Error("At most 8 default columns (the table keeps its two anchors).");
|
|
1869
|
+
const bad = clean.find((id) => !CONTACT_COLUMN_IDS.includes(id) && !/^custom:[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/.test(id));
|
|
1870
|
+
if (bad)
|
|
1871
|
+
throw new Error(`Unknown column id '${bad}' — one of ${CONTACT_COLUMN_IDS.join(", ")} or custom:<fieldKey>.`);
|
|
1872
|
+
return client.patch("/api/v1/settings", { contactTableDefaults: { columns: clean } });
|
|
1873
|
+
}));
|
|
1874
|
+
// ---- call flows + ring groups (#25 tri-surface parity, 2026-08-23) ----
|
|
1875
|
+
// IVR routing graphs and their companion ring sets. Settings-scoped at the
|
|
1876
|
+
// API-key boundary (same tier as the rest of the Twilio setup surface): a
|
|
1877
|
+
// dialer-only key gets 403 here. The flow graph (--graph) is the node/edge
|
|
1878
|
+
// JSON the API validates — `validate` previews problems without publishing.
|
|
1879
|
+
const callFlows = program.command("call-flows").description("Call flows: the IVR routing graphs inbound numbers execute (settings scope)");
|
|
1880
|
+
callFlows
|
|
1881
|
+
.command("list")
|
|
1882
|
+
.description("List call flows with status (draft/published) and attached numbers")
|
|
1883
|
+
.action(run(async (client) => client.get("/api/v1/call-flows")));
|
|
1884
|
+
callFlows
|
|
1885
|
+
.command("get <id>")
|
|
1886
|
+
.description("Get one call flow incl. draft + published graphs")
|
|
1887
|
+
.action(run(async (client, id) => { assertUuid(id, "call flow id"); return client.get(`/api/v1/call-flows/${encodeURIComponent(id)}`); }));
|
|
1888
|
+
callFlows
|
|
1889
|
+
.command("create <name>")
|
|
1890
|
+
.description("Create a call flow (starts as a DRAFT)")
|
|
1891
|
+
.option("--phone-number <e164>", "account number this flow answers")
|
|
1892
|
+
.option("--graph <json>", "initial flow graph (node/edge JSON) Kinds: entry, greeting, agent, group (queue.repeatRing = queue mode), menu (options[{digit,label,next}]), function (functionId, timeoutSeconds 2-8, next/failNext), set_attributes (assignments[{key,value}]), branch (attribute, op, value, next/elseNext), timing (hours, timezone, next/closedNext), voicemail, forward, hangup; every branch must reach an ending.")
|
|
1893
|
+
.action(run(async (client, name, opts) => {
|
|
1894
|
+
const body = { name };
|
|
1895
|
+
if (opts.phoneNumber !== undefined)
|
|
1896
|
+
body.phoneNumber = opts.phoneNumber;
|
|
1897
|
+
if (opts.graph !== undefined)
|
|
1898
|
+
body.draftGraph = graphArg(opts.graph);
|
|
1899
|
+
return client.post("/api/v1/call-flows", body);
|
|
1900
|
+
}));
|
|
1901
|
+
callFlows
|
|
1902
|
+
.command("update <id>")
|
|
1903
|
+
.description("Update the DRAFT (published behavior changes only on the next publish)")
|
|
1904
|
+
.option("--name <name>", "new name")
|
|
1905
|
+
.option("--phone-number <e164>", "new number")
|
|
1906
|
+
.option("--graph <json>", "replacement draft graph (node/edge JSON) — REQUIRES --expected-draft-revision. Kinds: entry, greeting, agent, group (queue.repeatRing = queue mode), menu (options[{digit,label,next}]), function (functionId, timeoutSeconds 2-8, next/failNext), set_attributes (assignments[{key,value}]), branch (attribute, op, value, next/elseNext), timing (hours, timezone, next/closedNext), voicemail, forward, hangup; every branch must reach an ending")
|
|
1907
|
+
.option("--expected-draft-revision <n>", "draftRevision from your last `call-flows get` read (REQUIRED with --graph; the API 428s without it — it is graph-specific, so calls/publish never invalidate it)")
|
|
1908
|
+
.action(run(async (client, id, opts) => {
|
|
1909
|
+
assertUuid(id, "call flow id");
|
|
1910
|
+
const body = {};
|
|
1911
|
+
if (opts.name !== undefined)
|
|
1912
|
+
body.name = opts.name;
|
|
1913
|
+
if (opts.phoneNumber !== undefined)
|
|
1914
|
+
body.phoneNumber = opts.phoneNumber;
|
|
1915
|
+
if (opts.graph !== undefined) {
|
|
1916
|
+
if (opts.expectedDraftRevision === undefined) {
|
|
1917
|
+
throw new Error("Replacing --graph requires --expected-draft-revision (the draftRevision you read the flow at) — `call-flows get` first.");
|
|
1918
|
+
}
|
|
1919
|
+
body.draftGraph = graphArg(opts.graph);
|
|
1920
|
+
}
|
|
1921
|
+
if (opts.expectedDraftRevision !== undefined) {
|
|
1922
|
+
// Strict decimal form BEFORE Number(): Number("") is 0, and a newly
|
|
1923
|
+
// created flow's revision IS 0 — an unset shell variable must refuse
|
|
1924
|
+
// locally, never perform a real graph replacement.
|
|
1925
|
+
const raw = String(opts.expectedDraftRevision).trim();
|
|
1926
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
1927
|
+
if (!Number.isSafeInteger(n) || n < 0) {
|
|
1928
|
+
throw new Error("--expected-draft-revision must be the non-negative integer draftRevision from your last read.");
|
|
1929
|
+
}
|
|
1930
|
+
body.expectedDraftRevision = n;
|
|
1931
|
+
}
|
|
1932
|
+
if (Object.keys(body).length === 0)
|
|
1933
|
+
throw new Error("Nothing to update. Pass at least one flag.");
|
|
1934
|
+
return client.patch(`/api/v1/call-flows/${encodeURIComponent(id)}`, body);
|
|
1935
|
+
}));
|
|
1936
|
+
callFlows
|
|
1937
|
+
.command("delete <id>")
|
|
1938
|
+
.description("Delete a call flow. A PUBLISHED flow refuses deletion (409) — run `call-flows unpublish` first")
|
|
1939
|
+
.action(run(async (client, id) => { assertUuid(id, "call flow id"); return client.del(`/api/v1/call-flows/${encodeURIComponent(id)}`); }));
|
|
1940
|
+
callFlows
|
|
1941
|
+
.command("validate <id>")
|
|
1942
|
+
.description("Validate the draft graph WITHOUT publishing — structured problems")
|
|
1943
|
+
.action(run(async (client, id) => { assertUuid(id, "call flow id"); return client.get(`/api/v1/call-flows/${encodeURIComponent(id)}/validate`); }));
|
|
1944
|
+
callFlows
|
|
1945
|
+
.command("publish <id>")
|
|
1946
|
+
.description("Publish the draft (refuses an invalid graph). REQUIRES --expected-updated-at (the API 428s without it): `call-flows get` first, pass its updatedAt — a concurrent edit then 409s instead of being published over")
|
|
1947
|
+
.requiredOption("--expected-updated-at <iso>", "updatedAt from your last `call-flows get` read (REQUIRED)")
|
|
1948
|
+
.action(run(async (client, id, opts) => {
|
|
1949
|
+
assertUuid(id, "call flow id");
|
|
1950
|
+
if (Number.isNaN(Date.parse(opts.expectedUpdatedAt))) {
|
|
1951
|
+
throw new Error("--expected-updated-at must be the ISO updatedAt timestamp from your last read.");
|
|
1952
|
+
}
|
|
1953
|
+
return client.post(`/api/v1/call-flows/${encodeURIComponent(id)}/publish`, { expectedUpdatedAt: opts.expectedUpdatedAt });
|
|
1954
|
+
}));
|
|
1955
|
+
callFlows
|
|
1956
|
+
.command("unpublish <id>")
|
|
1957
|
+
.description("Unpublish — the number falls back to default routing")
|
|
1958
|
+
.action(run(async (client, id) => { assertUuid(id, "call flow id"); return client.post(`/api/v1/call-flows/${encodeURIComponent(id)}/unpublish`, {}); }));
|
|
1959
|
+
callFlows
|
|
1960
|
+
.command("simulate <id>")
|
|
1961
|
+
.description('"If someone calls right now, who picks up?" — DRAFT graph vs LIVE availability, no side effects')
|
|
1962
|
+
.action(run(async (client, id) => { assertUuid(id, "call flow id"); return client.post(`/api/v1/call-flows/${encodeURIComponent(id)}/simulate`, {}); }));
|
|
1963
|
+
// #32 Call Flows function endpoints (Amazon Connect "Invoke Lambda" parity): the
|
|
1964
|
+
// HTTPS endpoints a flow's "Call a function" step may invoke mid-call.
|
|
1965
|
+
const callFlowFunctions = callFlows.command("functions").description("Function endpoints a \"Call a function\" step can invoke mid-call (settings scope; admin to change)");
|
|
1966
|
+
callFlowFunctions
|
|
1967
|
+
.command("list")
|
|
1968
|
+
.description("List the account's function endpoints")
|
|
1969
|
+
.action(run(async (client) => client.get("/api/v1/call-flows/functions")));
|
|
1970
|
+
callFlowFunctions
|
|
1971
|
+
.command("create <name>")
|
|
1972
|
+
.description("Register a function endpoint. Conduyt POSTs a signed JSON call snapshot (X-Conduyt-Signature = sha256 HMAC of `${timestamp}.${body}`, X-Conduyt-Timestamp, Idempotency-Key); reply 200 with a JSON object — top-level strings/numbers/booleans become call attributes. https + public host only, no redirects, 2-8 s timeout")
|
|
1973
|
+
.requiredOption("--url <https-url>", "HTTPS endpoint (ports 443/8443)")
|
|
1974
|
+
.requiredOption("--secret <secret>", "HMAC signing secret (16-200 chars) the endpoint verifies with")
|
|
1975
|
+
.action(run(async (client, name, opts) => client.post("/api/v1/call-flows/functions", { name, url: opts.url, secret: opts.secret })));
|
|
1976
|
+
callFlowFunctions
|
|
1977
|
+
.command("update <id>")
|
|
1978
|
+
.description("Rename, change the URL, or rotate the secret")
|
|
1979
|
+
.option("--name <name>", "new name")
|
|
1980
|
+
.option("--url <https-url>", "new HTTPS endpoint")
|
|
1981
|
+
.option("--secret <secret>", "new signing secret (rotation)")
|
|
1982
|
+
.action(run(async (client, id, opts) => {
|
|
1983
|
+
assertUuid(id, "function id");
|
|
1984
|
+
const body = {};
|
|
1985
|
+
if (opts.name !== undefined)
|
|
1986
|
+
body.name = opts.name;
|
|
1987
|
+
if (opts.url !== undefined)
|
|
1988
|
+
body.url = opts.url;
|
|
1989
|
+
if (opts.secret !== undefined)
|
|
1990
|
+
body.secret = opts.secret;
|
|
1991
|
+
if (Object.keys(body).length === 0)
|
|
1992
|
+
throw new Error("Nothing to update. Pass at least one flag.");
|
|
1993
|
+
return client.patch(`/api/v1/call-flows/functions/${encodeURIComponent(id)}`, body);
|
|
1994
|
+
}));
|
|
1995
|
+
callFlowFunctions
|
|
1996
|
+
.command("delete <id>")
|
|
1997
|
+
.description("Remove a function endpoint (409 while a PUBLISHED flow still calls it)")
|
|
1998
|
+
.action(run(async (client, id) => { assertUuid(id, "function id"); return client.del(`/api/v1/call-flows/functions/${encodeURIComponent(id)}`); }));
|
|
1999
|
+
callFlowFunctions
|
|
2000
|
+
.command("test <id>")
|
|
2001
|
+
.description("Send a real signed test call (callSid TEST, sample caller) and report status, latency and the attributes it would save")
|
|
2002
|
+
.action(run(async (client, id) => { assertUuid(id, "function id"); return client.post(`/api/v1/call-flows/functions/${encodeURIComponent(id)}/test`, {}); }));
|
|
2003
|
+
callFlows
|
|
2004
|
+
.command("roster")
|
|
2005
|
+
.description("Routing roster: members with DIDs + live availability")
|
|
2006
|
+
.action(run(async (client) => client.get("/api/v1/call-flows/roster")));
|
|
2007
|
+
const ringGroups = program.command("ring-groups").description("Ring groups: simultaneous/sequential ring sets over member DIDs (settings scope)");
|
|
2008
|
+
ringGroups
|
|
2009
|
+
.command("list")
|
|
2010
|
+
.description("List ring groups with numbers and members")
|
|
2011
|
+
.action(run(async (client) => client.get("/api/v1/ring-groups")));
|
|
2012
|
+
ringGroups
|
|
2013
|
+
.command("get <id>")
|
|
2014
|
+
.description("Get one ring group")
|
|
2015
|
+
.action(run(async (client, id) => { assertUuid(id, "ring group id"); return client.get(`/api/v1/ring-groups/${encodeURIComponent(id)}`); }));
|
|
2016
|
+
ringGroups
|
|
2017
|
+
.command("create <name>")
|
|
2018
|
+
.description("Create a ring group. --phone-number is REQUIRED; without --member-ids the group is created INACTIVE (memberless groups never ring)")
|
|
2019
|
+
.requiredOption("--phone-number <e164>", "account number that rings this group (REQUIRED)")
|
|
2020
|
+
.option("--member-ids <ids>", "comma-separated member user UUIDs (omit → created inactive)")
|
|
2021
|
+
.option("--strategy <strategy>", "ring strategy (e.g. simultaneous, sequential)")
|
|
2022
|
+
.option("--ring-seconds <n>", "seconds to ring before advancing/failing over (integer 5-120)")
|
|
2023
|
+
.option("--inactive", "create as inactive")
|
|
2024
|
+
.action(run(async (client, name, opts) => {
|
|
2025
|
+
// memberIds must ALWAYS be an array — the API rejects undefined
|
|
2026
|
+
// (validateRingGroupMemberIds requires an array; [] = memberless →
|
|
2027
|
+
// the group is created INACTIVE).
|
|
2028
|
+
const body = {
|
|
2029
|
+
name,
|
|
2030
|
+
phoneNumber: opts.phoneNumber,
|
|
2031
|
+
memberIds: opts.memberIds !== undefined ? memberIdsArg(opts.memberIds) : [],
|
|
2032
|
+
};
|
|
2033
|
+
if (opts.strategy !== undefined)
|
|
2034
|
+
body.strategy = opts.strategy;
|
|
2035
|
+
if (opts.ringSeconds !== undefined)
|
|
2036
|
+
body.ringSeconds = ringSecondsArg(opts.ringSeconds);
|
|
2037
|
+
if (opts.inactive)
|
|
2038
|
+
body.isActive = false;
|
|
2039
|
+
return client.post("/api/v1/ring-groups", body);
|
|
2040
|
+
}));
|
|
2041
|
+
ringGroups
|
|
2042
|
+
.command("update <id>")
|
|
2043
|
+
.description("Update a ring group. --expected-updated-at makes a concurrent edit 409 instead of being overwritten")
|
|
2044
|
+
.option("--name <name>", "new name")
|
|
2045
|
+
.option("--phone-number <e164>", "new number")
|
|
2046
|
+
.option("--member-ids <ids>", 'comma-separated replacement member UUIDs, or "none" to clear the roster (deactivates the group)')
|
|
2047
|
+
.option("--strategy <strategy>", "ring strategy")
|
|
2048
|
+
.option("--ring-seconds <n>", "seconds to ring")
|
|
2049
|
+
.option("--active <bool>", 'active flag — exactly "true" or "false"')
|
|
2050
|
+
.option("--expected-updated-at <iso>", "updatedAt from your last read (REQUIRED when replacing --member-ids)")
|
|
2051
|
+
.action(run(async (client, id, opts) => {
|
|
2052
|
+
assertUuid(id, "ring group id");
|
|
2053
|
+
const body = {};
|
|
2054
|
+
if (opts.name !== undefined)
|
|
2055
|
+
body.name = opts.name;
|
|
2056
|
+
if (opts.phoneNumber !== undefined)
|
|
2057
|
+
body.phoneNumber = opts.phoneNumber;
|
|
2058
|
+
if (opts.memberIds !== undefined) {
|
|
2059
|
+
if (opts.expectedUpdatedAt === undefined) {
|
|
2060
|
+
throw new Error("Replacing --member-ids requires --expected-updated-at (the updatedAt you read the group at) — `ring-groups get` first.");
|
|
2061
|
+
}
|
|
2062
|
+
const ids = memberIdsArg(opts.memberIds);
|
|
2063
|
+
body.memberIds = ids;
|
|
2064
|
+
if (ids.length === 0) {
|
|
2065
|
+
// An empty roster cannot stay active (the API 400s an active
|
|
2066
|
+
// memberless group) — clearing implies deactivation.
|
|
2067
|
+
if (opts.active === "true") {
|
|
2068
|
+
throw new Error('--member-ids none clears the roster and deactivates the group — it cannot combine with --active true.');
|
|
2069
|
+
}
|
|
2070
|
+
body.isActive = false;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
if (opts.strategy !== undefined)
|
|
2074
|
+
body.strategy = opts.strategy;
|
|
2075
|
+
if (opts.ringSeconds !== undefined)
|
|
2076
|
+
body.ringSeconds = ringSecondsArg(opts.ringSeconds);
|
|
2077
|
+
if (opts.active !== undefined)
|
|
2078
|
+
body.isActive = strictBool(opts.active, "--active");
|
|
2079
|
+
if (opts.expectedUpdatedAt !== undefined) {
|
|
2080
|
+
if (Number.isNaN(Date.parse(opts.expectedUpdatedAt))) {
|
|
2081
|
+
throw new Error("--expected-updated-at must be the ISO updatedAt timestamp from your last read.");
|
|
2082
|
+
}
|
|
2083
|
+
body.expectedUpdatedAt = opts.expectedUpdatedAt;
|
|
2084
|
+
}
|
|
2085
|
+
if (Object.keys(body).length === 0)
|
|
2086
|
+
throw new Error("Nothing to update. Pass at least one flag.");
|
|
2087
|
+
return client.patch(`/api/v1/ring-groups/${encodeURIComponent(id)}`, body);
|
|
2088
|
+
}));
|
|
2089
|
+
ringGroups
|
|
2090
|
+
.command("delete <id>")
|
|
2091
|
+
.description("Delete a ring group (its number keeps default routing)")
|
|
2092
|
+
.action(run(async (client, id) => { assertUuid(id, "ring group id"); return client.del(`/api/v1/ring-groups/${encodeURIComponent(id)}`); }));
|
|
2093
|
+
ringGroups
|
|
2094
|
+
.command("release-number <e164>")
|
|
2095
|
+
.description("Release a number from routing (DESTRUCTIVE). --confirm must repeat the EXACT number. A number still referenced by an ACTIVE group cannot be released — delete or repoint that group first; --force does NOT bypass it (it exists only for numbers whose Twilio ownership cannot be verified, and then --force-reason is required)")
|
|
2096
|
+
.requiredOption("--confirm <e164>", "EXACT repetition of the number being released (REQUIRED)")
|
|
2097
|
+
.option("--force", "ONLY when Twilio ownership is unverifiable; requires --force-reason")
|
|
2098
|
+
.option("--force-reason <why>", "non-empty justification, required with --force")
|
|
2099
|
+
.action(run(async (client, phoneNumber, opts) => {
|
|
2100
|
+
if (opts.confirm !== phoneNumber) {
|
|
2101
|
+
throw new Error(`--confirm must repeat the exact number (${phoneNumber}) — got "${opts.confirm}".`);
|
|
2102
|
+
}
|
|
2103
|
+
if (opts.force && !(opts.forceReason && opts.forceReason.trim())) {
|
|
2104
|
+
throw new Error("--force requires a non-empty --force-reason.");
|
|
2105
|
+
}
|
|
2106
|
+
const body = { phoneNumber, confirm: opts.confirm };
|
|
2107
|
+
if (opts.force) {
|
|
2108
|
+
body.force = true;
|
|
2109
|
+
body.forceReason = opts.forceReason;
|
|
2110
|
+
}
|
|
2111
|
+
return client.post("/api/v1/ring-groups/release-number", body);
|
|
2112
|
+
}));
|
|
1830
2113
|
program.parseAsync(process.argv).catch(fail);
|
|
1831
2114
|
// helpers
|
|
1832
2115
|
// Parse a --json / --stages style argument, throwing a clear CLI error (caught
|
|
@@ -1865,6 +2148,44 @@ function assertFiniteValue(v) {
|
|
|
1865
2148
|
// The path is also URL-encoded at the call site as defence in depth. The regex
|
|
1866
2149
|
// is inlined (not a module-level const) so it isn't in the temporal dead zone
|
|
1867
2150
|
// when an action callback runs during program.parse().
|
|
2151
|
+
const projectBlue = program.command("project-blue").description("Project Blue: dedicated iPhone lines (iMessage/SMS + FaceTime Audio), one line per agent (settings scope)");
|
|
2152
|
+
projectBlue
|
|
2153
|
+
.command("status")
|
|
2154
|
+
.description("Connection status (key masked) + current line assignments")
|
|
2155
|
+
.action(run(async (client) => client.get("/api/v1/settings/project-blue")));
|
|
2156
|
+
projectBlue
|
|
2157
|
+
.command("connect")
|
|
2158
|
+
.description("Connect or replace the account's Project Blue API key — verified with the vendor before it is stored")
|
|
2159
|
+
.option("--key <apiKey>", "Project Blue API key (proj_…); omit to read PROJECT_BLUE_API_KEY from the environment and keep the key out of shell history")
|
|
2160
|
+
.action(run(async (client, opts) => {
|
|
2161
|
+
const apiKey = (opts.key ?? process.env.PROJECT_BLUE_API_KEY ?? "").trim();
|
|
2162
|
+
if (!apiKey)
|
|
2163
|
+
throw new Error("Pass --key or set PROJECT_BLUE_API_KEY.");
|
|
2164
|
+
return client.put("/api/v1/settings/project-blue", { apiKey });
|
|
2165
|
+
}));
|
|
2166
|
+
projectBlue
|
|
2167
|
+
.command("disconnect")
|
|
2168
|
+
.description("Forget the API key and release every agent's line")
|
|
2169
|
+
.action(run(async (client) => client.del("/api/v1/settings/project-blue")));
|
|
2170
|
+
projectBlue
|
|
2171
|
+
.command("lines")
|
|
2172
|
+
.description("LIVE line inventory from the vendor with each line's assigned agent, plus assignments the vendor no longer lists (orphaned)")
|
|
2173
|
+
.action(run(async (client) => client.get("/api/v1/settings/project-blue/lines")));
|
|
2174
|
+
projectBlue
|
|
2175
|
+
.command("assign <lineId> <userId>")
|
|
2176
|
+
.description("Give a line to an agent (one line per agent, one agent per line — prior holders are released and reported)")
|
|
2177
|
+
.action(run(async (client, lineId, userId) => {
|
|
2178
|
+
assertUuid(lineId, "line id");
|
|
2179
|
+
assertUuid(userId, "user id");
|
|
2180
|
+
return client.put(`/api/v1/settings/project-blue/lines/${encodeURIComponent(lineId)}`, { userId });
|
|
2181
|
+
}));
|
|
2182
|
+
projectBlue
|
|
2183
|
+
.command("release <lineId>")
|
|
2184
|
+
.description("Release a line from whoever holds it")
|
|
2185
|
+
.action(run(async (client, lineId) => {
|
|
2186
|
+
assertUuid(lineId, "line id");
|
|
2187
|
+
return client.put(`/api/v1/settings/project-blue/lines/${encodeURIComponent(lineId)}`, { userId: null });
|
|
2188
|
+
}));
|
|
1868
2189
|
function assertUuid(id, label = "id") {
|
|
1869
2190
|
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1870
2191
|
if (!uuidRe.test(id.trim())) {
|
|
@@ -1900,6 +2221,39 @@ function jsonArrayArg(raw, flag) {
|
|
|
1900
2221
|
}
|
|
1901
2222
|
return v;
|
|
1902
2223
|
}
|
|
2224
|
+
function strictBool(raw, flag) {
|
|
2225
|
+
if (raw === "true")
|
|
2226
|
+
return true;
|
|
2227
|
+
if (raw === "false")
|
|
2228
|
+
return false;
|
|
2229
|
+
throw new Error(`${flag} must be exactly "true" or "false" (got "${raw}").`);
|
|
2230
|
+
}
|
|
2231
|
+
function ringSecondsArg(raw) {
|
|
2232
|
+
const n = Number(raw);
|
|
2233
|
+
if (!Number.isInteger(n) || n < 5 || n > 120) {
|
|
2234
|
+
throw new Error("--ring-seconds must be an integer between 5 and 120.");
|
|
2235
|
+
}
|
|
2236
|
+
return n;
|
|
2237
|
+
}
|
|
2238
|
+
function memberIdsArg(raw) {
|
|
2239
|
+
// "none" is the explicit empty roster: the API accepts memberIds: [] and
|
|
2240
|
+
// uses it to clear (and deactivate) a group — without a spelled form the
|
|
2241
|
+
// CLI could never remove the final member.
|
|
2242
|
+
if (raw.trim().toLowerCase() === "none")
|
|
2243
|
+
return [];
|
|
2244
|
+
const parts = raw.split(",").map((v) => v.trim());
|
|
2245
|
+
if (parts.length === 0 || parts.some((v) => v === "")) {
|
|
2246
|
+
throw new Error('--member-ids must be a comma-separated list of user UUIDs with no empty entries (or "none" to clear the roster).');
|
|
2247
|
+
}
|
|
2248
|
+
return parts;
|
|
2249
|
+
}
|
|
2250
|
+
function graphArg(raw) {
|
|
2251
|
+
const g = jsonArg(raw, "--graph");
|
|
2252
|
+
if (typeof g.entryId !== "string" || !Array.isArray(g.nodes)) {
|
|
2253
|
+
throw new Error("--graph must be a flow graph object with a string entryId and a nodes array — the API silently substitutes a default voicemail flow for anything else.");
|
|
2254
|
+
}
|
|
2255
|
+
return g;
|
|
2256
|
+
}
|
|
1903
2257
|
function jsonArg(raw, flag) {
|
|
1904
2258
|
if (raw.trim() === "") {
|
|
1905
2259
|
throw new Error(`${flag} must be valid JSON (got an empty string).`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conduyt",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"typecheck": "tsc --noEmit",
|
|
20
20
|
"start": "tsx src/index.ts",
|
|
21
|
-
"prepublishOnly": "npm run check:version && npm run build && node --import tsx --experimental-test-module-mocks --test src
|
|
21
|
+
"prepublishOnly": "npm run check:version && npm run build && node --import tsx --experimental-test-module-mocks --test src/*.test.ts",
|
|
22
22
|
"test": "node --import tsx --test src/*.test.ts",
|
|
23
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)\""
|
|
24
24
|
},
|