conduyt 1.65.0 → 1.67.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 +24 -1
- package/dist/action-config-typos.js +78 -0
- package/dist/client.js +97 -37
- package/dist/index.js +179 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ The saved config lives at `~/.conduyt/config.json` and is written owner-only (`c
|
|
|
32
32
|
```bash
|
|
33
33
|
conduyt whoami # show the authenticated user
|
|
34
34
|
conduyt contacts list --limit 25 # list contacts (supports --cursor, --query)
|
|
35
|
-
conduyt contacts get <id> # fetch one contact (carries `revision`)
|
|
35
|
+
conduyt contacts get <id> # fetch one contact (carries `revision` and `hasOutboundSms`, 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 collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
|
|
38
38
|
conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
|
|
@@ -44,6 +44,8 @@ conduyt notifications list [--page n] [--per-page 1-200] # prints { data, meta
|
|
|
44
44
|
conduyt deals list --pipeline <id> # list deals
|
|
45
45
|
conduyt deals list --health-summary --pipeline <id> # Pipeline health strip for the filtered set (totalOpenDeals, atRiskValue, checks[]); add --health-check stale --health-limit 20 for one check's deals, --health-cursor <nextCursor> to page, --health-rows-only for just the page
|
|
46
46
|
conduyt deals health --pipeline <id> --check needs_action # GET /deals/inspection: the six checks over open deals; --check adds its deals, --cursor/--offset pages
|
|
47
|
+
conduyt deals health-fix <dealId> --check stale --expected-updated-at <iso> --stage-id <uuid> # Pipeline health quick fix (PATCH /deals/{id} with checkOrigin): stale = --stage-id, unassigned = --assigned, missing_close_date = --close-date, missing_contact = --contact; 409 deal_changed / deal_check_cleared and 400 health_fix_mismatch exit 1 verbatim, never retried
|
|
48
|
+
conduyt deals health-next-action <dealId> --check needs_action --expected-updated-at <iso> --title "Call back" --idempotency-key <key> # "Set next action" (POST /tasks with checkOrigin): one non-done quick-fix task per deal, 409 deal_has_next_action / deal_changed / deal_check_cleared exit 1 verbatim
|
|
47
49
|
conduyt deals card-fields set value,contact,custom:next_contact_at # the kanban card fields (1-8, built-ins or custom:<fieldKey>); `card-fields get` reads them
|
|
48
50
|
conduyt pipelines stage-rules set <pipelineId> <stageId> --required-fields contactId,custom:loan_amount --required-documents 2 [--lost-reason-allow-other true] # what a deal needs to enter the stage; other stages untouched; `stage-rules get [pipelineId] [stageId]` reads
|
|
49
51
|
conduyt deals bulk-update --file updates.json # update up to 100 deals at once ([{id, stageId, value, ...}]); per-item results, exit 1 if any failed
|
|
@@ -76,15 +78,18 @@ conduyt whatsapp connect <senderSid> # connect a sender (verified with Twilio
|
|
|
76
78
|
conduyt whatsapp templates # approved / pending / rejected templates with their {{n}} placeholders
|
|
77
79
|
conduyt dialer voice-drop --contact <id> [--recording "Missed you"] [--if-human play] # a call that plays a Voicemail Drop recording to a machine
|
|
78
80
|
conduyt messages send-sms --contact <id> --transport whatsapp --whatsapp-template HX… --whatsapp-variables '{"1":"Alex"}' --body "…"
|
|
81
|
+
conduyt messages check --channel sms --body "…" [--context first|conversation] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
|
|
79
82
|
conduyt privacy export <id> # GDPR data-portability export (owner/admin)
|
|
80
83
|
conduyt privacy forget <id> --confirm FORGET # GDPR erasure — IRREVERSIBLE, owner only
|
|
81
84
|
conduyt reply-capture status # inbound reply capture: state, DNS records, provider health
|
|
82
85
|
conduyt reply-capture setup # create the receiving subdomain (--domain optional)
|
|
83
86
|
conduyt reply-capture verify # promote it to live once DNS is confirmed
|
|
84
87
|
conduyt reply-capture remove --confirm REMOVE # remove capture — replies to already-sent email are lost
|
|
88
|
+
conduyt integrations wordpress-forms status # is any intake address active, and how many ({ hasActive, activeCount }; count only, read-only, integrations scope)
|
|
85
89
|
conduyt integrations wordpress-forms list # WordPress form intake addresses: full URL of every active one, received counts
|
|
86
90
|
conduyt integrations wordpress-forms create --name "Contact page" --default-tag "Website lead" # prints the full URL
|
|
87
91
|
conduyt integrations wordpress-forms revoke <id> # the address answers 404 from now on; the row is kept
|
|
92
|
+
conduyt lifecycle set-booking-creates-own-card --enable # a booking creates its own card owned by the host instead of reusing the contact's open intake card (--disable = default; `lifecycle settings` shows bookingCreatesOwnCard)
|
|
88
93
|
conduyt api GET /api/v1/companies # raw authenticated request (escape hatch)
|
|
89
94
|
conduyt config show # show resolved config (key masked)
|
|
90
95
|
```
|
|
@@ -93,6 +98,24 @@ Pass `--input '{"...":"..."}'` to `insights` and `--body '{"...":"..."}'` to `ap
|
|
|
93
98
|
|
|
94
99
|
All output is JSON, ready to pipe into `jq`.
|
|
95
100
|
|
|
101
|
+
## Error output grammar
|
|
102
|
+
|
|
103
|
+
A refused call (HTTP 4xx/5xx) prints one line on stderr and exits 1:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
Error: Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: nothing is retried). `<details>` appears
|
|
110
|
+
only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try each `{` from the left
|
|
111
|
+
until the rest of the line parses as JSON (a server message may itself contain a brace). Scalars (`code`, revision) come
|
|
112
|
+
first, nested objects whole; `HealthFixRefusal.row` is always whole, any other nested object too large for the bound is left
|
|
113
|
+
out and named in `_omitted`. Every HTTP refusal takes this path (JSON calls, text exports and streams alike). The settings-conflict commands merge their hint and the current settings INTO that final object (`{...details, hint, current}`), never after it. Example:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
Error: Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
|
|
117
|
+
```
|
|
118
|
+
|
|
96
119
|
## License
|
|
97
120
|
|
|
98
121
|
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -7,6 +7,98 @@ function ambiguousTimeout(err, method, path, timeoutMs, what) {
|
|
|
7
7
|
}
|
|
8
8
|
return err;
|
|
9
9
|
}
|
|
10
|
+
/** Room for the nested objects of an error body (a 409 contact_changed's `contact`, a stage-requirements list). */
|
|
11
|
+
const ERROR_DETAILS_BOUND = 1000;
|
|
12
|
+
/** Error-body keys that are the actionable state itself and are always rendered WHOLE: HealthFixRefusal.row (the deal's
|
|
13
|
+
* current health row, which a caller needs to choose again). */
|
|
14
|
+
const ALWAYS_WHOLE_KEYS = new Set(["row"]);
|
|
15
|
+
/** Bounded scheme for a refusal's error LISTS (the automation write refusals: AutomationActionConfigKeyError.errors, the 422
|
|
16
|
+
* AutomationPreflightRefusal.data { message, errors[], warnings[] }) when they do not fit ERROR_DETAILS_BOUND: keep the first
|
|
17
|
+
* MAX_LISTED_ERRORS entries (warnings: MAX_LISTED_WARNINGS) with every string field capped at MAX_FIELD_CHARS, and close the list with
|
|
18
|
+
* { "_more": K } for the K entries dropped, so each kept error still carries its code, nodeId, field and suggestion text. */
|
|
19
|
+
const MAX_LISTED_ERRORS = 25;
|
|
20
|
+
const MAX_LISTED_WARNINGS = 10;
|
|
21
|
+
const MAX_FIELD_CHARS = 400;
|
|
22
|
+
const capText = (s) => (s.length > MAX_FIELD_CHARS ? s.slice(0, MAX_FIELD_CHARS) + "...[shortened]" : s);
|
|
23
|
+
const capEntry = (e) => {
|
|
24
|
+
if (typeof e === "string")
|
|
25
|
+
return capText(e);
|
|
26
|
+
if (e && typeof e === "object" && !Array.isArray(e))
|
|
27
|
+
return Object.fromEntries(Object.entries(e).map(([k, v]) => [k, typeof v === "string" ? capText(v) : v]));
|
|
28
|
+
return e;
|
|
29
|
+
};
|
|
30
|
+
const capList = (list, max) => [...list.slice(0, max).map(capEntry), ...(list.length > max ? [{ _more: list.length - max }] : [])];
|
|
31
|
+
function compactRefusalList(key, value) {
|
|
32
|
+
if (key === "errors" && Array.isArray(value))
|
|
33
|
+
return capList(value, MAX_LISTED_ERRORS);
|
|
34
|
+
if (key === "data" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
35
|
+
const d = value;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [k, v] of Object.entries(d)) {
|
|
38
|
+
if (k === "message" && typeof v === "string")
|
|
39
|
+
out[k] = capText(v);
|
|
40
|
+
else if (k === "errors" && Array.isArray(v))
|
|
41
|
+
out[k] = capList(v, MAX_LISTED_ERRORS);
|
|
42
|
+
else if (k === "warnings" && Array.isArray(v))
|
|
43
|
+
out[k] = capList(v, MAX_LISTED_WARNINGS);
|
|
44
|
+
else if (v === null || typeof v !== "object")
|
|
45
|
+
out[k] = v;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/** Everything but `error` as ONE valid JSON object: scalars first (a very long string is shortened inside its quotes), then
|
|
52
|
+
* nested objects whole while they fit ERROR_DETAILS_BOUND, plus ALWAYS_WHOLE_KEYS; one that does not fit is left out and
|
|
53
|
+
* named in `_omitted`. JSON is never cut mid-object. */
|
|
54
|
+
export function renderErrorDetails(body) {
|
|
55
|
+
const entries = Object.entries(body).filter(([k]) => k !== "error");
|
|
56
|
+
if (entries.length === 0)
|
|
57
|
+
return "";
|
|
58
|
+
const isScalar = (v) => v === null || typeof v !== "object";
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [k, v] of entries.filter(([, v]) => isScalar(v)))
|
|
61
|
+
out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
|
|
62
|
+
const omitted = [];
|
|
63
|
+
for (const [k, v] of entries.filter(([, v]) => !isScalar(v))) {
|
|
64
|
+
// an EMPTY list / object (the import refusal's `warnings: []`) always fits: it costs two characters and is never "omitted"
|
|
65
|
+
if (ALWAYS_WHOLE_KEYS.has(k) || JSON.stringify(v).length <= 2 || JSON.stringify({ ...out, [k]: v }).length <= ERROR_DETAILS_BOUND)
|
|
66
|
+
out[k] = v;
|
|
67
|
+
else {
|
|
68
|
+
// a refusal's error list never vanishes into `_omitted`: it is kept in the bounded scheme above (codes and suggestions intact)
|
|
69
|
+
const compact = compactRefusalList(k, v);
|
|
70
|
+
if (compact !== undefined)
|
|
71
|
+
out[k] = compact;
|
|
72
|
+
else
|
|
73
|
+
omitted.push(k);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (omitted.length > 0)
|
|
77
|
+
out._omitted = omitted;
|
|
78
|
+
return JSON.stringify(out);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The ONE place an HTTP refusal becomes an Error (request, postText and stream all use it). Grammar (stable):
|
|
82
|
+
* `Conduyt API <status>: <message>[ [Retry-After: <N>s]][ <details>]`; <details> is ONE JSON object and the FINAL token
|
|
83
|
+
* (renderErrorDetails). Retry-After is shown, never acted on. A structured body without `error` IS the details object.
|
|
84
|
+
*/
|
|
85
|
+
export function formatHttpError(res, text) {
|
|
86
|
+
const json = text ? safeParse(text) : null;
|
|
87
|
+
const ra = res.headers.get("retry-after");
|
|
88
|
+
const raN = ra === null || ra.trim() === "" ? NaN : Number(ra);
|
|
89
|
+
const raTag = Number.isFinite(raN) && raN >= 0 ? ` [Retry-After: ${raN}s]` : "";
|
|
90
|
+
const obj = json && typeof json === "object" && !Array.isArray(json) ? json : null;
|
|
91
|
+
if (obj && typeof obj.error === "string" && obj.error) {
|
|
92
|
+
const extras = renderErrorDetails(obj);
|
|
93
|
+
return new Error(`Conduyt API ${res.status}: ${obj.error}${raTag}${extras ? ` ${extras}` : ""}`);
|
|
94
|
+
}
|
|
95
|
+
// several endpoints answer the data a caller needs IN the error body (custom-field delete 409 valueCount / dependencyCount)
|
|
96
|
+
const extras = obj ? renderErrorDetails(obj) : "";
|
|
97
|
+
if (extras)
|
|
98
|
+
return new Error(`Conduyt API ${res.status}:${raTag} ${extras}`);
|
|
99
|
+
const plain = typeof json === "string" && json ? json.slice(0, 500) : "";
|
|
100
|
+
return new Error(`Conduyt API ${res.status}: ${plain || res.statusText || `HTTP ${res.status}`}${raTag}`);
|
|
101
|
+
}
|
|
10
102
|
export class ConduytClient {
|
|
11
103
|
baseUrl;
|
|
12
104
|
apiKey;
|
|
@@ -25,6 +117,7 @@ export class ConduytClient {
|
|
|
25
117
|
res = await fetch(url, {
|
|
26
118
|
method,
|
|
27
119
|
headers: {
|
|
120
|
+
...(opts?.headers ?? {}),
|
|
28
121
|
Authorization: `Bearer ${this.apiKey}`,
|
|
29
122
|
"Content-Type": "application/json",
|
|
30
123
|
},
|
|
@@ -45,31 +138,7 @@ export class ConduytClient {
|
|
|
45
138
|
}
|
|
46
139
|
const json = text ? safeParse(text) : null;
|
|
47
140
|
if (!res.ok) {
|
|
48
|
-
|
|
49
|
-
// a caller needs to proceed IN the error body (e.g. custom-field delete
|
|
50
|
-
// 409 carries valueCount/dependencyCount for the --confirm flow).
|
|
51
|
-
// Reducing those to statusText would strand the user.
|
|
52
|
-
const structured = json && typeof json === "object" && !json.error
|
|
53
|
-
? JSON.stringify(json)
|
|
54
|
-
: null;
|
|
55
|
-
let msg = json?.error || structured || res.statusText || `HTTP ${res.status}`;
|
|
56
|
-
// Bodies with BOTH an error message and structured context (e.g. the
|
|
57
|
-
// STAGE_REQUIREMENTS 422 carries lostReasonOptions) keep the context —
|
|
58
|
-
// same principle as `structured` above, bounded against huge bodies.
|
|
59
|
-
if (json && typeof json === "object" && json.error) {
|
|
60
|
-
// Scalars first (code, revision), nested objects last: a 409 contact_changed also carries the whole current
|
|
61
|
-
// `contact`, and the bound below may cut that, never the code or the current revision.
|
|
62
|
-
const isScalar = (v) => v === null || typeof v !== "object";
|
|
63
|
-
const entries = Object.entries(json).filter(([k]) => k !== "error");
|
|
64
|
-
const extras = Object.fromEntries([
|
|
65
|
-
...entries.filter(([, v]) => isScalar(v)),
|
|
66
|
-
...entries.filter(([, v]) => !isScalar(v)),
|
|
67
|
-
]);
|
|
68
|
-
if (Object.keys(extras).length > 0) {
|
|
69
|
-
msg = `${msg} ${JSON.stringify(extras).slice(0, 1000)}`;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
throw new Error(`Conduyt API ${res.status}: ${msg}`);
|
|
141
|
+
throw formatHttpError(res, text);
|
|
73
142
|
}
|
|
74
143
|
return json;
|
|
75
144
|
}
|
|
@@ -98,14 +167,8 @@ export class ConduytClient {
|
|
|
98
167
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
99
168
|
});
|
|
100
169
|
const text = await res.text();
|
|
101
|
-
if (!res.ok)
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
msg = JSON.parse(text).error ?? msg;
|
|
105
|
-
}
|
|
106
|
-
catch { /* keep the raw text */ }
|
|
107
|
-
throw new Error(`Conduyt API ${res.status}: ${msg}`);
|
|
108
|
-
}
|
|
170
|
+
if (!res.ok)
|
|
171
|
+
throw formatHttpError(res, text);
|
|
109
172
|
return { text, filename: res.headers.get("x-report-filename"), contentType: res.headers.get("content-type") };
|
|
110
173
|
}
|
|
111
174
|
// Stream `ai chat`'s text/event-stream response to stdout as clean answer
|
|
@@ -127,10 +190,7 @@ export class ConduytClient {
|
|
|
127
190
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
128
191
|
});
|
|
129
192
|
if (!res.ok || !res.body) {
|
|
130
|
-
|
|
131
|
-
const json = text ? safeParse(text) : null;
|
|
132
|
-
const msg = json?.error || res.statusText || `HTTP ${res.status}`;
|
|
133
|
-
throw new Error(`Conduyt API ${res.status}: ${msg}`);
|
|
193
|
+
throw formatHttpError(res, await res.text());
|
|
134
194
|
}
|
|
135
195
|
const reader = res.body.getReader();
|
|
136
196
|
const decoder = new TextDecoder();
|
package/dist/index.js
CHANGED
|
@@ -79,7 +79,7 @@ contacts
|
|
|
79
79
|
.action(run(async (client, opts) => client.get(`/api/v1/contacts${buildQuery({ limit: opts.limit, cursor: opts.cursor, query: opts.query, master_status: opts.masterStatus, exclude_master_status: opts.excludeMasterStatus })}`)));
|
|
80
80
|
contacts
|
|
81
81
|
.command("get <id>")
|
|
82
|
-
.description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`)")
|
|
82
|
+
.description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation)")
|
|
83
83
|
.action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
|
|
84
84
|
contacts
|
|
85
85
|
.command("create")
|
|
@@ -536,6 +536,73 @@ deals
|
|
|
536
536
|
const qs = usp.toString();
|
|
537
537
|
return client.get(`/api/v1/deals/inspection${qs ? `?${qs}` : ""}`);
|
|
538
538
|
}));
|
|
539
|
+
const HEALTH_FIX_REFUSALS = "Error line grammar: `Error: Conduyt API <status>: <message>[ [Retry-After: Ns]][ <one JSON object, the final token>]` (README, Error output grammar). Refusals exit 1 with the server's status, message and code printed verbatim, sent once (never retried): 409 deal_changed (the deal is no longer at --expected-updated-at; the error carries updatedAt and the deal's current health row: read it and choose again against row.updatedAt), 409 deal_check_cleared (the deal no longer fails that check), 400 health_fix_mismatch, 404";
|
|
540
|
+
deals
|
|
541
|
+
.command("health-fix <id>")
|
|
542
|
+
.description(`Pipeline health QUICK FIX for a deal row (PATCH /deals/{id} with checkOrigin). Send the check the row came from, --expected-updated-at (the deal's updatedAt as the health row carried it, REQUIRED) and ONLY that check's fix: stale = --stage-id (+ --expected-stage-id), unassigned = --assigned, missing_close_date = --close-date, missing_contact = --contact. Anything else is refused 400 health_fix_mismatch and nothing is read or written. For high_value_idle / needs_action use \`deals health-next-action\`. ${HEALTH_FIX_REFUSALS}; an uncoded 409 is a stage move by another writer, a stale --expected-stage-id or an in-flight publication`)
|
|
543
|
+
.requiredOption("--check <check>", "stale | unassigned | missing_close_date | missing_contact")
|
|
544
|
+
.requiredOption("--expected-updated-at <iso>", "the deal's updatedAt as the health row carried it (PipelineHealthDeal.updatedAt)")
|
|
545
|
+
.option("--stage-id <uuid>", "stale: the stage UUID to move the deal to")
|
|
546
|
+
.option("--expected-stage-id <uuid>", "stale: the stage the move was chosen against; 409 (nothing written) when the deal is no longer in it")
|
|
547
|
+
.option("--assigned <uuid>", "unassigned: the user (owner) UUID to assign")
|
|
548
|
+
.option("--close-date <date>", "missing_close_date: the expected close date (ISO 8601)")
|
|
549
|
+
.option("--contact <uuid>", "missing_contact: the contact UUID to link")
|
|
550
|
+
.action(run(async (client, id, opts) => {
|
|
551
|
+
const dealId = uuidOf(id, "deal id");
|
|
552
|
+
if (!["stale", "unassigned", "missing_close_date", "missing_contact"].includes(opts.check))
|
|
553
|
+
throw new Error("--check must be stale, unassigned, missing_close_date or missing_contact (for high_value_idle / needs_action use `deals health-next-action`).");
|
|
554
|
+
rejectBlankFlags([["--close-date", opts.closeDate]]);
|
|
555
|
+
const body = { checkOrigin: opts.check, expectedUpdatedAt: isoDate(opts.expectedUpdatedAt, "--expected-updated-at") };
|
|
556
|
+
if (opts.stageId !== undefined)
|
|
557
|
+
body.stageId = uuidOf(opts.stageId, "--stage-id");
|
|
558
|
+
if (opts.expectedStageId !== undefined)
|
|
559
|
+
body.expectedStageId = uuidOf(opts.expectedStageId, "--expected-stage-id");
|
|
560
|
+
if (opts.assigned !== undefined)
|
|
561
|
+
body.assignedTo = uuidOf(opts.assigned, "--assigned");
|
|
562
|
+
if (opts.closeDate !== undefined)
|
|
563
|
+
body.expectedCloseDate = opts.closeDate;
|
|
564
|
+
if (opts.contact !== undefined)
|
|
565
|
+
body.contactId = uuidOf(opts.contact, "--contact");
|
|
566
|
+
return client.patch(`/api/v1/deals/${encodeURIComponent(dealId)}`, body);
|
|
567
|
+
}));
|
|
568
|
+
deals
|
|
569
|
+
.command("health-next-action <id>")
|
|
570
|
+
.description(`Pipeline health QUICK FIX "Set next action" (POST /tasks with checkOrigin): creates a follow-up task on deal <id> from a High-value idle or Needs next action row. The task is created only while, under the deal's next-action lock, the deal is still at --expected-updated-at, still has no next action and still fails the check. Pass --idempotency-key (1-255 printable ASCII) so a retry after a lost response replays the first task; reuse it ONLY for the same request. The database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action); ordinary tasks (\`tasks create\`) are unconstrained and may coexist with or race a quick fix. ${HEALTH_FIX_REFUSALS.replace("409 deal_check_cleared", "409 deal_has_next_action (This deal already has a next action booked.), 409 deal_check_cleared")}; also an uncoded 409 (the contact was merged or deleted mid-request: retry against the surviving record) and the idempotency 409 idempotency_key_spent / 503 idempotency_claim_held / idempotency_outcome_unknown (verify the outcome before using a new key)`)
|
|
571
|
+
.requiredOption("--check <check>", "high_value_idle | needs_action")
|
|
572
|
+
.requiredOption("--expected-updated-at <iso>", "the deal's updatedAt as the health row carried it (sent as expectedDealUpdatedAt)")
|
|
573
|
+
.requiredOption("--title <title>", "task title (1-500 chars)")
|
|
574
|
+
.option("--description <text>", "description (max 5000)")
|
|
575
|
+
.option("--due <date>", "due date: YYYY-MM-DD is stored as noon UTC that day; a date-time as given")
|
|
576
|
+
.option("--priority <priority>", "low|medium|high|urgent (default medium)")
|
|
577
|
+
.option("--status <status>", "todo|in_progress|done (default todo)")
|
|
578
|
+
.option("--assigned <id>", "assignee user UUID, or @me / @owner (assigning to someone else needs tasks.assign)")
|
|
579
|
+
.option("--contact <uuid>", "link to contact UUID")
|
|
580
|
+
.option("--idempotency-key <key>", "sent as the Idempotency-Key header")
|
|
581
|
+
.action(run(async (client, id, opts) => {
|
|
582
|
+
const dealId = uuidOf(id, "deal id");
|
|
583
|
+
if (!["high_value_idle", "needs_action"].includes(opts.check))
|
|
584
|
+
throw new Error("--check must be high_value_idle or needs_action (for the other checks use `deals health-fix`).");
|
|
585
|
+
if (opts.title.trim() === "" || opts.title.length > 500)
|
|
586
|
+
throw new Error("--title must be 1 to 500 characters.");
|
|
587
|
+
if (opts.description !== undefined && opts.description.length > 5000)
|
|
588
|
+
throw new Error("--description must be at most 5000 characters.");
|
|
589
|
+
if (opts.idempotencyKey !== undefined && !/^[!-~](?:[ -~]{0,253}[!-~])?$/.test(opts.idempotencyKey))
|
|
590
|
+
throw new Error("--idempotency-key must be 1-255 printable ASCII characters, not blank.");
|
|
591
|
+
const body = { checkOrigin: opts.check, dealId, expectedDealUpdatedAt: isoDate(opts.expectedUpdatedAt, "--expected-updated-at"), title: opts.title };
|
|
592
|
+
if (opts.description !== undefined)
|
|
593
|
+
body.description = opts.description;
|
|
594
|
+
if (opts.due !== undefined)
|
|
595
|
+
body.dueDate = opts.due;
|
|
596
|
+
if (opts.priority !== undefined)
|
|
597
|
+
body.priority = opts.priority;
|
|
598
|
+
if (opts.status !== undefined)
|
|
599
|
+
body.status = opts.status;
|
|
600
|
+
if (opts.assigned !== undefined)
|
|
601
|
+
body.assignedTo = opts.assigned;
|
|
602
|
+
if (opts.contact !== undefined)
|
|
603
|
+
body.contactId = uuidOf(opts.contact, "--contact");
|
|
604
|
+
return client.post("/api/v1/tasks", body, opts.idempotencyKey !== undefined ? { headers: { "Idempotency-Key": opts.idempotencyKey } } : undefined);
|
|
605
|
+
}));
|
|
539
606
|
// (BDA board 10-08) settings.dealCardFields: the fields on each kanban deal card, in order
|
|
540
607
|
const cardFields = deals
|
|
541
608
|
.command("card-fields")
|
|
@@ -1413,7 +1480,7 @@ tasks
|
|
|
1413
1480
|
}));
|
|
1414
1481
|
tasks
|
|
1415
1482
|
.command("create")
|
|
1416
|
-
.description("Create a task (--assigned accepts a user UUID, or the magic strings @me / @owner)")
|
|
1483
|
+
.description("Create a task (--assigned accepts a user UUID, or the magic strings @me / @owner). This is the ORDINARY task route: ordinary tasks are unconstrained and may coexist with or race a Pipeline health quick-fix task; the database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action). Create the quick fix itself with `deals health-next-action`. A 409 without a code means the contact was merged or deleted mid-request: retry against the surviving record")
|
|
1417
1484
|
.option("--title <title>", "task title (required)")
|
|
1418
1485
|
.option("--description <text>", "description")
|
|
1419
1486
|
.option("--status <status>", "todo|in_progress|done (default todo)")
|
|
@@ -1447,7 +1514,7 @@ tasks
|
|
|
1447
1514
|
}));
|
|
1448
1515
|
tasks
|
|
1449
1516
|
.command("update <id>")
|
|
1450
|
-
.description("Update a task (PATCH — only the fields you pass change)")
|
|
1517
|
+
.description("Update a task (PATCH — only the fields you pass change). A 409 means nothing was written, printed verbatim with exit 1 and never retried: code deal_has_next_action (\"This deal already has a next action booked.\": re-opening or re-linking a quick-fix task would give the deal a second open one), or no code (the task changed while saving: read it again and retry)")
|
|
1451
1518
|
.option("--title <title>", "task title")
|
|
1452
1519
|
.option("--description <text>", "description")
|
|
1453
1520
|
.option("--status <status>", "todo|in_progress|done")
|
|
@@ -1776,6 +1843,45 @@ messages
|
|
|
1776
1843
|
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
1777
1844
|
return client.post("/api/v1/messages/sms/send", body);
|
|
1778
1845
|
}));
|
|
1846
|
+
messages
|
|
1847
|
+
.command("check")
|
|
1848
|
+
.description("Grade a DRAFT text or email before sending (POST /messages/check): the same check the contact composers run as the rep types. Prints the route's data (the standard envelope is unwrapped): { score 0-100, grade A-F, verdict, vclass, hits, subjectHits, checks, sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices (when present), engine { version, updatedAt } }. Each hit: { start, end, word, level (r likely blocked, a risky, y caution, n heads up), label, cat, why, alts (safer wording; some are advice rather than a swap), restricted (prohibited in US business texting: no rewording makes it deliverable), rule }. start / end are Unicode CODE-POINT offsets into the submitted text (the body for hits, the subject for subjectHits), end exclusive; an emoji counts as one, so [...text].slice(start, end) in JavaScript or text[start:end] in Python is the word (not a UTF-16 index). The check EDUCATES and never blocks: nothing is stored and nothing is sent; merge tokens such as {{contact.firstName}} are never flagged. --context template (default) grades a reusable message, first the first text to a contact, conversation a reply in an existing thread (the next engine release skips the opt-out and business-name checks for first / conversation; today every context scores the same); `contacts get <id>` carries hasOutboundSms to choose. Limits: body 10,000 characters, subject 300, request 48 KB; 20 checks per minute per account. Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 400 invalid_body, 403 forbidden (the key's member may view neither contacts nor email templates, or the key's scopes do not reach the route: needs the contacts, emails, sms or messages scope, a read-tier key too), 413 message_too_long / body_too_large, 429 rate_limited (Retry-After shown in the error text)")
|
|
1849
|
+
.option("--channel <channel>", "sms | email (required)")
|
|
1850
|
+
.option("--subject <text>", "email only (300 characters at most); refused on a text")
|
|
1851
|
+
.option("--body <text>", "the draft text (10,000 characters at most)")
|
|
1852
|
+
.option("--body-file <path>", "read the draft text from a file (for long text; not with --body)")
|
|
1853
|
+
.option("--context <context>", "template (default) | first | conversation")
|
|
1854
|
+
.option("--json <json>", "full JSON body { channel, subject?, body, context? }, merged over the flags")
|
|
1855
|
+
.action(run(async (client, opts) => {
|
|
1856
|
+
if (opts.body !== undefined && opts.bodyFile !== undefined)
|
|
1857
|
+
throw new Error("Pass either --body or --body-file, not both.");
|
|
1858
|
+
const body = {};
|
|
1859
|
+
if (opts.channel !== undefined)
|
|
1860
|
+
body.channel = opts.channel;
|
|
1861
|
+
if (opts.subject !== undefined)
|
|
1862
|
+
body.subject = opts.subject;
|
|
1863
|
+
if (opts.body !== undefined)
|
|
1864
|
+
body.body = opts.body;
|
|
1865
|
+
if (opts.bodyFile !== undefined) {
|
|
1866
|
+
if (opts.bodyFile.trim() === "")
|
|
1867
|
+
throw new Error("--body-file must be a path (got an empty string).");
|
|
1868
|
+
try {
|
|
1869
|
+
body.body = readFileSync(opts.bodyFile, "utf8");
|
|
1870
|
+
}
|
|
1871
|
+
catch {
|
|
1872
|
+
throw new Error(`--body-file could not be read: ${opts.bodyFile}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
if (opts.context !== undefined)
|
|
1876
|
+
body.context = opts.context;
|
|
1877
|
+
if (opts.json !== undefined)
|
|
1878
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
1879
|
+
if (body.channel === undefined)
|
|
1880
|
+
throw new Error("--channel is required (sms | email).");
|
|
1881
|
+
if (body.body === undefined)
|
|
1882
|
+
throw new Error("The draft is required. Pass --body '<text>' or --body-file <path>.");
|
|
1883
|
+
return client.post("/api/v1/messages/check", body);
|
|
1884
|
+
}));
|
|
1779
1885
|
messages
|
|
1780
1886
|
.command("conversations")
|
|
1781
1887
|
.description("List conversation threads")
|
|
@@ -1975,6 +2081,8 @@ calendars
|
|
|
1975
2081
|
return client.post("/api/v1/calendars", body);
|
|
1976
2082
|
}));
|
|
1977
2083
|
// ---- automations (the "AI builds a workflow" loop) ----
|
|
2084
|
+
// (#124, automation unknown keys / types) the refusal codes the write paths answer; printed by the client as `Conduyt API <status>: ...` with the details object last, never retried
|
|
2085
|
+
const AUTOMATION_KEY_REFUSALS_NOTE = " Unknown action keys are refused, never ignored: an action config key the action does not read (a typo such as `laneSoruce`) answers code unknown_action_config_key with the node, the key and, when one is close, a suggestion (\"Did you mean `laneSource`?\"); an action `type` no registry entry defines answers unknown_action_type (with \"Did you mean\" when a registered type is close). `automations import` answers 400, `automations validate` answers 200 with valid false and the `errors` list (nodeId, field, code, message), `automations publish` answers 422 (the preflight refusal wraps data.message, data.errors, data.warnings). Read the suggestion, fix the key and send again; a stored workflow is never refused on read.";
|
|
1978
2086
|
const automations = program.command("automations").description("Build and run automations/workflows");
|
|
1979
2087
|
automations
|
|
1980
2088
|
.command("list")
|
|
@@ -2016,13 +2124,13 @@ automations
|
|
|
2016
2124
|
}));
|
|
2017
2125
|
automations
|
|
2018
2126
|
.command("validate")
|
|
2019
|
-
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph). A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately).")
|
|
2127
|
+
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph). A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2020
2128
|
.option("--json <json>", "full request body as JSON")
|
|
2021
2129
|
.option("--file <path>", "read the request body from a JSON file")
|
|
2022
2130
|
.action(run(async (client, opts) => client.post("/api/v1/automations/validate", bodyFromJsonOrFile(opts))));
|
|
2023
2131
|
automations
|
|
2024
2132
|
.command("import")
|
|
2025
|
-
.description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph). A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: add (default) | remove}; add needs the sequence to allow automation enrollment. A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately).")
|
|
2133
|
+
.description("Create a workflow (with optional name resolution). Provide the body via --json or --file (needs name + triggerEvent + graph). A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: add (default) | remove}; add needs the sequence to allow automation enrollment. A delay node waits for a fixed duration by default; waitType \"until_field_date\" (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately). Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. \"GSM\", max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an Assign-to-contact step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first." + AUTOMATION_KEY_REFUSALS_NOTE)
|
|
2026
2134
|
.option("--json <json>", "full request body as JSON")
|
|
2027
2135
|
.option("--file <path>", "read the request body from a JSON file")
|
|
2028
2136
|
.option("--resolve-names", "set resolveNames=true so name fields in node configs are converted to ids")
|
|
@@ -2050,10 +2158,25 @@ automations
|
|
|
2050
2158
|
}));
|
|
2051
2159
|
automations
|
|
2052
2160
|
.command("publish <id>")
|
|
2053
|
-
.description("Publish a draft workflow to live")
|
|
2054
|
-
.
|
|
2161
|
+
.description("Publish a draft workflow to live. The API binds a publish to the draft you reviewed and answers 409 without its configRevision, so this reads GET /automations/<id> and POSTs its configRevision as expectedConfigRevision (--expected-config-revision N uses the revision you reviewed and skips the read; a draft changed since then is refused 409). A draft holding an action config key the action does not read, or an unregistered action type, is refused 422 with code unknown_action_config_key / unknown_action_type listed in data.errors (with a \"Did you mean\" suggestion); the error text prints the whole refusal")
|
|
2162
|
+
.option("--expected-config-revision <n>", "the configRevision of the draft you reviewed (from `automations` read / GET /automations/<id>); omit to read the current one")
|
|
2163
|
+
.action(run(async (client, id, opts) => {
|
|
2055
2164
|
assertUuid(id, "automation id");
|
|
2056
|
-
|
|
2165
|
+
let revision;
|
|
2166
|
+
if (opts.expectedConfigRevision !== undefined) {
|
|
2167
|
+
if (!/^\d+$/.test(opts.expectedConfigRevision.trim()))
|
|
2168
|
+
throw new Error("--expected-config-revision must be a non-negative integer (the configRevision of the draft you reviewed).");
|
|
2169
|
+
revision = Number(opts.expectedConfigRevision.trim());
|
|
2170
|
+
}
|
|
2171
|
+
else {
|
|
2172
|
+
// GET /automations/:id answers { data: { ...automation, configRevision } }
|
|
2173
|
+
const read = (await client.get(`/api/v1/automations/${encodeURIComponent(id)}`));
|
|
2174
|
+
const rev = read?.data?.configRevision;
|
|
2175
|
+
if (typeof rev !== "number")
|
|
2176
|
+
throw new Error("The automation read carried no configRevision, so the publish cannot be bound to the draft. Nothing was published.");
|
|
2177
|
+
revision = rev;
|
|
2178
|
+
}
|
|
2179
|
+
return client.post(`/api/v1/automations/${encodeURIComponent(id)}/publish`, { expectedConfigRevision: revision });
|
|
2057
2180
|
}));
|
|
2058
2181
|
automations
|
|
2059
2182
|
.command("step-logs [id]")
|
|
@@ -2717,6 +2840,10 @@ const integrations = program.command("integrations").description("Integrations m
|
|
|
2717
2840
|
const wordpressForms = integrations
|
|
2718
2841
|
.command("wordpress-forms")
|
|
2719
2842
|
.description("WordPress form intake addresses (Gravity Forms, WPForms, Elementor): submissions become contacts (form-intakes + contacts scopes, write tier; revoke: form-intakes)");
|
|
2843
|
+
wordpressForms
|
|
2844
|
+
.command("status")
|
|
2845
|
+
.description("Whether any intake address is ACTIVE and how many: { data: { hasActive, activeCount } } (GET /integrations/wordpress-forms/status). A boolean and a count only, never an address or token; read-only, API key scope integrations (a member: settings.view). A 403 exits 1 with the API's message")
|
|
2846
|
+
.action(run(async (client) => client.get("/api/v1/integrations/wordpress-forms/status")));
|
|
2720
2847
|
wordpressForms
|
|
2721
2848
|
.command("list")
|
|
2722
2849
|
.description("List every active intake address (newest first), then the revoked history (the newest 200 revocations), with the full URL of every active intake and received counts")
|
|
@@ -3651,6 +3778,28 @@ replyCapture
|
|
|
3651
3778
|
}));
|
|
3652
3779
|
// ---- lifecycle (master lead status + intake deals) ----
|
|
3653
3780
|
// ---- general settings writes (09-15) ----
|
|
3781
|
+
/** Split an error message into its prefix and its trailing details object (the grammar's final token): try each `{` from the
|
|
3782
|
+
* left until the rest parses as a JSON object. */
|
|
3783
|
+
function splitErrorDetails(message) {
|
|
3784
|
+
for (let i = message.indexOf("{"); i !== -1; i = message.indexOf("{", i + 1)) {
|
|
3785
|
+
try {
|
|
3786
|
+
const v = JSON.parse(message.slice(i));
|
|
3787
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
3788
|
+
return { prefix: message.slice(0, i).trimEnd(), details: v };
|
|
3789
|
+
}
|
|
3790
|
+
catch { /* next brace */ }
|
|
3791
|
+
}
|
|
3792
|
+
return { prefix: message, details: {} };
|
|
3793
|
+
}
|
|
3794
|
+
/** A settings conflict re-raised with a hint and the CURRENT settings: the original details object and the extra state are merged
|
|
3795
|
+
* into ONE final JSON object `{...details, hint, current}`, so nothing follows it. `current` is kept whole up to 20000 characters;
|
|
3796
|
+
* past that it is left out and `currentOmitted` says so (re-read with `settings get`). */
|
|
3797
|
+
function settingsConflictError(err, hint, current) {
|
|
3798
|
+
const { prefix, details } = splitErrorDetails(err.message);
|
|
3799
|
+
const whole = JSON.stringify(current ?? null);
|
|
3800
|
+
const extra = whole.length <= 20000 ? { hint, current: current ?? null } : { hint, currentOmitted: `${whole.length} characters; re-read the settings` };
|
|
3801
|
+
return new Error(`${prefix} ${JSON.stringify({ ...details, ...extra })}`);
|
|
3802
|
+
}
|
|
3654
3803
|
// PATCH /api/v1/settings refuses a general-settings change without the settingsRevision the editor loaded
|
|
3655
3804
|
// (400 expected_settings_revision_required) and answers 409 when another save landed first. Every general
|
|
3656
3805
|
// write below reads the revision first and sends it; a 409 is re-raised with the current settings so the
|
|
@@ -3670,7 +3819,7 @@ async function patchGeneralSettings(client, body) {
|
|
|
3670
3819
|
catch (err) {
|
|
3671
3820
|
if (err instanceof Error && /^Conduyt API 409/.test(err.message)) {
|
|
3672
3821
|
const current = await read();
|
|
3673
|
-
throw
|
|
3822
|
+
throw settingsConflictError(err, `the account settings changed since revision ${expectedSettingsRevision ?? "?"} was read; re-run the command`, current?.data);
|
|
3674
3823
|
}
|
|
3675
3824
|
throw err;
|
|
3676
3825
|
}
|
|
@@ -3694,7 +3843,7 @@ async function patchGeneralSettingsMerged(client, build) {
|
|
|
3694
3843
|
throw err;
|
|
3695
3844
|
if (attempt === 1) {
|
|
3696
3845
|
const current = await read();
|
|
3697
|
-
throw
|
|
3846
|
+
throw settingsConflictError(err, `the account settings changed twice while this was being saved (last revision read ${revision ?? "?"}); re-run the command`, current?.data);
|
|
3698
3847
|
}
|
|
3699
3848
|
}
|
|
3700
3849
|
}
|
|
@@ -3907,6 +4056,14 @@ lifecycle
|
|
|
3907
4056
|
enabled: lostRaw?.enabled === true,
|
|
3908
4057
|
enabledAt: typeof lostRaw?.enabledAt === "string" ? lostRaw.enabledAt : null,
|
|
3909
4058
|
},
|
|
4059
|
+
// (card ownership per lane, 10-09) false = a booking uses the contact's existing open card in the intake pipeline. A stored value
|
|
4060
|
+
// is shown as is; absent in a FULL snapshot (never saved; the runtime treats it as false) is false; absent in a REDUCED snapshot
|
|
4061
|
+
// (a caller without settings.view) is null + note. The full view always carries `accountId` and the reduced one never does.
|
|
4062
|
+
...(typeof raw.bookingCreatesOwnCard === "boolean"
|
|
4063
|
+
? { bookingCreatesOwnCard: raw.bookingCreatesOwnCard }
|
|
4064
|
+
: typeof raw.accountId === "string"
|
|
4065
|
+
? { bookingCreatesOwnCard: false }
|
|
4066
|
+
: { bookingCreatesOwnCard: null, bookingCreatesOwnCardNote: "not visible to this role (needs settings.view)" }),
|
|
3910
4067
|
masterStatuses: {
|
|
3911
4068
|
defaults: DEFAULTS,
|
|
3912
4069
|
customs,
|
|
@@ -3949,6 +4106,18 @@ lifecycle
|
|
|
3949
4106
|
throw new Error("Pass --enable or --disable.");
|
|
3950
4107
|
return patchGeneralSettings(client, { lostPropagation: { enabled: Boolean(opts.enable) } });
|
|
3951
4108
|
}));
|
|
4109
|
+
lifecycle
|
|
4110
|
+
.command("set-booking-creates-own-card")
|
|
4111
|
+
.description("Set settings.bookingCreatesOwnCard (card ownership per lane; PATCH /settings, needs settings:edit). --enable: a booking creates a NEW card in the intake-deals pipeline and stage, owned by the booking's host, instead of using the contact's existing open card there. --disable (the default): the contact's existing open card is used. `lifecycle settings` shows the current value. A stale settings revision exits 1 with the conflict and the current settings")
|
|
4112
|
+
.option("--enable", "each booking creates its own card owned by the host")
|
|
4113
|
+
.option("--disable", "reuse the contact's open intake card (default)")
|
|
4114
|
+
.action(run(async (client, opts) => {
|
|
4115
|
+
if (opts.enable && opts.disable)
|
|
4116
|
+
throw new Error("Pass either --enable or --disable, not both.");
|
|
4117
|
+
if (opts.enable === undefined && opts.disable === undefined)
|
|
4118
|
+
throw new Error("Pass --enable or --disable.");
|
|
4119
|
+
return patchGeneralSettings(client, { bookingCreatesOwnCard: Boolean(opts.enable) });
|
|
4120
|
+
}));
|
|
3952
4121
|
lifecycle
|
|
3953
4122
|
.command("set-intake-deals")
|
|
3954
4123
|
.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")
|
package/package.json
CHANGED