conduyt 1.0.0 → 1.1.1

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.
Files changed (2) hide show
  1. package/dist/index.js +140 -5
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,13 +1,29 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
2
5
  import { Command } from "commander";
3
6
  import { ConduytClient, buildQuery } from "./client.js";
4
7
  import { saveConfig, resolveConfig, configPath } from "./config.js";
5
8
  import { print, fail } from "./output.js";
9
+ // Derive the version from package.json at runtime so `--version` can never
10
+ // drift from the published package (dist/index.js -> ../package.json). Read at
11
+ // top level runs on every invocation, so fall back gracefully rather than
12
+ // crashing the whole CLI if the file is somehow unreadable.
13
+ function readVersion() {
14
+ try {
15
+ const raw = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8");
16
+ return JSON.parse(raw).version ?? "0.0.0";
17
+ }
18
+ catch {
19
+ return "0.0.0";
20
+ }
21
+ }
6
22
  const program = new Command();
7
23
  program
8
24
  .name("conduyt")
9
25
  .description("Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.")
10
- .version("1.0.0");
26
+ .version(readVersion());
11
27
  // ---- config ----
12
28
  const config = program.command("config").description("Manage CLI configuration");
13
29
  config
@@ -52,10 +68,129 @@ const deals = program.command("deals").description("Manage deals");
52
68
  deals
53
69
  .command("list")
54
70
  .description("List deals")
55
- .option("--limit <n>", "max results")
56
- .option("--cursor <cursor>", "pagination cursor")
57
- .option("--pipeline <id>", "filter by pipeline id")
58
- .action(run(async (client, opts) => client.get(`/api/v1/deals${buildQuery({ limit: opts.limit, cursor: opts.cursor, pipelineId: opts.pipeline })}`)));
71
+ .option("--all", "fetch ALL matching deals across every page — use this for totals/P&L")
72
+ .option("--limit <n>", "results per page, 1-1000 (default 50)")
73
+ .option("--page <n>", "fetch a specific page (an explicit partial page is allowed)")
74
+ .option("--pipeline <id>", "filter by pipeline UUID")
75
+ .option("--pipeline-name <name>", "filter by pipeline name (resolved server-side; errors if not found — never returns the whole account)")
76
+ .option("--stage <id>", "filter by stage UUID")
77
+ .option("--stage-name <name>", "filter by stage name (requires --pipeline or --pipeline-name)")
78
+ .action(run(async (client, opts) => {
79
+ // A provided-but-blank filter must error, not silently become "no filter":
80
+ // buildQuery drops empty strings, so a blank flag would never reach the
81
+ // server to be rejected and would return the whole account. On a CLI a
82
+ // blank flag is always a mistake (an empty $VAR) — there is no "All"
83
+ // selection to express, you just omit the flag — so guard ALL filter
84
+ // flags, ids included.
85
+ for (const [flag, val] of [
86
+ ["--pipeline", opts.pipeline],
87
+ ["--pipeline-name", opts.pipelineName],
88
+ ["--stage", opts.stage],
89
+ ["--stage-name", opts.stageName],
90
+ ]) {
91
+ if (val !== undefined && val.trim() === "") {
92
+ throw new Error(`${flag} was provided but is blank. Omit it to skip the filter, or pass a real value.`);
93
+ }
94
+ }
95
+ const MAX_PER_PAGE = 1000; // matches the server's per_page cap
96
+ const parsePos = (v, name) => {
97
+ if (v === undefined)
98
+ return undefined;
99
+ const n = Number(v);
100
+ if (!Number.isInteger(n) || n < 1) {
101
+ throw new Error(`${name} must be a positive integer.`);
102
+ }
103
+ return n;
104
+ };
105
+ const limitN = parsePos(opts.limit, "--limit");
106
+ if (limitN !== undefined && limitN > MAX_PER_PAGE) {
107
+ throw new Error(`--limit cannot exceed ${MAX_PER_PAGE}. Use --all to fetch every page.`);
108
+ }
109
+ const pageN = parsePos(opts.page, "--page");
110
+ if (opts.all && pageN !== undefined) {
111
+ throw new Error("Use either --all or --page, not both.");
112
+ }
113
+ // Each filter routes to the canonical wire param the live contract
114
+ // advertises (deals paginates by per_page/page, NOT limit/cursor). An
115
+ // unknown/blank/conflicting name 400s server-side and surfaces as a CLI
116
+ // error — never a silent whole-account list.
117
+ const filterQ = {
118
+ pipeline: opts.pipeline,
119
+ pipeline_name: opts.pipelineName,
120
+ stage: opts.stage,
121
+ stage_name: opts.stageName,
122
+ };
123
+ // --all: auto-page through EVERY page and PROVE completeness, so totals/P&L
124
+ // are computed on the whole, consistent set — otherwise FAIL CLOSED. We
125
+ // de-dup by deal id (page/offset pagination can repeat a row if data shifts),
126
+ // require the server total to stay stable across pages, and require the final
127
+ // unique count to equal that total. A non-terminating server (ignoring page)
128
+ // hits a hard error, never a silent truncated/duplicated success.
129
+ if (opts.all) {
130
+ // --all must return a CONSISTENT, COMPLETE set for totals/P&L. The deals
131
+ // API paginates by page/offset with no snapshot/cursor, so stitching
132
+ // multiple pages over live data can silently mix snapshots (a deal leaves
133
+ // the filter as another enters, total unchanged). To avoid ANY such
134
+ // inconsistency we fetch in ONE atomic max-size page and FAIL CLOSED if
135
+ // the result does not fit — never assembling a multi-page set. (≤1000
136
+ // deals — typical pipelines, incl. this workflow — fetch fine.)
137
+ if (limitN !== undefined) {
138
+ throw new Error("--all controls paging itself; do not combine it with --limit.");
139
+ }
140
+ const res = (await client.get(`/api/v1/deals${buildQuery({ ...filterQ, per_page: String(MAX_PER_PAGE), page: "1" })}`));
141
+ const rows = res?.data?.data;
142
+ const total = res?.data?.meta?.total;
143
+ if (!Array.isArray(rows)) {
144
+ throw new Error("--all: unexpected response (data.data is not an array). Aborting.");
145
+ }
146
+ if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
147
+ throw new Error("--all cannot verify completeness: the deals endpoint returned no numeric meta.total. " +
148
+ "Refusing to emit a possibly-partial set.");
149
+ }
150
+ if (total > MAX_PER_PAGE) {
151
+ throw new Error(`--all: this filter matches ${total} deals — more than the ${MAX_PER_PAGE} that fit in one ` +
152
+ "consistent page. The deals API has no snapshot/cursor pagination, so a multi-page total " +
153
+ "could be inconsistent. Narrow the filter (e.g. --stage-name) or fetch specific --page ranges.");
154
+ }
155
+ // Validate ids; a REPEATED id means an internally-inconsistent response
156
+ // (two rows for one deal, possibly with conflicting amounts) — fail rather
157
+ // than let a Map overwrite hide it. Require row count AND unique count to
158
+ // both equal the total, so nothing is duplicated, dropped, or arbitrary.
159
+ const byId = new Map();
160
+ for (const d of rows) {
161
+ const id = d?.id;
162
+ if (typeof id !== "string" || id.trim() === "") {
163
+ throw new Error("--all: a deal row has a missing/invalid id — cannot de-duplicate safely. Aborting.");
164
+ }
165
+ if (byId.has(id)) {
166
+ throw new Error(`--all: the response contains a duplicate deal id (${id}) — inconsistent. Aborting.`);
167
+ }
168
+ byId.set(id, d);
169
+ }
170
+ if (rows.length !== total || byId.size !== total) {
171
+ throw new Error(`--all: page returned ${rows.length} rows (${byId.size} unique) but total=${total} — inconsistent. ` +
172
+ "Aborting — a partial or duplicated set would produce wrong totals/P&L.");
173
+ }
174
+ return { data: { data: [...byId.values()], meta: { total, fetched: byId.size } } };
175
+ }
176
+ // Single page.
177
+ const res = (await client.get(`/api/v1/deals${buildQuery({ ...filterQ, per_page: opts.limit, page: opts.page })}`));
178
+ const rows = res?.data?.data;
179
+ const total = res?.data?.meta?.total;
180
+ // HARD-FAIL on IMPLICIT truncation: if the caller did NOT request a specific
181
+ // page and this page is incomplete, exit non-zero instead of emitting a
182
+ // partial list that a script (piping stdout to jq, ignoring stderr) could
183
+ // compute a wrong P&L from. An explicit --page is an intentional partial.
184
+ if (pageN === undefined &&
185
+ Array.isArray(rows) &&
186
+ typeof total === "number" &&
187
+ rows.length < total) {
188
+ throw new Error(`Result truncated: page 1 has ${rows.length} of ${total} deals. ` +
189
+ `Use --all to fetch every deal (required for correct totals/P&L), ` +
190
+ `or --page <n> for a specific page.`);
191
+ }
192
+ return res;
193
+ }));
59
194
  // ---- pipelines ----
60
195
  program
61
196
  .command("pipelines")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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",