conduyt 1.59.0 → 1.61.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 +8 -0
- package/dist/board-settings.js +88 -0
- package/dist/index.js +295 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,8 +34,13 @@ conduyt whoami # show the authenticated user
|
|
|
34
34
|
conduyt contacts list --limit 25 # list contacts (supports --cursor, --query)
|
|
35
35
|
conduyt contacts get <id> # fetch one contact
|
|
36
36
|
conduyt deals list --pipeline <id> # list deals
|
|
37
|
+
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
|
|
38
|
+
conduyt deals health --pipeline <id> --check needs_action # GET /deals/inspection: the six checks over open deals; --check adds its deals, --cursor/--offset pages
|
|
39
|
+
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
|
|
40
|
+
conduyt pipelines stage-rules set <pipelineId> <stageId> --required-fields contactId,custom:loan_amount --required-documents 2 # what a deal needs to enter the stage; other stages untouched; `stage-rules get [pipelineId] [stageId]` reads
|
|
37
41
|
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
|
|
38
42
|
conduyt pipelines # list pipelines and stages
|
|
43
|
+
conduyt pipelines update <id> --automation-only true # only automations, API keys and owners / admins move or edit its cards (owner / admin key)
|
|
39
44
|
conduyt search "acme corp" # search across the CRM
|
|
40
45
|
conduyt insights summary # run an AI insight query
|
|
41
46
|
conduyt insights saved_report --report "Lead Activity" --period last_7_days # run any saved report as a tool
|
|
@@ -59,6 +64,9 @@ conduyt reply-capture status # inbound reply capture: state, DNS reco
|
|
|
59
64
|
conduyt reply-capture setup # create the receiving subdomain (--domain optional)
|
|
60
65
|
conduyt reply-capture verify # promote it to live once DNS is confirmed
|
|
61
66
|
conduyt reply-capture remove --confirm REMOVE # remove capture — replies to already-sent email are lost
|
|
67
|
+
conduyt integrations wordpress-forms list # WordPress form intake addresses: full URL of every active one, received counts
|
|
68
|
+
conduyt integrations wordpress-forms create --name "Contact page" --default-tag "Website lead" # prints the full URL
|
|
69
|
+
conduyt integrations wordpress-forms revoke <id> # the address answers 404 from now on; the row is kept
|
|
62
70
|
conduyt api GET /api/v1/companies # raw authenticated request (escape hatch)
|
|
63
71
|
conduyt config show # show resolved config (key masked)
|
|
64
72
|
```
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deal board settings (app 10-08, BDA board): per-stage rules (settings.pipelineStageRules[pipelineId][stageId]) and the
|
|
3
|
+
* kanban card fields (settings.dealCardFields). The validators mirror the API's settingsPatchSchema entries, the source
|
|
4
|
+
* of truth: the built-in names are the app's lists, `custom:<fieldKey>` names one of the account's DEAL custom fields
|
|
5
|
+
* (the API checks it exists at save), and the stage rule object is strict (no other keys).
|
|
6
|
+
*/
|
|
7
|
+
export const DEAL_STAGE_REQUIRED_FIELDS = ["value", "expectedCloseDate", "contactId", "assignedTo", "lostReason"];
|
|
8
|
+
export const DEAL_CARD_FIELDS = ["value", "contact", "phones", "company", "owner", "priority", "expectedCloseDate", "daysInStage", "nextAction", "tags", "stageHealth", "source"];
|
|
9
|
+
export const MAX_DEAL_CARD_FIELDS = 8;
|
|
10
|
+
export const MAX_STAGE_REQUIRED_FIELDS = 25;
|
|
11
|
+
export const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
|
|
12
|
+
export const MAX_LOST_REASON_OPTIONS = 50;
|
|
13
|
+
const CUSTOM_FIELD_RE = /^custom:[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
14
|
+
export const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions"];
|
|
15
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
16
|
+
/** "a,b,custom:x" → ["a","b","custom:x"]; blank items are dropped, an all-blank value is an empty list */
|
|
17
|
+
export function splitList(raw) {
|
|
18
|
+
return raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
19
|
+
}
|
|
20
|
+
export function validateRequiredFields(v, flag = "requiredFields") {
|
|
21
|
+
if (!Array.isArray(v))
|
|
22
|
+
throw new Error(`${flag} must be a list.`);
|
|
23
|
+
if (v.length > MAX_STAGE_REQUIRED_FIELDS)
|
|
24
|
+
throw new Error(`${flag} takes at most ${MAX_STAGE_REQUIRED_FIELDS} entries (got ${v.length}).`);
|
|
25
|
+
for (const f of v) {
|
|
26
|
+
if (typeof f !== "string" || !(DEAL_STAGE_REQUIRED_FIELDS.includes(f) || CUSTOM_FIELD_RE.test(f))) {
|
|
27
|
+
throw new Error(`${flag}: "${String(f)}" is not allowed. Use ${DEAL_STAGE_REQUIRED_FIELDS.join(", ")} or custom:<deal custom field key>.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
export function validateCardFields(v, flag = "fields") {
|
|
33
|
+
if (!Array.isArray(v))
|
|
34
|
+
throw new Error(`${flag} must be a list.`);
|
|
35
|
+
if (v.length < 1)
|
|
36
|
+
throw new Error(`${flag} needs at least one field.`);
|
|
37
|
+
if (v.length > MAX_DEAL_CARD_FIELDS)
|
|
38
|
+
throw new Error(`${flag} takes at most ${MAX_DEAL_CARD_FIELDS} fields (got ${v.length}).`);
|
|
39
|
+
for (const f of v) {
|
|
40
|
+
if (typeof f !== "string" || !(DEAL_CARD_FIELDS.includes(f) || CUSTOM_FIELD_RE.test(f))) {
|
|
41
|
+
throw new Error(`${flag}: "${String(f)}" is not allowed. Use ${DEAL_CARD_FIELDS.join(", ")} or custom:<deal custom field key>.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
/** The fields of one stage rule the caller gave, each validated like the API does. Unknown keys are refused (the rule is strict). */
|
|
47
|
+
export function validateRuleChange(raw) {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
50
|
+
if (!RULE_KEYS.includes(k))
|
|
51
|
+
throw new Error(`Unknown stage rule field "${k}". A stage rule takes ${RULE_KEYS.join(", ")}.`);
|
|
52
|
+
if (v === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
if (k === "requiredFields")
|
|
55
|
+
out[k] = validateRequiredFields(v);
|
|
56
|
+
else if (k === "requiredDocuments") {
|
|
57
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0 || v > MAX_STAGE_REQUIRED_DOCUMENTS)
|
|
58
|
+
throw new Error(`requiredDocuments must be an integer 0-${MAX_STAGE_REQUIRED_DOCUMENTS}.`);
|
|
59
|
+
out[k] = v;
|
|
60
|
+
}
|
|
61
|
+
else if (k === "lostReasonLabel") {
|
|
62
|
+
const t = typeof v === "string" ? v.trim() : "";
|
|
63
|
+
if (t.length < 1 || t.length > 60)
|
|
64
|
+
throw new Error("lostReasonLabel must be 1-60 characters.");
|
|
65
|
+
out[k] = t;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
if (!Array.isArray(v) || v.some((o) => typeof o !== "string"))
|
|
69
|
+
throw new Error("lostReasonOptions must be a list of strings.");
|
|
70
|
+
if (v.length > MAX_LOST_REASON_OPTIONS)
|
|
71
|
+
throw new Error(`lostReasonOptions takes at most ${MAX_LOST_REASON_OPTIONS} entries.`);
|
|
72
|
+
out[k] = v;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The PATCH body that sets ONE stage's rule. The API merges settings.pipelineStageRules per pipeline and replaces that
|
|
79
|
+
* pipeline's stage map, so the body carries the pipeline's whole stage map with only this stage's rule changed (the given
|
|
80
|
+
* fields over the stored ones); every other pipeline is left out and therefore untouched.
|
|
81
|
+
*/
|
|
82
|
+
export function stageRulePatch(settings, pipelineId, stageId, change) {
|
|
83
|
+
const rules = isObj(settings.pipelineStageRules) ? settings.pipelineStageRules : {};
|
|
84
|
+
const stages = isObj(rules[pipelineId]) ? rules[pipelineId] : {};
|
|
85
|
+
const current = isObj(stages[stageId]) ? stages[stageId] : {};
|
|
86
|
+
const kept = Object.fromEntries(RULE_KEYS.filter((k) => current[k] !== undefined).map((k) => [k, current[k]]));
|
|
87
|
+
return { pipelineStageRules: { [pipelineId]: { ...stages, [stageId]: { ...kept, ...change } } } };
|
|
88
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { ConduytClient, buildQuery } from "./client.js";
|
|
8
|
+
import { splitList, stageRulePatch, validateCardFields, validateRuleChange } from "./board-settings.js";
|
|
8
9
|
import { saveConfig, resolveConfig, configPath } from "./config.js";
|
|
9
10
|
import { print, fail, unwrap } from "./output.js";
|
|
10
11
|
// Exit cleanly when a downstream pipe closes early (e.g. `conduyt deals list |
|
|
@@ -299,6 +300,18 @@ contacts
|
|
|
299
300
|
};
|
|
300
301
|
}));
|
|
301
302
|
const deals = program.command("deals").description("Manage deals");
|
|
303
|
+
/** A flag that was given but is blank (an empty shell variable) is a mistake, never "no filter": widening the read to every
|
|
304
|
+
* pipeline or dropping the check silently would answer a different question. --cursor / --health-cursor are the exception:
|
|
305
|
+
* a blank cursor is forwarded so the API answers health_cursor_blank. */
|
|
306
|
+
function rejectBlankFlags(flags) {
|
|
307
|
+
for (const [flag, val] of flags) {
|
|
308
|
+
if (val !== undefined && val.trim() === "") {
|
|
309
|
+
throw new Error(`${flag} was provided but is blank. Omit it, or pass a real value.`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check); the API validates the value. */
|
|
314
|
+
const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
|
|
302
315
|
deals
|
|
303
316
|
.command("list")
|
|
304
317
|
.description("List deals")
|
|
@@ -309,6 +322,12 @@ deals
|
|
|
309
322
|
.option("--pipeline-name <name>", "filter by pipeline name (resolved server-side; errors if not found — never returns the whole account)")
|
|
310
323
|
.option("--stage <id>", "filter by stage UUID")
|
|
311
324
|
.option("--stage-name <name>", "filter by stage name (requires --pipeline or --pipeline-name)")
|
|
325
|
+
.option("--health-summary", "answer the Pipeline health of the filtered set instead of the deal list: totalOpenDeals, atRiskValue, checks[] (six checks, ordered by money at risk), deals. Each deal row also carries hasNextStep and nextAppointmentAt (null unless your key can view the calendar)")
|
|
326
|
+
.option("--health-check <check>", `with --health-summary: add this check's deals, highest value first (${PIPELINE_HEALTH_CHECKS.join(" | ")})`)
|
|
327
|
+
.option("--health-limit <n>", "with --health-summary and --health-check: deals per page, 1-100 (default 5)")
|
|
328
|
+
.option("--health-offset <n>", "with --health-summary and --health-check: deals to skip (default 0); prefer --health-cursor")
|
|
329
|
+
.option("--health-cursor <token>", "with --health-summary and --health-check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED (opaque signed token; a blank, forged or foreign one is a 400 with health_cursor_blank / health_cursor_invalid / health_cursor_context / health_cursor_needs_check)")
|
|
330
|
+
.option("--health-rows-only", "with --health-summary and --health-check: answer only that page of the check's deals ({check, offset, items, hasMore, nextCursor}), without the summary")
|
|
312
331
|
.action(run(async (client, opts) => {
|
|
313
332
|
// A provided-but-blank filter must error, not silently become "no filter":
|
|
314
333
|
// buildQuery drops empty strings, so a blank flag would never reach the
|
|
@@ -326,6 +345,48 @@ deals
|
|
|
326
345
|
throw new Error(`${flag} was provided but is blank. Omit it to skip the filter, or pass a real value.`);
|
|
327
346
|
}
|
|
328
347
|
}
|
|
348
|
+
// Pipeline health (app 10-08): a different answer shape (no data.data / meta.total), so it never goes through the
|
|
349
|
+
// --all / truncation logic below. The API validates the check, the paging and the cursor (400 + code).
|
|
350
|
+
const healthFlags = [
|
|
351
|
+
["--health-check", opts.healthCheck], ["--health-limit", opts.healthLimit], ["--health-offset", opts.healthOffset],
|
|
352
|
+
["--health-cursor", opts.healthCursor], ["--health-rows-only", opts.healthRowsOnly],
|
|
353
|
+
];
|
|
354
|
+
if (opts.healthSummary || healthFlags.some(([, v]) => v !== undefined)) {
|
|
355
|
+
rejectBlankFlags([["--health-check", opts.healthCheck], ["--health-limit", opts.healthLimit], ["--health-offset", opts.healthOffset]]);
|
|
356
|
+
if (!opts.healthSummary) {
|
|
357
|
+
throw new Error(`${healthFlags.find(([, v]) => v !== undefined)[0]} needs --health-summary.`);
|
|
358
|
+
}
|
|
359
|
+
if (opts.all || opts.limit !== undefined || opts.page !== undefined) {
|
|
360
|
+
throw new Error("--health-summary answers the Pipeline health, not the deal list: drop --all / --limit / --page (page a check's deals with --health-limit and --health-cursor).");
|
|
361
|
+
}
|
|
362
|
+
const usp = new URLSearchParams();
|
|
363
|
+
for (const [k, v] of Object.entries({ pipeline: opts.pipeline, pipeline_name: opts.pipelineName, stage: opts.stage, stage_name: opts.stageName })) {
|
|
364
|
+
if (v !== undefined)
|
|
365
|
+
usp.set(k, v);
|
|
366
|
+
}
|
|
367
|
+
usp.set("health_summary", "true");
|
|
368
|
+
if (opts.healthCheck !== undefined)
|
|
369
|
+
usp.set("health_check", opts.healthCheck);
|
|
370
|
+
if (opts.healthLimit !== undefined)
|
|
371
|
+
usp.set("health_limit", opts.healthLimit);
|
|
372
|
+
if (opts.healthOffset !== undefined)
|
|
373
|
+
usp.set("health_offset", opts.healthOffset);
|
|
374
|
+
// sent as given, even when blank, so the API answers health_cursor_blank instead of the flag vanishing
|
|
375
|
+
if (opts.healthCursor !== undefined)
|
|
376
|
+
usp.set("health_cursor", opts.healthCursor);
|
|
377
|
+
if (opts.healthRowsOnly)
|
|
378
|
+
usp.set("health_rows_only", "true");
|
|
379
|
+
const answer = await client.get(`/api/v1/deals?${usp.toString()}`);
|
|
380
|
+
// the live rows-only answer is { data: { deals: page } }: print the page itself, as the help promises
|
|
381
|
+
if (opts.healthRowsOnly) {
|
|
382
|
+
const page = answer?.data?.deals;
|
|
383
|
+
if (page === undefined || page === null || typeof page !== "object") {
|
|
384
|
+
throw new Error("--health-rows-only: the API answered without the page (data.deals). Aborting rather than print another shape.");
|
|
385
|
+
}
|
|
386
|
+
return { data: page };
|
|
387
|
+
}
|
|
388
|
+
return answer;
|
|
389
|
+
}
|
|
329
390
|
const MAX_PER_PAGE = 1000; // matches the server's per_page cap
|
|
330
391
|
const parsePos = (v, name) => {
|
|
331
392
|
if (v === undefined)
|
|
@@ -425,6 +486,64 @@ deals
|
|
|
425
486
|
}
|
|
426
487
|
return res;
|
|
427
488
|
}));
|
|
489
|
+
deals
|
|
490
|
+
.command("health")
|
|
491
|
+
.description(`Pipeline health for one pipeline (or every visible active pipeline) over its open deals (GET /deals/inspection): totalOpenDeals, atRiskValue, checks[] (${PIPELINE_HEALTH_CHECKS.join(", ")}; ordered by money at risk), deals (null without --check), summary, labels. With --check, deals is that check's page {check, offset, items, hasMore, nextCursor, total}, highest value first. For the board's other filters use \`deals list --health-summary\``)
|
|
492
|
+
.option("--pipeline <id>", "pipeline UUID; omit for every visible active pipeline")
|
|
493
|
+
.option("--check <check>", `adds that check's deals (${PIPELINE_HEALTH_CHECKS.join(" | ")})`)
|
|
494
|
+
.option("--limit <n>", "with --check: deals per page, 1-100 (default 5)")
|
|
495
|
+
.option("--offset <n>", "with --check: deals to skip (default 0); not combined with --cursor")
|
|
496
|
+
.option("--cursor <token>", "with --check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED (opaque signed token; 400 health_cursor_invalid / health_cursor_context / health_cursor_blank / health_cursor_needs_check)")
|
|
497
|
+
.action(run(async (client, opts) => {
|
|
498
|
+
rejectBlankFlags([["--pipeline", opts.pipeline], ["--check", opts.check], ["--limit", opts.limit], ["--offset", opts.offset]]);
|
|
499
|
+
const usp = new URLSearchParams();
|
|
500
|
+
if (opts.pipeline !== undefined)
|
|
501
|
+
usp.set("pipeline", opts.pipeline);
|
|
502
|
+
if (opts.check !== undefined)
|
|
503
|
+
usp.set("check", opts.check);
|
|
504
|
+
if (opts.limit !== undefined)
|
|
505
|
+
usp.set("limit", opts.limit);
|
|
506
|
+
if (opts.offset !== undefined)
|
|
507
|
+
usp.set("offset", opts.offset);
|
|
508
|
+
// sent as given, even when blank, so the API answers health_cursor_blank
|
|
509
|
+
if (opts.cursor !== undefined)
|
|
510
|
+
usp.set("cursor", opts.cursor);
|
|
511
|
+
const qs = usp.toString();
|
|
512
|
+
return client.get(`/api/v1/deals/inspection${qs ? `?${qs}` : ""}`);
|
|
513
|
+
}));
|
|
514
|
+
// (BDA board 10-08) settings.dealCardFields: the fields on each kanban deal card, in order
|
|
515
|
+
const cardFields = deals
|
|
516
|
+
.command("card-fields")
|
|
517
|
+
.description("The fields shown on each kanban deal card, in order (settings.dealCardFields, max 8)");
|
|
518
|
+
cardFields
|
|
519
|
+
.command("get")
|
|
520
|
+
.description("Read the card fields: built-ins (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) and custom:<fieldKey> entries")
|
|
521
|
+
.action(run(async (client) => {
|
|
522
|
+
const res = (await client.get("/api/v1/settings"));
|
|
523
|
+
return { data: { dealCardFields: res?.data?.dealCardFields ?? null } };
|
|
524
|
+
}));
|
|
525
|
+
cardFields
|
|
526
|
+
.command("set [fields]")
|
|
527
|
+
.description("REPLACE the card fields (admin key, settings:edit): a comma-separated list of 1 to 8, each a built-in (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) or custom:<deal custom field key>, e.g. `value,contact,custom:next_contact_at`. Or pass the list as a JSON array with --json")
|
|
528
|
+
.option("--json <json>", "the complete list as a JSON array, e.g. '[\"value\",\"custom:next_contact_at\"]' (instead of the positional list)")
|
|
529
|
+
.action(run(async (client, fields, opts) => {
|
|
530
|
+
if ((fields === undefined) === (opts.json === undefined))
|
|
531
|
+
throw new Error("Pass the fields as a comma-separated list or as --json '[...]', not both and not neither.");
|
|
532
|
+
let list;
|
|
533
|
+
if (opts.json !== undefined) {
|
|
534
|
+
try {
|
|
535
|
+
list = JSON.parse(opts.json);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
throw new Error("--json must be valid JSON.");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
list = splitList(fields);
|
|
543
|
+
}
|
|
544
|
+
const dealCardFields = validateCardFields(list);
|
|
545
|
+
return patchGeneralSettingsMerged(client, () => ({ dealCardFields }));
|
|
546
|
+
}));
|
|
428
547
|
deals
|
|
429
548
|
.command("create")
|
|
430
549
|
.description("Create a deal (use `pipelines list` to get pipeline/stage ids)")
|
|
@@ -675,6 +794,110 @@ pipelines
|
|
|
675
794
|
assertStagesShape(body.stages);
|
|
676
795
|
return client.post("/api/v1/pipelines", body);
|
|
677
796
|
}));
|
|
797
|
+
// (BDA board 10-08) PATCH /pipelines/:id: name, description, isDefault, sortOrder and automationOnly (owner / admin key)
|
|
798
|
+
pipelines
|
|
799
|
+
.command("update <id>")
|
|
800
|
+
.description("Update a pipeline (PATCH: only the fields you pass change). --automation-only true makes it an automation-only pipeline: its cards are moved and edited only by automations, API keys and owners / admins, a member's edit is refused with 403 pipeline_automation_only (owner / admin key only; `pipelines list` shows the flag)")
|
|
801
|
+
.option("--name <name>", "pipeline name")
|
|
802
|
+
.option("--description <text>", "pipeline description")
|
|
803
|
+
.option("--is-default <bool>", "true|false: the account's default pipeline")
|
|
804
|
+
.option("--automation-only <bool>", "true|false: only automations, API keys and owners / admins move or edit its cards")
|
|
805
|
+
.option("--sort-order <n>", "position among the pipelines (integer)")
|
|
806
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
807
|
+
.action(run(async (client, id, opts) => {
|
|
808
|
+
assertUuid(id, "pipeline id");
|
|
809
|
+
const flag = (raw, name) => {
|
|
810
|
+
if (raw === undefined)
|
|
811
|
+
return undefined;
|
|
812
|
+
const v = raw.trim().toLowerCase();
|
|
813
|
+
if (v === "true")
|
|
814
|
+
return true;
|
|
815
|
+
if (v === "false")
|
|
816
|
+
return false;
|
|
817
|
+
throw new Error(`${name} must be true or false.`);
|
|
818
|
+
};
|
|
819
|
+
// --json is parsed FIRST and wins: a flag is read (and validated) only when the JSON does not carry its key, so
|
|
820
|
+
// --json can override a flag whose value would not have parsed
|
|
821
|
+
const fromJson = opts.json !== undefined ? jsonArg(opts.json, "--json") : {};
|
|
822
|
+
const body = {};
|
|
823
|
+
const unlessJson = (key) => !Object.prototype.hasOwnProperty.call(fromJson, key);
|
|
824
|
+
if (opts.name !== undefined && unlessJson("name"))
|
|
825
|
+
body.name = opts.name;
|
|
826
|
+
if (opts.description !== undefined && unlessJson("description"))
|
|
827
|
+
body.description = opts.description;
|
|
828
|
+
if (unlessJson("isDefault")) {
|
|
829
|
+
const isDefault = flag(opts.isDefault, "--is-default");
|
|
830
|
+
if (isDefault !== undefined)
|
|
831
|
+
body.isDefault = isDefault;
|
|
832
|
+
}
|
|
833
|
+
if (unlessJson("automationOnly")) {
|
|
834
|
+
const automationOnly = flag(opts.automationOnly, "--automation-only");
|
|
835
|
+
if (automationOnly !== undefined)
|
|
836
|
+
body.automationOnly = automationOnly;
|
|
837
|
+
}
|
|
838
|
+
if (opts.sortOrder !== undefined && unlessJson("sortOrder")) {
|
|
839
|
+
const n = Number(opts.sortOrder.trim());
|
|
840
|
+
if (opts.sortOrder.trim() === "" || !Number.isInteger(n))
|
|
841
|
+
throw new Error("--sort-order must be an integer.");
|
|
842
|
+
body.sortOrder = n;
|
|
843
|
+
}
|
|
844
|
+
Object.assign(body, fromJson);
|
|
845
|
+
if (Object.keys(body).length === 0)
|
|
846
|
+
throw new Error("Nothing to update. Pass at least one field flag or --json.");
|
|
847
|
+
return client.patch(`/api/v1/pipelines/${encodeURIComponent(id)}`, body);
|
|
848
|
+
}));
|
|
849
|
+
// (BDA board 10-08) settings.pipelineStageRules: what a deal needs before it may enter a stage. Read-merge-write with the
|
|
850
|
+
// settings revision (a 409 is re-read and retried once); other stages and pipelines are untouched.
|
|
851
|
+
const stageRules = pipelines
|
|
852
|
+
.command("stage-rules")
|
|
853
|
+
.description("Per-stage rules (settings.pipelineStageRules): the fields and documents a deal needs before it may enter a stage");
|
|
854
|
+
stageRules
|
|
855
|
+
.command("get [pipelineId] [stageId]")
|
|
856
|
+
.description("Read the stage rules as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions } } }, for everything, one pipeline, or one stage")
|
|
857
|
+
.action(run(async (client, pipelineId, stageId) => {
|
|
858
|
+
if (pipelineId !== undefined)
|
|
859
|
+
assertUuid(pipelineId, "pipeline id");
|
|
860
|
+
if (stageId !== undefined) {
|
|
861
|
+
if (pipelineId === undefined)
|
|
862
|
+
throw new Error("stage id needs a pipeline id.");
|
|
863
|
+
assertUuid(stageId, "stage id");
|
|
864
|
+
}
|
|
865
|
+
const res = (await client.get("/api/v1/settings"));
|
|
866
|
+
const all = res?.data?.pipelineStageRules && typeof res.data.pipelineStageRules === "object" ? res.data.pipelineStageRules : {};
|
|
867
|
+
if (pipelineId === undefined)
|
|
868
|
+
return { data: all };
|
|
869
|
+
const stages = all[pipelineId] ?? {};
|
|
870
|
+
return { data: { [pipelineId]: stageId === undefined ? stages : stages[stageId] !== undefined ? { [stageId]: stages[stageId] } : {} } };
|
|
871
|
+
}));
|
|
872
|
+
stageRules
|
|
873
|
+
.command("set <pipelineId> <stageId>")
|
|
874
|
+
.description("Set ONE stage's rule (admin key, settings:edit). Only the fields you pass change; they are merged into the stage's current rule. --required-fields REPLACES the list (max 25: value, expectedCloseDate, contactId, assignedTo, lostReason, or custom:<deal custom field key>; an empty value clears it). --required-documents is how many documents must be attached before a deal may enter the stage (integer 0-20)")
|
|
875
|
+
.option("--required-fields <list>", "comma-separated, e.g. contactId,custom:next_contact_at (empty clears)")
|
|
876
|
+
.option("--required-documents <n>", "integer 0-20")
|
|
877
|
+
.option("--lost-reason-label <text>", "label of the stage's lost-reason prompt (1-60 chars)")
|
|
878
|
+
.option("--lost-reason-options <list>", "comma-separated dropdown options of the lost-reason prompt (max 50; empty clears)")
|
|
879
|
+
.option("--json <json>", "the rule fields as a JSON object ({requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions}), merged over the flags")
|
|
880
|
+
.action(run(async (client, pipelineId, stageId, opts) => {
|
|
881
|
+
assertUuid(pipelineId, "pipeline id");
|
|
882
|
+
assertUuid(stageId, "stage id");
|
|
883
|
+
const raw = {};
|
|
884
|
+
if (opts.requiredFields !== undefined)
|
|
885
|
+
raw.requiredFields = splitList(opts.requiredFields);
|
|
886
|
+
if (opts.requiredDocuments !== undefined) {
|
|
887
|
+
const t = opts.requiredDocuments.trim();
|
|
888
|
+
raw.requiredDocuments = t === "" ? NaN : Number(t);
|
|
889
|
+
}
|
|
890
|
+
if (opts.lostReasonLabel !== undefined)
|
|
891
|
+
raw.lostReasonLabel = opts.lostReasonLabel;
|
|
892
|
+
if (opts.lostReasonOptions !== undefined)
|
|
893
|
+
raw.lostReasonOptions = splitList(opts.lostReasonOptions);
|
|
894
|
+
if (opts.json !== undefined)
|
|
895
|
+
Object.assign(raw, jsonArg(opts.json, "--json"));
|
|
896
|
+
const change = validateRuleChange(raw);
|
|
897
|
+
if (Object.keys(change).length === 0)
|
|
898
|
+
throw new Error("Nothing to set. Pass --required-fields, --required-documents, --lost-reason-label, --lost-reason-options or --json.");
|
|
899
|
+
return patchGeneralSettingsMerged(client, (settings) => stageRulePatch(settings, pipelineId, stageId, change));
|
|
900
|
+
}));
|
|
678
901
|
// (system stages 2026-09-24) tri-surface parity: the stage flags the API takes on PATCH /pipelines/:id/stages/:stageId,
|
|
679
902
|
// isSystem included (a system holding stage automations route deals through; hidden from rep pickers, never won or lost)
|
|
680
903
|
pipelines
|
|
@@ -1519,13 +1742,13 @@ automations
|
|
|
1519
1742
|
}));
|
|
1520
1743
|
automations
|
|
1521
1744
|
.command("validate")
|
|
1522
|
-
.description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph)")
|
|
1745
|
+
.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).")
|
|
1523
1746
|
.option("--json <json>", "full request body as JSON")
|
|
1524
1747
|
.option("--file <path>", "read the request body from a JSON file")
|
|
1525
1748
|
.action(run(async (client, opts) => client.post("/api/v1/automations/validate", bodyFromJsonOrFile(opts))));
|
|
1526
1749
|
automations
|
|
1527
1750
|
.command("import")
|
|
1528
|
-
.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")
|
|
1751
|
+
.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).")
|
|
1529
1752
|
.option("--json <json>", "full request body as JSON")
|
|
1530
1753
|
.option("--file <path>", "read the request body from a JSON file")
|
|
1531
1754
|
.option("--resolve-names", "set resolveNames=true so name fields in node configs are converted to ids")
|
|
@@ -2211,6 +2434,46 @@ webhooks
|
|
|
2211
2434
|
.option("--acknowledge-duplicate-risk", "you accept the step may be delivered twice")
|
|
2212
2435
|
.action(run(async (client, id, opts) => client.post(`/api/v1/webhook-logs/${uuidArg(id, "id")}/cancel`, barrierBody(opts))));
|
|
2213
2436
|
// ---------------------------------------------------------------------------
|
|
2437
|
+
// Integrations → WordPress forms (app 10-07): per-form intake addresses that Gravity Forms (Webhooks Add-On), WPForms
|
|
2438
|
+
// (Webhooks addon) and Elementor Pro (Webhook action) post submissions to; each submission becomes a contact. The full
|
|
2439
|
+
// address is the credential: `create` prints it and `list` shows it for every active intake. API key: list and create
|
|
2440
|
+
// need the form-intakes AND contacts scopes on a write or admin tier key; revoke needs form-intakes.
|
|
2441
|
+
// ---------------------------------------------------------------------------
|
|
2442
|
+
const integrations = program.command("integrations").description("Integrations managed from the CLI");
|
|
2443
|
+
const wordpressForms = integrations
|
|
2444
|
+
.command("wordpress-forms")
|
|
2445
|
+
.description("WordPress form intake addresses (Gravity Forms, WPForms, Elementor): submissions become contacts (form-intakes + contacts scopes, write tier; revoke: form-intakes)");
|
|
2446
|
+
wordpressForms
|
|
2447
|
+
.command("list")
|
|
2448
|
+
.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")
|
|
2449
|
+
.action(run(async (client) => client.get("/api/v1/integrations/wordpress-forms/intakes")));
|
|
2450
|
+
wordpressForms
|
|
2451
|
+
.command("create")
|
|
2452
|
+
.description("Create an intake address for one form. Prints the full URL (the credential; list shows it too while active): paste it into the form plugin's webhook URL. Needs the form-intakes and contacts scopes")
|
|
2453
|
+
.option("--name <text>", "what the form is, e.g. \"Contact page, Smith Roofing\" (required, max 120)")
|
|
2454
|
+
.option("--default-tag <name>", "tag name applied to every submission unless the submission sends its own tags")
|
|
2455
|
+
.option("--source <text>", "contact source for submissions that do not send one (default wordpress)")
|
|
2456
|
+
.option("--assign-to <userId>", "user id (an active member) who owns new contacts; omit = lead routing decides")
|
|
2457
|
+
.action(run(async (client, opts) => {
|
|
2458
|
+
const name = (opts.name ?? "").trim();
|
|
2459
|
+
if (!name)
|
|
2460
|
+
throw new Error("--name is required");
|
|
2461
|
+
if (name.length > 120)
|
|
2462
|
+
throw new Error("--name must be at most 120 characters");
|
|
2463
|
+
const body = { name };
|
|
2464
|
+
if (opts.defaultTag !== undefined)
|
|
2465
|
+
body.defaultTag = opts.defaultTag;
|
|
2466
|
+
if (opts.source !== undefined)
|
|
2467
|
+
body.source = opts.source;
|
|
2468
|
+
if (opts.assignTo !== undefined)
|
|
2469
|
+
body.assignedTo = uuidArg(opts.assignTo, "--assign-to");
|
|
2470
|
+
return client.post("/api/v1/integrations/wordpress-forms/intakes", body);
|
|
2471
|
+
}));
|
|
2472
|
+
wordpressForms
|
|
2473
|
+
.command("revoke <id>")
|
|
2474
|
+
.description("Revoke an intake address: submissions to it answer 404 from now on. The row is kept (status revoked) for history")
|
|
2475
|
+
.action(run(async (client, id) => client.del(`/api/v1/integrations/wordpress-forms/intakes/${uuidArg(id, "id")}`)));
|
|
2476
|
+
// ---------------------------------------------------------------------------
|
|
2214
2477
|
// Sequences (one model for Email and SMS since 2026-09-30): steps send saved templates of the sequence's channel a
|
|
2215
2478
|
// number of days apart inside a send window; a run stops on a reply, a booked meeting, an opt-out, a closed deal or a
|
|
2216
2479
|
// removal. Provider drip timetables are the `drip-enrollments` group, a different thing.
|
|
@@ -2995,12 +3258,17 @@ replyCapture
|
|
|
2995
3258
|
// (400 expected_settings_revision_required) and answers 409 when another save landed first. Every general
|
|
2996
3259
|
// write below reads the revision first and sends it; a 409 is re-raised with the current settings so the
|
|
2997
3260
|
// caller re-derives the change instead of overwriting a newer save.
|
|
3261
|
+
// the ONE place a general-settings PATCH is sent: it always carries the revision it was derived from (lifecycle-guards.test.ts
|
|
3262
|
+
// pins this single call site and that both helpers hand it their revision)
|
|
3263
|
+
function patchSettingsWithRevision(client, body, expectedSettingsRevision) {
|
|
3264
|
+
return client.patch("/api/v1/settings", { ...body, ...(expectedSettingsRevision ? { expectedSettingsRevision } : {}) });
|
|
3265
|
+
}
|
|
2998
3266
|
async function patchGeneralSettings(client, body) {
|
|
2999
3267
|
const read = async () => (await client.get("/api/v1/settings"));
|
|
3000
3268
|
const before = await read();
|
|
3001
3269
|
const expectedSettingsRevision = typeof before?.data?.settingsRevision === "string" ? before.data.settingsRevision : null;
|
|
3002
3270
|
try {
|
|
3003
|
-
return await client
|
|
3271
|
+
return await patchSettingsWithRevision(client, body, expectedSettingsRevision);
|
|
3004
3272
|
}
|
|
3005
3273
|
catch (err) {
|
|
3006
3274
|
if (err instanceof Error && /^Conduyt API 409/.test(err.message)) {
|
|
@@ -3010,6 +3278,30 @@ async function patchGeneralSettings(client, body) {
|
|
|
3010
3278
|
throw err;
|
|
3011
3279
|
}
|
|
3012
3280
|
}
|
|
3281
|
+
// read-merge-write for a change that depends on what is stored (a per-stage rule): `build` derives the PATCH body from the
|
|
3282
|
+
// settings just read and the save carries that read's settingsRevision. A 409 (another save landed first) re-reads and
|
|
3283
|
+
// re-derives ONCE; a second 409 is re-raised with the current settings, like patchGeneralSettings.
|
|
3284
|
+
async function patchGeneralSettingsMerged(client, build) {
|
|
3285
|
+
const read = async () => (await client.get("/api/v1/settings"));
|
|
3286
|
+
let revision = null;
|
|
3287
|
+
for (let attempt = 0;; attempt += 1) {
|
|
3288
|
+
const before = await read();
|
|
3289
|
+
const settings = before?.data ?? {};
|
|
3290
|
+
revision = typeof settings.settingsRevision === "string" ? settings.settingsRevision : null;
|
|
3291
|
+
const body = build(settings);
|
|
3292
|
+
try {
|
|
3293
|
+
return await patchSettingsWithRevision(client, body, revision);
|
|
3294
|
+
}
|
|
3295
|
+
catch (err) {
|
|
3296
|
+
if (!(err instanceof Error && /^Conduyt API 409/.test(err.message)))
|
|
3297
|
+
throw err;
|
|
3298
|
+
if (attempt === 1) {
|
|
3299
|
+
const current = await read();
|
|
3300
|
+
throw new Error(`${err.message} — the account settings changed twice while this was being saved (last revision read ${revision ?? "?"}); re-run the command (current: ${JSON.stringify(current?.data ?? null).slice(0, 1500)})`);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3013
3305
|
function poolFilterOf(settings) {
|
|
3014
3306
|
const raw = settings.poolFilter;
|
|
3015
3307
|
const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
package/package.json
CHANGED