conduyt 1.60.0 → 1.62.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 CHANGED
@@ -34,8 +34,23 @@ 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 [--lost-reason-allow-other true] # 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
- conduyt pipelines # list pipelines and stages
42
+ conduyt pipelines # list pipelines and stages (each carries color, automationOnly, sortOrder)
43
+ conduyt pipelines list --archived # the archived pipelines (owner / admin key): name, archivedName, openDeals, stageCount
44
+ conduyt pipelines archive <id> --move-open-deals-to <pipelineId|none> [--expected-settings-revision N] # archive + move the open deals in one transaction; refusals print their code (stale_settings_revision, target_not_open, dpp_post_close, archive_too_large, target_archived, intake_target, pipeline_archived) and exit 1
45
+ conduyt pipelines settings-revision # the account settingsRevision to pass as --expected-settings-revision to archive / delete-stage (409 stale_settings_revision when settings changed since)
46
+ conduyt pipelines restore <id> # bring an archived pipeline back (last in the menu, never the default; 409 name_taken)
47
+ conduyt pipelines reorder --order id1,id2 --expected-revision id1,id2 # board-menu order in one transaction (409 order_changed prints the current revision); `reorder-stages <pipelineId>` does the same for a pipeline's columns
48
+ conduyt pipelines delete-stage <pipelineId> <stageId> [--move-to <stageId>] [--expected-settings-revision N] # move the live deals to another stage and delete this one; a stale revision is 409 stale_settings_revision
49
+ conduyt pipelines rot-days set <pipelineId> 21 # days without activity (1-90) before a deal reads "Stale in stage"; `rot-days get [pipelineId]` reads
50
+ conduyt status-metadata list --stage-id <id> # the status codes + buckets attached to stages; `get | create | update | delete | import` (update / delete take --expected-updated-at; 409 metadata_changed)
51
+ conduyt status-metadata import --file rows.json --mode replace # set-based import (max 1000; an item key outside stageId, code, bucket, lifecycleStage, dripEligible, dripTrackName, isTerminal, notes is refused before any request); per-item errors print and exit 1; `--mode replace` with no items is refused unless `--confirm-empty-replace` (it deletes every row). Timeouts: archive 75 s, import and the Mailchimp write / action commands 60 s; on a timeout the server may have applied it, so read the state first
52
+ conduyt integrations mailchimp settings # the Mailchimp audience sync (API key scope integrations); `set | audiences | push | retry-unsubscribes | reconcile-statuses [--restart] | confirm-reconciliation`. Connecting is browser-only (OAuth): do it in the app
53
+ conduyt pipelines update <id> --automation-only true # only automations, API keys and owners / admins move or edit its cards (owner / admin key); also --color <palette hex> and --sort-order
39
54
  conduyt search "acme corp" # search across the CRM
40
55
  conduyt insights summary # run an AI insight query
41
56
  conduyt insights saved_report --report "Lead Activity" --period last_7_days # run any saved report as a tool
@@ -0,0 +1,93 @@
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", "lostReasonAllowOther"];
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 if (k === "lostReasonAllowOther") {
68
+ if (typeof v !== "boolean")
69
+ throw new Error("lostReasonAllowOther must be true or false.");
70
+ out[k] = v;
71
+ }
72
+ else {
73
+ if (!Array.isArray(v) || v.some((o) => typeof o !== "string"))
74
+ throw new Error("lostReasonOptions must be a list of strings.");
75
+ if (v.length > MAX_LOST_REASON_OPTIONS)
76
+ throw new Error(`lostReasonOptions takes at most ${MAX_LOST_REASON_OPTIONS} entries.`);
77
+ out[k] = v;
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * The PATCH body that sets ONE stage's rule. The API merges settings.pipelineStageRules per pipeline and replaces that
84
+ * pipeline's stage map, so the body carries the pipeline's whole stage map with only this stage's rule changed (the given
85
+ * fields over the stored ones); every other pipeline is left out and therefore untouched.
86
+ */
87
+ export function stageRulePatch(settings, pipelineId, stageId, change) {
88
+ const rules = isObj(settings.pipelineStageRules) ? settings.pipelineStageRules : {};
89
+ const stages = isObj(rules[pipelineId]) ? rules[pipelineId] : {};
90
+ const current = isObj(stages[stageId]) ? stages[stageId] : {};
91
+ const kept = Object.fromEntries(RULE_KEYS.filter((k) => current[k] !== undefined).map((k) => [k, current[k]]));
92
+ return { pipelineStageRules: { [pipelineId]: { ...stages, [stageId]: { ...kept, ...change } } } };
93
+ }
package/dist/client.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import { requireAuth } from "./config.js";
2
+ /** A deadline hit (only when the call set one) is "the server may have applied it", for every method: read the state first. */
3
+ function ambiguousTimeout(err, method, path, timeoutMs, what) {
4
+ const name = err?.name;
5
+ if (timeoutMs !== undefined && (name === "TimeoutError" || name === "AbortError")) {
6
+ return new Error(`Conduyt API timeout: ${what} within ${timeoutMs / 1000}s for ${method} ${path}. The server may have applied it: read the current state before repeating it.`);
7
+ }
8
+ return err;
9
+ }
2
10
  export class ConduytClient {
3
11
  baseUrl;
4
12
  apiKey;
@@ -7,17 +15,34 @@ export class ConduytClient {
7
15
  this.baseUrl = cfg.apiUrl.replace(/\/+$/, "");
8
16
  this.apiKey = cfg.apiKey;
9
17
  }
10
- async request(method, path, body) {
18
+ /** timeoutMs: a per-call deadline for the routes whose server budget is long (archive 75 s, Mailchimp admin and
19
+ * status-metadata import 60 s). Without it the request has no client-side deadline. A call that hits it may still have
20
+ * been applied by the server, and the error says so. */
21
+ async request(method, path, body, opts) {
11
22
  const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
12
- const res = await fetch(url, {
13
- method,
14
- headers: {
15
- Authorization: `Bearer ${this.apiKey}`,
16
- "Content-Type": "application/json",
17
- },
18
- body: body !== undefined ? JSON.stringify(body) : undefined,
19
- });
20
- const text = await res.text();
23
+ let res;
24
+ try {
25
+ res = await fetch(url, {
26
+ method,
27
+ headers: {
28
+ Authorization: `Bearer ${this.apiKey}`,
29
+ "Content-Type": "application/json",
30
+ },
31
+ body: body !== undefined ? JSON.stringify(body) : undefined,
32
+ ...(opts?.timeoutMs !== undefined ? { signal: AbortSignal.timeout(opts.timeoutMs) } : {}),
33
+ });
34
+ }
35
+ catch (err) {
36
+ throw ambiguousTimeout(err, method, path, opts?.timeoutMs, "no answer");
37
+ }
38
+ // the headers can arrive and the body then stall until the deadline: the same ambiguity, not a raw TimeoutError
39
+ let text;
40
+ try {
41
+ text = await res.text();
42
+ }
43
+ catch (err) {
44
+ throw ambiguousTimeout(err, method, path, opts?.timeoutMs, "the response body did not finish");
45
+ }
21
46
  const json = text ? safeParse(text) : null;
22
47
  if (!res.ok) {
23
48
  // Preserve structured non-2xx bodies: several endpoints return the data
@@ -44,14 +69,14 @@ export class ConduytClient {
44
69
  get(path) {
45
70
  return this.request("GET", path);
46
71
  }
47
- post(path, body) {
48
- return this.request("POST", path, body);
72
+ post(path, body, opts) {
73
+ return this.request("POST", path, body, opts);
49
74
  }
50
75
  patch(path, body) {
51
76
  return this.request("PATCH", path, body);
52
77
  }
53
- put(path, body) {
54
- return this.request("PUT", path, body);
78
+ put(path, body, opts) {
79
+ return this.request("PUT", path, body, opts);
55
80
  }
56
81
  del(path) {
57
82
  return this.request("DELETE", path);
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ 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";
9
+ import { ARCHIVE_PIPELINE_TIMEOUT_MS, MAILCHIMP_ADMIN_TIMEOUT_MS, STATUS_METADATA_IMPORT_TIMEOUT_MS, PIPELINE_COLORS, boolFlag, intFlag, isoDate, lifecycleStage as lifecycleStageArg, MAX_PIPELINE_ROT_DAYS, MIN_PIPELINE_ROT_DAYS, pipelineColor, settingsRevision, slug as slugArg, uuid as uuidOf, uuidList, validateImportItems, validateStatusMetadataBody } from "./pipeline-settings.js";
8
10
  import { saveConfig, resolveConfig, configPath } from "./config.js";
9
11
  import { print, fail, unwrap } from "./output.js";
10
12
  // Exit cleanly when a downstream pipe closes early (e.g. `conduyt deals list |
@@ -299,6 +301,18 @@ contacts
299
301
  };
300
302
  }));
301
303
  const deals = program.command("deals").description("Manage deals");
304
+ /** A flag that was given but is blank (an empty shell variable) is a mistake, never "no filter": widening the read to every
305
+ * pipeline or dropping the check silently would answer a different question. --cursor / --health-cursor are the exception:
306
+ * a blank cursor is forwarded so the API answers health_cursor_blank. */
307
+ function rejectBlankFlags(flags) {
308
+ for (const [flag, val] of flags) {
309
+ if (val !== undefined && val.trim() === "") {
310
+ throw new Error(`${flag} was provided but is blank. Omit it, or pass a real value.`);
311
+ }
312
+ }
313
+ }
314
+ /** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check); the API validates the value. */
315
+ const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
302
316
  deals
303
317
  .command("list")
304
318
  .description("List deals")
@@ -309,6 +323,12 @@ deals
309
323
  .option("--pipeline-name <name>", "filter by pipeline name (resolved server-side; errors if not found — never returns the whole account)")
310
324
  .option("--stage <id>", "filter by stage UUID")
311
325
  .option("--stage-name <name>", "filter by stage name (requires --pipeline or --pipeline-name)")
326
+ .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)")
327
+ .option("--health-check <check>", `with --health-summary: add this check's deals, highest value first (${PIPELINE_HEALTH_CHECKS.join(" | ")})`)
328
+ .option("--health-limit <n>", "with --health-summary and --health-check: deals per page, 1-100 (default 5)")
329
+ .option("--health-offset <n>", "with --health-summary and --health-check: deals to skip (default 0); prefer --health-cursor")
330
+ .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)")
331
+ .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
332
  .action(run(async (client, opts) => {
313
333
  // A provided-but-blank filter must error, not silently become "no filter":
314
334
  // buildQuery drops empty strings, so a blank flag would never reach the
@@ -326,6 +346,48 @@ deals
326
346
  throw new Error(`${flag} was provided but is blank. Omit it to skip the filter, or pass a real value.`);
327
347
  }
328
348
  }
349
+ // Pipeline health (app 10-08): a different answer shape (no data.data / meta.total), so it never goes through the
350
+ // --all / truncation logic below. The API validates the check, the paging and the cursor (400 + code).
351
+ const healthFlags = [
352
+ ["--health-check", opts.healthCheck], ["--health-limit", opts.healthLimit], ["--health-offset", opts.healthOffset],
353
+ ["--health-cursor", opts.healthCursor], ["--health-rows-only", opts.healthRowsOnly],
354
+ ];
355
+ if (opts.healthSummary || healthFlags.some(([, v]) => v !== undefined)) {
356
+ rejectBlankFlags([["--health-check", opts.healthCheck], ["--health-limit", opts.healthLimit], ["--health-offset", opts.healthOffset]]);
357
+ if (!opts.healthSummary) {
358
+ throw new Error(`${healthFlags.find(([, v]) => v !== undefined)[0]} needs --health-summary.`);
359
+ }
360
+ if (opts.all || opts.limit !== undefined || opts.page !== undefined) {
361
+ 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).");
362
+ }
363
+ const usp = new URLSearchParams();
364
+ for (const [k, v] of Object.entries({ pipeline: opts.pipeline, pipeline_name: opts.pipelineName, stage: opts.stage, stage_name: opts.stageName })) {
365
+ if (v !== undefined)
366
+ usp.set(k, v);
367
+ }
368
+ usp.set("health_summary", "true");
369
+ if (opts.healthCheck !== undefined)
370
+ usp.set("health_check", opts.healthCheck);
371
+ if (opts.healthLimit !== undefined)
372
+ usp.set("health_limit", opts.healthLimit);
373
+ if (opts.healthOffset !== undefined)
374
+ usp.set("health_offset", opts.healthOffset);
375
+ // sent as given, even when blank, so the API answers health_cursor_blank instead of the flag vanishing
376
+ if (opts.healthCursor !== undefined)
377
+ usp.set("health_cursor", opts.healthCursor);
378
+ if (opts.healthRowsOnly)
379
+ usp.set("health_rows_only", "true");
380
+ const answer = await client.get(`/api/v1/deals?${usp.toString()}`);
381
+ // the live rows-only answer is { data: { deals: page } }: print the page itself, as the help promises
382
+ if (opts.healthRowsOnly) {
383
+ const page = answer?.data?.deals;
384
+ if (page === undefined || page === null || typeof page !== "object") {
385
+ throw new Error("--health-rows-only: the API answered without the page (data.deals). Aborting rather than print another shape.");
386
+ }
387
+ return { data: page };
388
+ }
389
+ return answer;
390
+ }
329
391
  const MAX_PER_PAGE = 1000; // matches the server's per_page cap
330
392
  const parsePos = (v, name) => {
331
393
  if (v === undefined)
@@ -425,6 +487,64 @@ deals
425
487
  }
426
488
  return res;
427
489
  }));
490
+ deals
491
+ .command("health")
492
+ .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\``)
493
+ .option("--pipeline <id>", "pipeline UUID; omit for every visible active pipeline")
494
+ .option("--check <check>", `adds that check's deals (${PIPELINE_HEALTH_CHECKS.join(" | ")})`)
495
+ .option("--limit <n>", "with --check: deals per page, 1-100 (default 5)")
496
+ .option("--offset <n>", "with --check: deals to skip (default 0); not combined with --cursor")
497
+ .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)")
498
+ .action(run(async (client, opts) => {
499
+ rejectBlankFlags([["--pipeline", opts.pipeline], ["--check", opts.check], ["--limit", opts.limit], ["--offset", opts.offset]]);
500
+ const usp = new URLSearchParams();
501
+ if (opts.pipeline !== undefined)
502
+ usp.set("pipeline", opts.pipeline);
503
+ if (opts.check !== undefined)
504
+ usp.set("check", opts.check);
505
+ if (opts.limit !== undefined)
506
+ usp.set("limit", opts.limit);
507
+ if (opts.offset !== undefined)
508
+ usp.set("offset", opts.offset);
509
+ // sent as given, even when blank, so the API answers health_cursor_blank
510
+ if (opts.cursor !== undefined)
511
+ usp.set("cursor", opts.cursor);
512
+ const qs = usp.toString();
513
+ return client.get(`/api/v1/deals/inspection${qs ? `?${qs}` : ""}`);
514
+ }));
515
+ // (BDA board 10-08) settings.dealCardFields: the fields on each kanban deal card, in order
516
+ const cardFields = deals
517
+ .command("card-fields")
518
+ .description("The fields shown on each kanban deal card, in order (settings.dealCardFields, max 8)");
519
+ cardFields
520
+ .command("get")
521
+ .description("Read the card fields: built-ins (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) and custom:<fieldKey> entries")
522
+ .action(run(async (client) => {
523
+ const res = (await client.get("/api/v1/settings"));
524
+ return { data: { dealCardFields: res?.data?.dealCardFields ?? null } };
525
+ }));
526
+ cardFields
527
+ .command("set [fields]")
528
+ .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")
529
+ .option("--json <json>", "the complete list as a JSON array, e.g. '[\"value\",\"custom:next_contact_at\"]' (instead of the positional list)")
530
+ .action(run(async (client, fields, opts) => {
531
+ if ((fields === undefined) === (opts.json === undefined))
532
+ throw new Error("Pass the fields as a comma-separated list or as --json '[...]', not both and not neither.");
533
+ let list;
534
+ if (opts.json !== undefined) {
535
+ try {
536
+ list = JSON.parse(opts.json);
537
+ }
538
+ catch {
539
+ throw new Error("--json must be valid JSON.");
540
+ }
541
+ }
542
+ else {
543
+ list = splitList(fields);
544
+ }
545
+ const dealCardFields = validateCardFields(list);
546
+ return patchGeneralSettingsMerged(client, () => ({ dealCardFields }));
547
+ }));
428
548
  deals
429
549
  .command("create")
430
550
  .description("Create a deal (use `pipelines list` to get pipeline/stage ids)")
@@ -639,18 +759,22 @@ const pipelines = program
639
759
  .action(run(async (client) => client.get("/api/v1/pipelines")));
640
760
  pipelines
641
761
  .command("list")
642
- .description("List pipelines and their stages")
643
- .action(run(async (client) => client.get("/api/v1/pipelines")));
762
+ .description("List pipelines and their stages (each pipeline carries color, automationOnly, sortOrder). --archived lists the ARCHIVED pipelines instead (owner / admin key): id, name (the pre-archive name), archivedName, color, archivedAt, openDeals, stageCount")
763
+ .option("--archived", "list the archived pipelines (GET /pipelines?archived=true) instead of the active ones")
764
+ .action(run(async (client, opts) => client.get(opts.archived ? "/api/v1/pipelines?archived=true" : "/api/v1/pipelines")));
644
765
  pipelines
645
766
  .command("create")
646
767
  .description("Create a pipeline with stages")
647
768
  .option("--name <name>", "pipeline name")
648
769
  .option("--stages <json>", "JSON array of stages, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'")
770
+ .option("--color <hex>", `pipeline colour, one of the palette: ${PIPELINE_COLORS.join(" ")}`)
649
771
  .option("--json <json>", "full JSON body, merged over the flags")
650
772
  .action(run(async (client, opts) => {
651
773
  const body = {};
652
774
  if (opts.name !== undefined)
653
775
  body.name = opts.name;
776
+ if (opts.color !== undefined)
777
+ body.color = pipelineColor(opts.color);
654
778
  if (opts.stages !== undefined) {
655
779
  if (opts.stages.trim() === "")
656
780
  throw new Error("--stages must be valid JSON (got an empty string).");
@@ -675,6 +799,116 @@ pipelines
675
799
  assertStagesShape(body.stages);
676
800
  return client.post("/api/v1/pipelines", body);
677
801
  }));
802
+ // (BDA board 10-08) PATCH /pipelines/:id: name, description, isDefault, sortOrder and automationOnly (owner / admin key)
803
+ pipelines
804
+ .command("update <id>")
805
+ .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)")
806
+ .option("--name <name>", "pipeline name")
807
+ .option("--description <text>", "pipeline description")
808
+ .option("--is-default <bool>", "true|false: the account's default pipeline")
809
+ .option("--automation-only <bool>", "true|false: only automations, API keys and owners / admins move or edit its cards")
810
+ .option("--sort-order <n>", "position among the pipelines (integer)")
811
+ .option("--color <hex>", `pipeline colour, one of the palette: ${PIPELINE_COLORS.join(" ")}`)
812
+ .option("--json <json>", "full JSON body, merged over the flags")
813
+ .action(run(async (client, id, opts) => {
814
+ assertUuid(id, "pipeline id");
815
+ const flag = (raw, name) => {
816
+ if (raw === undefined)
817
+ return undefined;
818
+ const v = raw.trim().toLowerCase();
819
+ if (v === "true")
820
+ return true;
821
+ if (v === "false")
822
+ return false;
823
+ throw new Error(`${name} must be true or false.`);
824
+ };
825
+ // --json is parsed FIRST and wins: a flag is read (and validated) only when the JSON does not carry its key, so
826
+ // --json can override a flag whose value would not have parsed
827
+ const fromJson = opts.json !== undefined ? jsonArg(opts.json, "--json") : {};
828
+ const body = {};
829
+ const unlessJson = (key) => !Object.prototype.hasOwnProperty.call(fromJson, key);
830
+ if (opts.name !== undefined && unlessJson("name"))
831
+ body.name = opts.name;
832
+ if (opts.description !== undefined && unlessJson("description"))
833
+ body.description = opts.description;
834
+ if (unlessJson("isDefault")) {
835
+ const isDefault = flag(opts.isDefault, "--is-default");
836
+ if (isDefault !== undefined)
837
+ body.isDefault = isDefault;
838
+ }
839
+ if (unlessJson("automationOnly")) {
840
+ const automationOnly = flag(opts.automationOnly, "--automation-only");
841
+ if (automationOnly !== undefined)
842
+ body.automationOnly = automationOnly;
843
+ }
844
+ if (opts.sortOrder !== undefined && unlessJson("sortOrder")) {
845
+ const n = Number(opts.sortOrder.trim());
846
+ if (opts.sortOrder.trim() === "" || !Number.isInteger(n))
847
+ throw new Error("--sort-order must be an integer.");
848
+ body.sortOrder = n;
849
+ }
850
+ if (opts.color !== undefined && unlessJson("color"))
851
+ body.color = pipelineColor(opts.color);
852
+ Object.assign(body, fromJson);
853
+ if (Object.keys(body).length === 0)
854
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
855
+ return client.patch(`/api/v1/pipelines/${encodeURIComponent(id)}`, body);
856
+ }));
857
+ // (BDA board 10-08) settings.pipelineStageRules: what a deal needs before it may enter a stage. Read-merge-write with the
858
+ // settings revision (a 409 is re-read and retried once); other stages and pipelines are untouched.
859
+ const stageRules = pipelines
860
+ .command("stage-rules")
861
+ .description("Per-stage rules (settings.pipelineStageRules): the fields and documents a deal needs before it may enter a stage");
862
+ stageRules
863
+ .command("get [pipelineId] [stageId]")
864
+ .description("Read the stage rules as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther } } }, for everything, one pipeline, or one stage")
865
+ .action(run(async (client, pipelineId, stageId) => {
866
+ if (pipelineId !== undefined)
867
+ assertUuid(pipelineId, "pipeline id");
868
+ if (stageId !== undefined) {
869
+ if (pipelineId === undefined)
870
+ throw new Error("stage id needs a pipeline id.");
871
+ assertUuid(stageId, "stage id");
872
+ }
873
+ const res = (await client.get("/api/v1/settings"));
874
+ const all = res?.data?.pipelineStageRules && typeof res.data.pipelineStageRules === "object" ? res.data.pipelineStageRules : {};
875
+ if (pipelineId === undefined)
876
+ return { data: all };
877
+ const stages = all[pipelineId] ?? {};
878
+ return { data: { [pipelineId]: stageId === undefined ? stages : stages[stageId] !== undefined ? { [stageId]: stages[stageId] } : {} } };
879
+ }));
880
+ stageRules
881
+ .command("set <pipelineId> <stageId>")
882
+ .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)")
883
+ .option("--required-fields <list>", "comma-separated, e.g. contactId,custom:next_contact_at (empty clears)")
884
+ .option("--required-documents <n>", "integer 0-20")
885
+ .option("--lost-reason-label <text>", "label of the stage's lost-reason prompt (1-60 chars)")
886
+ .option("--lost-reason-options <list>", "comma-separated dropdown options of the lost-reason prompt (max 50; empty clears)")
887
+ .option("--lost-reason-allow-other <bool>", "true|false: the rep may also pick \"Other\" and type the reason (stored as the reason text; takes effect while the stage has lost-reason options)")
888
+ .option("--json <json>", "the rule fields as a JSON object ({requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther}), merged over the flags")
889
+ .action(run(async (client, pipelineId, stageId, opts) => {
890
+ assertUuid(pipelineId, "pipeline id");
891
+ assertUuid(stageId, "stage id");
892
+ const raw = {};
893
+ if (opts.requiredFields !== undefined)
894
+ raw.requiredFields = splitList(opts.requiredFields);
895
+ if (opts.requiredDocuments !== undefined) {
896
+ const t = opts.requiredDocuments.trim();
897
+ raw.requiredDocuments = t === "" ? NaN : Number(t);
898
+ }
899
+ if (opts.lostReasonLabel !== undefined)
900
+ raw.lostReasonLabel = opts.lostReasonLabel;
901
+ if (opts.lostReasonOptions !== undefined)
902
+ raw.lostReasonOptions = splitList(opts.lostReasonOptions);
903
+ if (opts.lostReasonAllowOther !== undefined)
904
+ raw.lostReasonAllowOther = boolFlag(opts.lostReasonAllowOther, "--lost-reason-allow-other");
905
+ if (opts.json !== undefined)
906
+ Object.assign(raw, jsonArg(opts.json, "--json"));
907
+ const change = validateRuleChange(raw);
908
+ if (Object.keys(change).length === 0)
909
+ throw new Error("Nothing to set. Pass --required-fields, --required-documents, --lost-reason-label, --lost-reason-options, --lost-reason-allow-other or --json.");
910
+ return patchGeneralSettingsMerged(client, (settings) => stageRulePatch(settings, pipelineId, stageId, change));
911
+ }));
678
912
  // (system stages 2026-09-24) tri-surface parity: the stage flags the API takes on PATCH /pipelines/:id/stages/:stageId,
679
913
  // isSystem included (a system holding stage automations route deals through; hidden from rep pickers, never won or lost)
680
914
  pipelines
@@ -728,6 +962,245 @@ pipelines
728
962
  throw new Error("Nothing to update. Pass at least one field flag or --json.");
729
963
  return client.patch(`/api/v1/pipelines/${encodeURIComponent(pipelineId)}/stages/${encodeURIComponent(stageId)}`, body);
730
964
  }));
965
+ // (pipeline settings 10-08) archive / restore / reorder, stage delete with a move, the stale-in-stage days.
966
+ pipelines
967
+ .command("archive <id>")
968
+ .description("Archive a pipeline (owner / admin key): renamed with the archived prefix, never deleted; its closed deals stay as history and `pipelines restore` brings it back. Every OPEN deal moves in the same transaction to --move-open-deals-to's first stage through the normal stage-entry rules; any refusal aborts all of it. Pass --move-open-deals-to none only when there are no open deals. Waits up to 75 s; if it times out the archive may have committed, so run `pipelines list` / `pipelines list --archived` before retrying. The default pipeline cannot be archived. Refusal codes are printed with the error and the command exits 1: stale_settings_revision, target_not_open, dpp_post_close, archive_too_large, target_archived, intake_target, pipeline_archived, open_deals, default_pipeline, stage_requirements, archive_raced, pipeline_automation_only. An already-archived pipeline answers { archived: true, alreadyArchived: true }")
969
+ .requiredOption("--move-open-deals-to <pipelineId|none>", "the active pipeline the open deals move to (its first stage must be open), or none when the pipeline has no open deals")
970
+ .option("--expected-settings-revision <n>", "the settingsRevision from `pipelines settings-revision`; refused 409 stale_settings_revision when settings changed since")
971
+ .action(run(async (client, id, opts) => {
972
+ const pid = uuidOf(id, "pipeline id");
973
+ const target = opts.moveOpenDealsTo.trim().toLowerCase();
974
+ const moveOpenDealsTo = target === "none" ? null : uuidOf(opts.moveOpenDealsTo, "--move-open-deals-to");
975
+ const body = { moveOpenDealsTo };
976
+ if (opts.expectedSettingsRevision !== undefined)
977
+ body.expectedSettingsRevision = settingsRevision(opts.expectedSettingsRevision);
978
+ return client.post(`/api/v1/pipelines/${pid}/archive`, body, { timeoutMs: ARCHIVE_PIPELINE_TIMEOUT_MS });
979
+ }));
980
+ pipelines
981
+ .command("restore <id>")
982
+ .description("Restore an ARCHIVED pipeline (owner / admin key): it loses the archived prefix, goes last in the board menu and is never the default; its stages and closed deals come back with it. 404 when it is not an archived pipeline of this account; 409 name_taken when an active pipeline has its name (rename that one first); 403 PLAN_LIMIT_REACHED when the plan's pipeline limit is full")
983
+ .action(run(async (client, id) => client.post(`/api/v1/pipelines/${uuidOf(id, "pipeline id")}/restore`, {})));
984
+ pipelines
985
+ .command("reorder")
986
+ .description("Set the board-menu order of the active pipelines in one transaction (owner / admin key). --order must list EVERY active pipeline exactly once; --expected-revision is the order you last read (the pipeline ids by position, comma-joined). 409 order_changed (with the current revision) when the order moved since; nothing is written. Prints { order, revision }")
987
+ .requiredOption("--order <ids>", "comma-separated pipeline ids in the new order")
988
+ .requiredOption("--expected-revision <revision>", "the current order: the pipeline ids by position, comma-joined")
989
+ .action(run(async (client, opts) => {
990
+ const order = uuidList(opts.order, "--order");
991
+ if (opts.expectedRevision.trim() === "")
992
+ throw new Error("--expected-revision must be the current order (the pipeline ids by position, comma-joined).");
993
+ return client.post("/api/v1/pipelines/reorder", { order, expectedRevision: opts.expectedRevision.trim().toLowerCase() });
994
+ }));
995
+ pipelines
996
+ .command("reorder-stages <pipelineId>")
997
+ .description("Set a pipeline's column order in one transaction (owner / admin key). --order must list EVERY stage of the pipeline exactly once and may not put a system holding stage first that was not first; --expected-revision is the stage ids by position, comma-joined. 409 order_changed (with the current revision) when it moved since, 409 pipeline_archived when the pipeline was archived meanwhile. Prints { order, revision }")
998
+ .requiredOption("--order <ids>", "comma-separated stage ids in the new order")
999
+ .requiredOption("--expected-revision <revision>", "the current order: the stage ids by position, comma-joined")
1000
+ .action(run(async (client, pipelineId, opts) => {
1001
+ const pid = uuidOf(pipelineId, "pipeline id");
1002
+ const order = uuidList(opts.order, "--order");
1003
+ if (opts.expectedRevision.trim() === "")
1004
+ throw new Error("--expected-revision must be the current order (the stage ids by position, comma-joined).");
1005
+ return client.post(`/api/v1/pipelines/${pid}/stages/reorder`, { order, expectedRevision: opts.expectedRevision.trim().toLowerCase() });
1006
+ }));
1007
+ pipelines
1008
+ .command("delete-stage <pipelineId> <stageId>")
1009
+ .description("Delete a stage (owner / admin key). Without --move-to the stage must have no deals; with it the live deals move to another stage of the SAME pipeline (same won / lost setting, not a system stage) and the stage is deleted in one transaction. A stage a soft-deleted deal still points at is refused. With --expected-settings-revision the answer is { id, deleted: true, settingsRevision }. Refusals exit 1 with the code printed: stale_settings_revision, pipeline_archived, intake_target (other 409s name the cause)")
1010
+ .option("--move-to <stageId>", "another stage of the same pipeline the live deals move to")
1011
+ .option("--expected-settings-revision <n>", "the settingsRevision from `pipelines settings-revision`; refused 409 stale_settings_revision when settings changed since")
1012
+ .action(run(async (client, pipelineId, stageId, opts) => {
1013
+ const qs = new URLSearchParams();
1014
+ if (opts.moveTo !== undefined)
1015
+ qs.set("move_to", uuidOf(opts.moveTo, "--move-to"));
1016
+ if (opts.expectedSettingsRevision !== undefined)
1017
+ qs.set("expected_settings_revision", settingsRevision(opts.expectedSettingsRevision));
1018
+ const q = qs.toString();
1019
+ const res = await client.del(`/api/v1/pipelines/${uuidOf(pipelineId, "pipeline id")}/stages/${uuidOf(stageId, "stage id")}${q ? `?${q}` : ""}`);
1020
+ return res ?? { data: { deleted: true, id: stageId } };
1021
+ }));
1022
+ pipelines
1023
+ .command("settings-revision")
1024
+ .description("The account settingsRevision (plus the stored stage rules and rot days): pass it as --expected-settings-revision to `pipelines archive` / `pipelines delete-stage` so they are refused (409 stale_settings_revision) when settings changed after you read it. Read it right before the mutation")
1025
+ .action(run(async (client) => {
1026
+ const res = (await client.get("/api/v1/settings"));
1027
+ const d = res?.data ?? {};
1028
+ return { data: { settingsRevision: typeof d.settingsRevision === "string" ? d.settingsRevision : null, pipelineStageRules: d.pipelineStageRules ?? null, pipelineRotDays: d.pipelineRotDays ?? null } };
1029
+ }));
1030
+ // settings.pipelineRotDays: days without activity before a deal reads "Stale in stage" (per pipeline, 1-90)
1031
+ const rotDays = pipelines
1032
+ .command("rot-days")
1033
+ .description("Stale-in-stage threshold per pipeline (settings.pipelineRotDays): days without activity (1-90) before a deal reads \"Stale in stage\"");
1034
+ rotDays
1035
+ .command("get [pipelineId]")
1036
+ .description("Read the thresholds as { pipelineId: days }, for every pipeline that has one or just the one given")
1037
+ .action(run(async (client, pipelineId) => {
1038
+ const pid = pipelineId === undefined ? undefined : uuidOf(pipelineId, "pipeline id");
1039
+ const res = (await client.get("/api/v1/settings"));
1040
+ const all = res?.data?.pipelineRotDays && typeof res.data.pipelineRotDays === "object" ? res.data.pipelineRotDays : {};
1041
+ return { data: { pipelineRotDays: pid === undefined ? all : all[pid] !== undefined ? { [pid]: all[pid] } : {} } };
1042
+ }));
1043
+ rotDays
1044
+ .command("set <pipelineId> <days>")
1045
+ .description(`Set one pipeline's threshold (admin key, settings:edit): an integer ${MIN_PIPELINE_ROT_DAYS}-${MAX_PIPELINE_ROT_DAYS}. Only that pipeline changes. The pipeline must be an ACTIVE pipeline of the account (422 for an unknown or archived one). Saved with the settings revision; a 409 is re-read and retried once`)
1046
+ .action(run(async (client, pipelineId, days) => {
1047
+ const pid = uuidOf(pipelineId, "pipeline id");
1048
+ const n = intFlag(days, "days", MIN_PIPELINE_ROT_DAYS, MAX_PIPELINE_ROT_DAYS);
1049
+ return patchGeneralSettingsMerged(client, () => ({ pipelineRotDays: { [pid]: n } }));
1050
+ }));
1051
+ // ---- status metadata (app 10-08): the status taxonomy rows attached to a pipeline stage ----
1052
+ const statusMetadata = program
1053
+ .command("status-metadata")
1054
+ .description("Status metadata: the status codes and buckets attached to pipeline stages (one row per stage and code). update / delete take --expected-updated-at (the row's updatedAt as read): 409 metadata_changed (with the current row) when it changed or moved since, 404 status_metadata_not_found");
1055
+ statusMetadata
1056
+ .command("list")
1057
+ .description("List the rows (settings:view); updatedAt is the version for --expected-updated-at")
1058
+ .option("--stage-id <id>", "only this stage's rows")
1059
+ .option("--bucket <bucket>", "only this bucket")
1060
+ .option("--code <code>", "only this code")
1061
+ .action(run(async (client, opts) => client.get(`/api/v1/status-metadata${buildQuery({ stageId: opts.stageId === undefined ? undefined : uuidOf(opts.stageId, "--stage-id"), bucket: opts.bucket, code: opts.code })}`)));
1062
+ statusMetadata
1063
+ .command("get <id>")
1064
+ .description("One row with its stage and drip track")
1065
+ .action(run(async (client, id) => client.get(`/api/v1/status-metadata/${uuidOf(id, "id")}`)));
1066
+ /** the row fields shared by create and update; "null" clears a nullable one */
1067
+ function statusMetadataFields(opts) {
1068
+ const body = {};
1069
+ if (opts.stageId !== undefined)
1070
+ body.stageId = uuidOf(opts.stageId, "--stage-id");
1071
+ if (opts.code !== undefined)
1072
+ body.code = slugArg(opts.code, "--code");
1073
+ if (opts.bucket !== undefined)
1074
+ body.bucket = slugArg(opts.bucket, "--bucket");
1075
+ if (opts.lifecycleStage !== undefined)
1076
+ body.lifecycleStage = opts.lifecycleStage === "null" ? null : lifecycleStageArg(opts.lifecycleStage);
1077
+ if (opts.dripEligible !== undefined)
1078
+ body.dripEligible = boolFlag(opts.dripEligible, "--drip-eligible");
1079
+ if (opts.dripTrackId !== undefined)
1080
+ body.dripTrackId = opts.dripTrackId === "null" ? null : uuidOf(opts.dripTrackId, "--drip-track-id");
1081
+ if (opts.isTerminal !== undefined)
1082
+ body.isTerminal = boolFlag(opts.isTerminal, "--is-terminal");
1083
+ if (opts.notes !== undefined) {
1084
+ if (opts.notes !== "null" && opts.notes.length > 5000)
1085
+ throw new Error("--notes must be at most 5000 characters.");
1086
+ body.notes = opts.notes === "null" ? null : opts.notes;
1087
+ }
1088
+ return body;
1089
+ }
1090
+ statusMetadata
1091
+ .command("create")
1092
+ .description("Create a row (settings:edit, active billing). --code and --bucket are lowercase slugs (letters, numbers, underscores, max 80); one row per stage and code (a duplicate is a 409)")
1093
+ .requiredOption("--stage-id <id>", "pipeline stage the row attaches to")
1094
+ .requiredOption("--code <slug>", "status code, e.g. appt_set")
1095
+ .requiredOption("--bucket <slug>", "bucket, e.g. working")
1096
+ .option("--lifecycle-stage <stage>", `one of ${["pre_sale", "sale", "underwriting", "active", "resolution", "terminal"].join(", ")}, or null`)
1097
+ .option("--drip-eligible <bool>", "true|false")
1098
+ .option("--drip-track-id <id>", "drip track id, or null")
1099
+ .option("--is-terminal <bool>", "true|false")
1100
+ .option("--notes <text>", "free text (max 5000), or null")
1101
+ .option("--json <json>", "full JSON body, merged over the flags")
1102
+ .action(run(async (client, opts) => {
1103
+ const body = statusMetadataFields(opts);
1104
+ if (opts.json !== undefined)
1105
+ Object.assign(body, jsonArg(opts.json, "--json"));
1106
+ // flags + --json together, against the exact field list and types: the API ignores a misspelled key and applies the rest
1107
+ return client.post("/api/v1/status-metadata", validateStatusMetadataBody(body, "create"));
1108
+ }));
1109
+ statusMetadata
1110
+ .command("update <id>")
1111
+ .description("Update a row (settings:edit, active billing): only the fields you pass change. --expected-updated-at refuses the update when the row was changed or moved since you read it (409 metadata_changed, the current row is in the error)")
1112
+ .option("--expected-updated-at <iso>", "the row's updatedAt as you read it")
1113
+ .option("--stage-id <id>", "move the row to another stage")
1114
+ .option("--code <slug>", "status code")
1115
+ .option("--bucket <slug>", "bucket")
1116
+ .option("--lifecycle-stage <stage>", "lifecycle stage, or null")
1117
+ .option("--drip-eligible <bool>", "true|false")
1118
+ .option("--drip-track-id <id>", "drip track id, or null")
1119
+ .option("--is-terminal <bool>", "true|false")
1120
+ .option("--notes <text>", "free text (max 5000), or null")
1121
+ .option("--json <json>", "full JSON body, merged over the flags")
1122
+ .action(run(async (client, id, opts) => {
1123
+ const rowId = uuidOf(id, "id");
1124
+ const body = statusMetadataFields(opts);
1125
+ if (opts.expectedUpdatedAt !== undefined)
1126
+ body.expectedUpdatedAt = isoDate(opts.expectedUpdatedAt, "--expected-updated-at");
1127
+ if (opts.json !== undefined)
1128
+ Object.assign(body, jsonArg(opts.json, "--json"));
1129
+ validateStatusMetadataBody(body, "update");
1130
+ if (Object.keys(body).filter((k) => k !== "expectedUpdatedAt").length === 0)
1131
+ throw new Error("Nothing to update. Pass at least one field flag or --json.");
1132
+ return client.patch(`/api/v1/status-metadata/${rowId}`, body);
1133
+ }));
1134
+ statusMetadata
1135
+ .command("delete <id>")
1136
+ .description("Delete a row (settings:edit, active billing). --expected-updated-at refuses the delete when the row changed or moved since you read it (409 metadata_changed)")
1137
+ .option("--expected-updated-at <iso>", "the row's updatedAt as you read it")
1138
+ .action(run(async (client, id, opts) => {
1139
+ const rowId = uuidOf(id, "id");
1140
+ const q = opts.expectedUpdatedAt === undefined ? "" : `?expected_updated_at=${encodeURIComponent(isoDate(opts.expectedUpdatedAt, "--expected-updated-at"))}`;
1141
+ const res = await client.del(`/api/v1/status-metadata/${rowId}${q}`);
1142
+ return res ?? { data: { deleted: true, id: rowId } };
1143
+ }));
1144
+ statusMetadata
1145
+ .command("import")
1146
+ .description(`Set-based import (owner / admin key, active billing; max 1000 items) from a JSON array of { stageId, code, bucket, lifecycleStage?, dripEligible?, dripTrackName?, isTerminal?, notes? } via --items or --file. merge (default) upserts the valid items and reports the rest in errors[]; replace replaces the rows of the ACTIVE pipelines and imports nothing if any item is invalid (rows of archived pipelines are kept). With --mode replace and NO items nothing is sent unless --confirm-empty-replace is given (it would delete every row). Prints { imported, skipped, errors } and exits 1 when errors is not empty (the full result is still printed). 409 pipeline_archived when an item names a stage of an archived pipeline`)
1147
+ .option("--mode <mode>", "merge (default) | replace")
1148
+ .option("--items <json>", "the items as a JSON array (or @file)")
1149
+ .option("--file <path>", "read the items JSON array from a file")
1150
+ .option("--confirm-empty-replace", "allow --mode replace with NO items, which deletes every status metadata row of the active pipelines")
1151
+ .action(async (opts) => {
1152
+ try {
1153
+ if (opts.items !== undefined && opts.file !== undefined)
1154
+ throw new Error("Pass either --items or --file, not both.");
1155
+ if (opts.mode !== undefined && opts.mode !== "merge" && opts.mode !== "replace")
1156
+ throw new Error("--mode must be merge or replace.");
1157
+ let raw;
1158
+ if (opts.file !== undefined) {
1159
+ try {
1160
+ raw = readFileSync(opts.file, "utf8");
1161
+ }
1162
+ catch {
1163
+ throw new Error(`--file could not be read: ${opts.file}`);
1164
+ }
1165
+ }
1166
+ else if (opts.items !== undefined) {
1167
+ raw = opts.items.startsWith("@") ? readFileSync(opts.items.slice(1), "utf8") : opts.items;
1168
+ }
1169
+ else {
1170
+ throw new Error("The items are required. Pass --items '<json array>' or --file <path>.");
1171
+ }
1172
+ let parsed;
1173
+ try {
1174
+ parsed = JSON.parse(raw);
1175
+ }
1176
+ catch {
1177
+ throw new Error("The items must be valid JSON.");
1178
+ }
1179
+ const items = validateImportItems(parsed);
1180
+ // checked before any request; the flag is only a local confirmation and is never sent
1181
+ if (opts.mode === "replace" && items.length === 0 && !opts.confirmEmptyReplace) {
1182
+ throw new Error("replace with no items would delete every status metadata row; pass --confirm-empty-replace to do that on purpose");
1183
+ }
1184
+ const client = new ConduytClient();
1185
+ const res = await client.post("/api/v1/status-metadata/import", { ...(opts.mode !== undefined ? { mode: opts.mode } : {}), items }, { timeoutMs: STATUS_METADATA_IMPORT_TIMEOUT_MS });
1186
+ print(res);
1187
+ // a 200 can still carry per-item errors (merge) or a refused replace (imported 0): that is a refusal, exit 1
1188
+ const data = unwrap(res);
1189
+ if (!data || typeof data !== "object" || typeof data.imported !== "number" || !Array.isArray(data.errors)) {
1190
+ process.stderr.write("Error: Unexpected response shape from the API: expected { imported, skipped, errors }.\n");
1191
+ process.exitCode = 1;
1192
+ }
1193
+ else if (data.errors.length > 0) {
1194
+ for (const e of data.errors)
1195
+ process.stderr.write(`Skipped ${String(e?.stageId ?? "?")}: ${String(e?.reason ?? "unknown reason")}\n`);
1196
+ process.stderr.write(`Error: ${data.errors.length} item(s) were not imported (${data.imported} imported).\n`);
1197
+ process.exitCode = 1;
1198
+ }
1199
+ }
1200
+ catch (err) {
1201
+ fail(err);
1202
+ }
1203
+ });
731
1204
  // ---- search ----
732
1205
  program
733
1206
  .command("search <query>")
@@ -1519,13 +1992,13 @@ automations
1519
1992
  }));
1520
1993
  automations
1521
1994
  .command("validate")
1522
- .description("Validate a workflow graph before saving. Provide the body via --json or --file (needs triggerEvent + graph)")
1995
+ .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
1996
  .option("--json <json>", "full request body as JSON")
1524
1997
  .option("--file <path>", "read the request body from a JSON file")
1525
1998
  .action(run(async (client, opts) => client.post("/api/v1/automations/validate", bodyFromJsonOrFile(opts))));
1526
1999
  automations
1527
2000
  .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")
2001
+ .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
2002
  .option("--json <json>", "full request body as JSON")
1530
2003
  .option("--file <path>", "read the request body from a JSON file")
1531
2004
  .option("--resolve-names", "set resolveNames=true so name fields in node configs are converted to ids")
@@ -2250,6 +2723,69 @@ wordpressForms
2250
2723
  .command("revoke <id>")
2251
2724
  .description("Revoke an intake address: submissions to it answer 404 from now on. The row is kept (status revoked) for history")
2252
2725
  .action(run(async (client, id) => client.del(`/api/v1/integrations/wordpress-forms/intakes/${uuidArg(id, "id")}`)));
2726
+ // Integrations -> Mailchimp (app 10-07/10-08, API key scope `integrations`): the audience sync. The OAuth connect and
2727
+ // callback are browser-only (connect Mailchimp in the app: Settings > Integrations), so there is no `connect` here.
2728
+ const mailchimp = integrations
2729
+ .command("mailchimp")
2730
+ .description("Mailchimp audience sync (API key scope integrations). Connecting is browser-only (OAuth): do it in the app, then configure it here. Errors exit 1 with the server's message and code (404 not connected, 409 revoked: reconnect in the app)");
2731
+ mailchimp
2732
+ .command("settings")
2733
+ .description("The Mailchimp connection: the audience, the source (one smart list or one tag), statusIfNew, unsubscribeSetsOptOut and the sync status (backfill progress, pending pushes, last pushed, last webhook, the last Mailchimp error, statusSweepBlocked). connected: false when there is none")
2734
+ .action(run(async (client) => client.get("/api/v1/integrations/mailchimp/settings")));
2735
+ mailchimp
2736
+ .command("set")
2737
+ .description("Configure the sync (PUT; settings:edit, active billing): only the flags you pass change. A new audience gets Conduyt's webhook and starts the backfill; a new source starts the backfill. --source-type and --source-id go together")
2738
+ .option("--list-id <id>", "Mailchimp audience id (see `audiences`)")
2739
+ .option("--source-type <type>", "smart_list | tag")
2740
+ .option("--source-id <id>", "the smart list or tag UUID")
2741
+ .option("--status-if-new <status>", "subscribed (default) | pending (Mailchimp sends a confirmation email)")
2742
+ .option("--unsubscribe-sets-opt-out <bool>", "true|false (default true): a Mailchimp unsubscribe also opts the contact out of Conduyt email")
2743
+ .action(run(async (client, opts) => {
2744
+ const body = {};
2745
+ if (opts.listId !== undefined) {
2746
+ if (opts.listId.trim() === "")
2747
+ throw new Error("--list-id was provided but is blank.");
2748
+ body.listId = opts.listId.trim();
2749
+ }
2750
+ if ((opts.sourceType === undefined) !== (opts.sourceId === undefined))
2751
+ throw new Error("--source-type and --source-id go together.");
2752
+ if (opts.sourceType !== undefined) {
2753
+ if (opts.sourceType !== "smart_list" && opts.sourceType !== "tag")
2754
+ throw new Error('--source-type must be "smart_list" or "tag".');
2755
+ body.source = { type: opts.sourceType, id: uuidOf(opts.sourceId, "--source-id") };
2756
+ }
2757
+ if (opts.statusIfNew !== undefined) {
2758
+ if (opts.statusIfNew !== "subscribed" && opts.statusIfNew !== "pending")
2759
+ throw new Error('--status-if-new must be "subscribed" or "pending".');
2760
+ body.statusIfNew = opts.statusIfNew;
2761
+ }
2762
+ if (opts.unsubscribeSetsOptOut !== undefined)
2763
+ body.unsubscribeSetsOptOut = boolFlag(opts.unsubscribeSetsOptOut, "--unsubscribe-sets-opt-out");
2764
+ if (Object.keys(body).length === 0)
2765
+ throw new Error("Nothing to set. Pass --list-id, --source-type with --source-id, --status-if-new or --unsubscribe-sets-opt-out.");
2766
+ return client.put("/api/v1/integrations/mailchimp/settings", body, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS });
2767
+ }));
2768
+ mailchimp
2769
+ .command("audiences")
2770
+ .description("The connected account's audiences: id, name, memberCount (use an id as --list-id)")
2771
+ .action(run(async (client) => client.get("/api/v1/integrations/mailchimp/audiences")));
2772
+ mailchimp
2773
+ .command("push")
2774
+ .description("Run one sync pass now instead of waiting for the next minute (settings:edit, active billing): queue the contacts newly in the source, take a backfill step, send due pushes. Waits up to 60 s (if it times out the server may have applied it: read `settings`). Prints { ran, result, settings }; ran false means another pass holds the connection or Mailchimp asked to slow down")
2775
+ .action(run(async (client) => client.post("/api/v1/integrations/mailchimp/push", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS })));
2776
+ mailchimp
2777
+ .command("retry-unsubscribes")
2778
+ .description("Try again, in one pass now, the unsubscribes Conduyt could not deliver to Mailchimp (also retried daily). Prints { ran, pending, delivered, undelivered, result, settings }; pending true = nothing was tried. One retry per account per 10 minutes: otherwise exit 1 with 429 retry_cooldown")
2779
+ .action(run(async (client) => client.post("/api/v1/integrations/mailchimp/unsubscribes/retry", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS })));
2780
+ mailchimp
2781
+ .command("reconcile-statuses")
2782
+ .description("Full reconciliation when Conduyt's check of Mailchimp's unsubscribes is blocked (statusSweepBlocked in `settings`; owner / admin key): reads every member Mailchimp shows as unsubscribed or cleaned up to the blocked second and applies them (it only ever OPTS OUT), then records a report; it never resumes the check on its own, review the report and run `confirm-reconciliation`. Starts or resumes the run and runs one pass: repeat until done is true. --restart replaces a completed run's report with a new run; without it a completed run exits 1 with 409 reconciliation_completed")
2783
+ .option("--restart", "replace a completed run's report with a new run")
2784
+ .action(run(async (client, opts) => client.post("/api/v1/integrations/mailchimp/reconcile-statuses", opts.restart ? { restart: true } : {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS })));
2785
+ mailchimp
2786
+ .command("confirm-reconciliation")
2787
+ .description("Confirm the completed reconciliation's report and resume the unsubscribe check after the blocked second (owner / admin key; audited with the report's counts). 409 when nothing is blocked, a reconciliation is still running, or none completed since the block began")
2788
+ .action(run(async (client) => client.post("/api/v1/integrations/mailchimp/confirm-reconciliation", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS })));
2253
2789
  // ---------------------------------------------------------------------------
2254
2790
  // Sequences (one model for Email and SMS since 2026-09-30): steps send saved templates of the sequence's channel a
2255
2791
  // 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
@@ -3035,12 +3571,17 @@ replyCapture
3035
3571
  // (400 expected_settings_revision_required) and answers 409 when another save landed first. Every general
3036
3572
  // write below reads the revision first and sends it; a 409 is re-raised with the current settings so the
3037
3573
  // caller re-derives the change instead of overwriting a newer save.
3574
+ // the ONE place a general-settings PATCH is sent: it always carries the revision it was derived from (lifecycle-guards.test.ts
3575
+ // pins this single call site and that both helpers hand it their revision)
3576
+ function patchSettingsWithRevision(client, body, expectedSettingsRevision) {
3577
+ return client.patch("/api/v1/settings", { ...body, ...(expectedSettingsRevision ? { expectedSettingsRevision } : {}) });
3578
+ }
3038
3579
  async function patchGeneralSettings(client, body) {
3039
3580
  const read = async () => (await client.get("/api/v1/settings"));
3040
3581
  const before = await read();
3041
3582
  const expectedSettingsRevision = typeof before?.data?.settingsRevision === "string" ? before.data.settingsRevision : null;
3042
3583
  try {
3043
- return await client.patch("/api/v1/settings", { ...body, ...(expectedSettingsRevision ? { expectedSettingsRevision } : {}) });
3584
+ return await patchSettingsWithRevision(client, body, expectedSettingsRevision);
3044
3585
  }
3045
3586
  catch (err) {
3046
3587
  if (err instanceof Error && /^Conduyt API 409/.test(err.message)) {
@@ -3050,6 +3591,30 @@ async function patchGeneralSettings(client, body) {
3050
3591
  throw err;
3051
3592
  }
3052
3593
  }
3594
+ // read-merge-write for a change that depends on what is stored (a per-stage rule): `build` derives the PATCH body from the
3595
+ // settings just read and the save carries that read's settingsRevision. A 409 (another save landed first) re-reads and
3596
+ // re-derives ONCE; a second 409 is re-raised with the current settings, like patchGeneralSettings.
3597
+ async function patchGeneralSettingsMerged(client, build) {
3598
+ const read = async () => (await client.get("/api/v1/settings"));
3599
+ let revision = null;
3600
+ for (let attempt = 0;; attempt += 1) {
3601
+ const before = await read();
3602
+ const settings = before?.data ?? {};
3603
+ revision = typeof settings.settingsRevision === "string" ? settings.settingsRevision : null;
3604
+ const body = build(settings);
3605
+ try {
3606
+ return await patchSettingsWithRevision(client, body, revision);
3607
+ }
3608
+ catch (err) {
3609
+ if (!(err instanceof Error && /^Conduyt API 409/.test(err.message)))
3610
+ throw err;
3611
+ if (attempt === 1) {
3612
+ const current = await read();
3613
+ 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)})`);
3614
+ }
3615
+ }
3616
+ }
3617
+ }
3053
3618
  function poolFilterOf(settings) {
3054
3619
  const raw = settings.poolFilter;
3055
3620
  const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Pipeline settings release (app 10-08), status metadata and Mailchimp: the validators the commands run before any
3
+ * request. They mirror the API (the source of truth) so a bad value fails locally with the same words; the API still
4
+ * re-checks everything and its refusal codes are printed with the error.
5
+ */
6
+ export const PIPELINE_COLORS = ["#3845C9", "#43DFF6", "#2DD6B5", "#22C58B", "#9BD13B", "#F2B544", "#F07A2F", "#E6465A", "#FD88C0", "#956EFA"];
7
+ export const LIFECYCLE_STAGES = ["pre_sale", "sale", "underwriting", "active", "resolution", "terminal"];
8
+ export const MAX_STATUS_METADATA_IMPORT = 1000;
9
+ /** per-call deadlines above the server budgets (archive tx 60 s; Mailchimp admin 60 s; import tx 20 s plus locks) */
10
+ export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
11
+ export const MAILCHIMP_ADMIN_TIMEOUT_MS = 60_000;
12
+ export const STATUS_METADATA_IMPORT_TIMEOUT_MS = 60_000;
13
+ /** the exact fields an import item takes; anything else (a misspelled isTerminal, say) would be dropped by the API and stored as defaults */
14
+ export const IMPORT_ITEM_KEYS = ["stageId", "code", "bucket", "lifecycleStage", "dripEligible", "dripTrackName", "isTerminal", "notes"];
15
+ export const MIN_PIPELINE_ROT_DAYS = 1;
16
+ export const MAX_PIPELINE_ROT_DAYS = 90;
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const SLUG_RE = /^[a-z0-9][a-z0-9_]{0,79}$/;
19
+ const SETTINGS_REVISION_RE = /^\d+$/;
20
+ export const isUuid = (v) => typeof v === "string" && UUID_RE.test(v.trim());
21
+ export function uuid(raw, name) {
22
+ if (!isUuid(raw))
23
+ throw new Error(`${name} must be a UUID.`);
24
+ return raw.trim().toLowerCase();
25
+ }
26
+ /** true | false (any case) */
27
+ export function boolFlag(raw, name) {
28
+ const v = raw.trim().toLowerCase();
29
+ if (v === "true")
30
+ return true;
31
+ if (v === "false")
32
+ return false;
33
+ throw new Error(`${name} must be true or false.`);
34
+ }
35
+ export function intFlag(raw, name, min, max) {
36
+ const t = raw.trim();
37
+ const n = Number(t);
38
+ if (t === "" || !Number.isInteger(n) || n < min || n > max)
39
+ throw new Error(`${name} must be an integer ${min}-${max}.`);
40
+ return n;
41
+ }
42
+ /** A pipeline colour is one of the 10 palette colours (case-insensitive); the API answers 400 for anything else. */
43
+ export function pipelineColor(raw, name = "--color") {
44
+ const upper = raw.trim().toUpperCase();
45
+ const hit = PIPELINE_COLORS.find((c) => c === upper);
46
+ if (!hit)
47
+ throw new Error(`${name} must be one of the pipeline palette colours: ${PIPELINE_COLORS.join(", ")}.`);
48
+ return hit;
49
+ }
50
+ /** The settingsRevision a settings-backed write was read at: digits only (the API's pattern). */
51
+ export function settingsRevision(raw, name = "--expected-settings-revision") {
52
+ const t = raw.trim();
53
+ if (!SETTINGS_REVISION_RE.test(t))
54
+ throw new Error(`${name} must be the settingsRevision you read (digits only).`);
55
+ return t;
56
+ }
57
+ /** An ISO date (the row's updatedAt as read); the API answers 400 for anything else. */
58
+ export function isoDate(raw, name) {
59
+ const t = raw.trim();
60
+ if (t === "" || Number.isNaN(new Date(t).getTime()))
61
+ throw new Error(`${name} must be the updatedAt you read (an ISO date).`);
62
+ return t;
63
+ }
64
+ export function slug(raw, name) {
65
+ if (!SLUG_RE.test(raw))
66
+ throw new Error(`${name} must be a lowercase machine slug using letters, numbers and underscores (max 80).`);
67
+ return raw;
68
+ }
69
+ export function lifecycleStage(raw, name = "--lifecycle-stage") {
70
+ if (!LIFECYCLE_STAGES.includes(raw))
71
+ throw new Error(`${name} must be one of: ${LIFECYCLE_STAGES.join(", ")}.`);
72
+ return raw;
73
+ }
74
+ /** A comma-separated order list of UUIDs, no duplicates. */
75
+ export function uuidList(raw, name) {
76
+ const ids = raw.split(",").map((s) => s.trim()).filter((s) => s !== "").map((s) => uuid(s, `${name} entry`));
77
+ if (ids.length === 0)
78
+ throw new Error(`${name} needs at least one id.`);
79
+ if (new Set(ids).size !== ids.length)
80
+ throw new Error(`${name} lists an id more than once.`);
81
+ return ids;
82
+ }
83
+ /** The fields of a status metadata row for create / update / get (a drip track is named by id); import names it by dripTrackName instead. */
84
+ export const ROW_FIELD_KEYS = IMPORT_ITEM_KEYS.map((k) => (k === "dripTrackName" ? "dripTrackId" : k));
85
+ /**
86
+ * THE one definition of a status metadata row's fields and types, shared by import items and the create / update bodies:
87
+ * unknown keys are refused naming the key (the API drops them and applies the rest of the call), and each present value is
88
+ * type-checked like the API does. `required`: stageId, code and bucket must be present. `trackKey` is the drip track field.
89
+ */
90
+ function checkRowFields(o, at, allowed, required, trackKey, what) {
91
+ for (const k of Object.keys(o)) {
92
+ if (!allowed.includes(k))
93
+ throw new Error(`${at}: unknown field "${k}". ${what} takes ${allowed.join(", ")}; nothing was sent.`);
94
+ }
95
+ const has = (k) => o[k] !== undefined;
96
+ if (required || has("stageId")) {
97
+ if (typeof o.stageId !== "string" || !isUuid(o.stageId))
98
+ throw new Error(`${at}.stageId must be a UUID.`);
99
+ }
100
+ for (const k of ["code", "bucket"]) {
101
+ if (required || has(k)) {
102
+ if (typeof o[k] !== "string")
103
+ throw new Error(`${at}.${k} must be a slug.`);
104
+ slug(o[k], `${at}.${k}`);
105
+ }
106
+ }
107
+ if (has("lifecycleStage") && o.lifecycleStage !== null) {
108
+ if (typeof o.lifecycleStage !== "string")
109
+ throw new Error(`${at}.lifecycleStage must be one of: ${LIFECYCLE_STAGES.join(", ")}, or null.`);
110
+ lifecycleStage(o.lifecycleStage, `${at}.lifecycleStage`);
111
+ }
112
+ if (has("dripEligible") && typeof o.dripEligible !== "boolean")
113
+ throw new Error(`${at}.dripEligible must be a boolean.`);
114
+ if (has("isTerminal") && typeof o.isTerminal !== "boolean")
115
+ throw new Error(`${at}.isTerminal must be a boolean.`);
116
+ if (has(trackKey) && o[trackKey] !== null) {
117
+ const v = o[trackKey];
118
+ if (typeof v !== "string")
119
+ throw new Error(`${at}.${trackKey} must be ${trackKey === "dripTrackId" ? "a UUID" : "a slug"} or null.`);
120
+ if (trackKey === "dripTrackId") {
121
+ if (!isUuid(v))
122
+ throw new Error(`${at}.dripTrackId must be a UUID or null.`);
123
+ }
124
+ else
125
+ slug(v, `${at}.dripTrackName`);
126
+ }
127
+ if (has("notes") && o.notes !== null && (typeof o.notes !== "string" || o.notes.trim().length > 5000))
128
+ throw new Error(`${at}.notes must be text of at most 5000 characters.`);
129
+ }
130
+ /** One status metadata item, as the import route takes it. */
131
+ export function validateImportItem(raw, index) {
132
+ const at = `items[${index}]`;
133
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
134
+ throw new Error(`${at} must be an object.`);
135
+ checkRowFields(raw, at, IMPORT_ITEM_KEYS, true, "dripTrackName", "An import item");
136
+ return raw;
137
+ }
138
+ /** The COMPLETE body of `status-metadata create` / `update` (flags + --json merged), checked before any request. */
139
+ export function validateStatusMetadataBody(body, mode) {
140
+ const allowed = mode === "update" ? [...ROW_FIELD_KEYS, "expectedUpdatedAt"] : ROW_FIELD_KEYS;
141
+ checkRowFields(body, "body", allowed, mode === "create", "dripTrackId", mode === "create" ? "A status metadata row" : "A status metadata update");
142
+ if (body.expectedUpdatedAt !== undefined) {
143
+ if (typeof body.expectedUpdatedAt !== "string")
144
+ throw new Error("body.expectedUpdatedAt must be the updatedAt you read (an ISO date).");
145
+ isoDate(body.expectedUpdatedAt, "body.expectedUpdatedAt");
146
+ }
147
+ return body;
148
+ }
149
+ export function validateImportItems(raw) {
150
+ if (!Array.isArray(raw))
151
+ throw new Error("items must be a JSON array.");
152
+ if (raw.length > MAX_STATUS_METADATA_IMPORT)
153
+ throw new Error(`items takes at most ${MAX_STATUS_METADATA_IMPORT} entries (got ${raw.length}).`);
154
+ return raw.map((item, i) => validateImportItem(item, i));
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.60.0",
3
+ "version": "1.62.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",