conduyt 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +414 -10
- package/package.json +2 -2
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(
|
|
26
|
+
.version(readVersion());
|
|
11
27
|
// ---- config ----
|
|
12
28
|
const config = program.command("config").description("Manage CLI configuration");
|
|
13
29
|
config
|
|
@@ -47,20 +63,244 @@ contacts
|
|
|
47
63
|
.command("get <id>")
|
|
48
64
|
.description("Get a single contact by id")
|
|
49
65
|
.action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
|
|
66
|
+
contacts
|
|
67
|
+
.command("create")
|
|
68
|
+
.description("Create a contact")
|
|
69
|
+
.option("--first <name>", "first name")
|
|
70
|
+
.option("--last <name>", "last name")
|
|
71
|
+
.option("--email <email>", "email")
|
|
72
|
+
.option("--phone <phone>", "phone")
|
|
73
|
+
.option("--company <company>", "company name (auto-creates/links a company)")
|
|
74
|
+
.option("--json <json>", "full JSON body, merged over the flags above (for any field)")
|
|
75
|
+
.action(run(async (client, opts) => {
|
|
76
|
+
const body = {};
|
|
77
|
+
if (opts.first !== undefined)
|
|
78
|
+
body.firstName = opts.first;
|
|
79
|
+
if (opts.last !== undefined)
|
|
80
|
+
body.lastName = opts.last;
|
|
81
|
+
if (opts.email !== undefined)
|
|
82
|
+
body.email = opts.email;
|
|
83
|
+
if (opts.phone !== undefined)
|
|
84
|
+
body.phone = opts.phone;
|
|
85
|
+
if (opts.company !== undefined)
|
|
86
|
+
body.company = opts.company;
|
|
87
|
+
if (opts.json !== undefined)
|
|
88
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
89
|
+
return client.post("/api/v1/contacts", body);
|
|
90
|
+
}));
|
|
50
91
|
// ---- deals ----
|
|
51
92
|
const deals = program.command("deals").description("Manage deals");
|
|
52
93
|
deals
|
|
53
94
|
.command("list")
|
|
54
95
|
.description("List deals")
|
|
55
|
-
.option("--
|
|
56
|
-
.option("--
|
|
57
|
-
.option("--
|
|
58
|
-
.
|
|
96
|
+
.option("--all", "fetch ALL matching deals across every page — use this for totals/P&L")
|
|
97
|
+
.option("--limit <n>", "results per page, 1-1000 (default 50)")
|
|
98
|
+
.option("--page <n>", "fetch a specific page (an explicit partial page is allowed)")
|
|
99
|
+
.option("--pipeline <id>", "filter by pipeline UUID")
|
|
100
|
+
.option("--pipeline-name <name>", "filter by pipeline name (resolved server-side; errors if not found — never returns the whole account)")
|
|
101
|
+
.option("--stage <id>", "filter by stage UUID")
|
|
102
|
+
.option("--stage-name <name>", "filter by stage name (requires --pipeline or --pipeline-name)")
|
|
103
|
+
.action(run(async (client, opts) => {
|
|
104
|
+
// A provided-but-blank filter must error, not silently become "no filter":
|
|
105
|
+
// buildQuery drops empty strings, so a blank flag would never reach the
|
|
106
|
+
// server to be rejected and would return the whole account. On a CLI a
|
|
107
|
+
// blank flag is always a mistake (an empty $VAR) — there is no "All"
|
|
108
|
+
// selection to express, you just omit the flag — so guard ALL filter
|
|
109
|
+
// flags, ids included.
|
|
110
|
+
for (const [flag, val] of [
|
|
111
|
+
["--pipeline", opts.pipeline],
|
|
112
|
+
["--pipeline-name", opts.pipelineName],
|
|
113
|
+
["--stage", opts.stage],
|
|
114
|
+
["--stage-name", opts.stageName],
|
|
115
|
+
]) {
|
|
116
|
+
if (val !== undefined && val.trim() === "") {
|
|
117
|
+
throw new Error(`${flag} was provided but is blank. Omit it to skip the filter, or pass a real value.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const MAX_PER_PAGE = 1000; // matches the server's per_page cap
|
|
121
|
+
const parsePos = (v, name) => {
|
|
122
|
+
if (v === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
const n = Number(v);
|
|
125
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
126
|
+
throw new Error(`${name} must be a positive integer.`);
|
|
127
|
+
}
|
|
128
|
+
return n;
|
|
129
|
+
};
|
|
130
|
+
const limitN = parsePos(opts.limit, "--limit");
|
|
131
|
+
if (limitN !== undefined && limitN > MAX_PER_PAGE) {
|
|
132
|
+
throw new Error(`--limit cannot exceed ${MAX_PER_PAGE}. Use --all to fetch every page.`);
|
|
133
|
+
}
|
|
134
|
+
const pageN = parsePos(opts.page, "--page");
|
|
135
|
+
if (opts.all && pageN !== undefined) {
|
|
136
|
+
throw new Error("Use either --all or --page, not both.");
|
|
137
|
+
}
|
|
138
|
+
// Each filter routes to the canonical wire param the live contract
|
|
139
|
+
// advertises (deals paginates by per_page/page, NOT limit/cursor). An
|
|
140
|
+
// unknown/blank/conflicting name 400s server-side and surfaces as a CLI
|
|
141
|
+
// error — never a silent whole-account list.
|
|
142
|
+
const filterQ = {
|
|
143
|
+
pipeline: opts.pipeline,
|
|
144
|
+
pipeline_name: opts.pipelineName,
|
|
145
|
+
stage: opts.stage,
|
|
146
|
+
stage_name: opts.stageName,
|
|
147
|
+
};
|
|
148
|
+
// --all: auto-page through EVERY page and PROVE completeness, so totals/P&L
|
|
149
|
+
// are computed on the whole, consistent set — otherwise FAIL CLOSED. We
|
|
150
|
+
// de-dup by deal id (page/offset pagination can repeat a row if data shifts),
|
|
151
|
+
// require the server total to stay stable across pages, and require the final
|
|
152
|
+
// unique count to equal that total. A non-terminating server (ignoring page)
|
|
153
|
+
// hits a hard error, never a silent truncated/duplicated success.
|
|
154
|
+
if (opts.all) {
|
|
155
|
+
// --all must return a CONSISTENT, COMPLETE set for totals/P&L. The deals
|
|
156
|
+
// API paginates by page/offset with no snapshot/cursor, so stitching
|
|
157
|
+
// multiple pages over live data can silently mix snapshots (a deal leaves
|
|
158
|
+
// the filter as another enters, total unchanged). To avoid ANY such
|
|
159
|
+
// inconsistency we fetch in ONE atomic max-size page and FAIL CLOSED if
|
|
160
|
+
// the result does not fit — never assembling a multi-page set. (≤1000
|
|
161
|
+
// deals — typical pipelines, incl. this workflow — fetch fine.)
|
|
162
|
+
if (limitN !== undefined) {
|
|
163
|
+
throw new Error("--all controls paging itself; do not combine it with --limit.");
|
|
164
|
+
}
|
|
165
|
+
const res = (await client.get(`/api/v1/deals${buildQuery({ ...filterQ, per_page: String(MAX_PER_PAGE), page: "1" })}`));
|
|
166
|
+
const rows = res?.data?.data;
|
|
167
|
+
const total = res?.data?.meta?.total;
|
|
168
|
+
if (!Array.isArray(rows)) {
|
|
169
|
+
throw new Error("--all: unexpected response (data.data is not an array). Aborting.");
|
|
170
|
+
}
|
|
171
|
+
if (typeof total !== "number" || !Number.isInteger(total) || total < 0) {
|
|
172
|
+
throw new Error("--all cannot verify completeness: the deals endpoint returned no numeric meta.total. " +
|
|
173
|
+
"Refusing to emit a possibly-partial set.");
|
|
174
|
+
}
|
|
175
|
+
if (total > MAX_PER_PAGE) {
|
|
176
|
+
throw new Error(`--all: this filter matches ${total} deals — more than the ${MAX_PER_PAGE} that fit in one ` +
|
|
177
|
+
"consistent page. The deals API has no snapshot/cursor pagination, so a multi-page total " +
|
|
178
|
+
"could be inconsistent. Narrow the filter (e.g. --stage-name) or fetch specific --page ranges.");
|
|
179
|
+
}
|
|
180
|
+
// Validate ids; a REPEATED id means an internally-inconsistent response
|
|
181
|
+
// (two rows for one deal, possibly with conflicting amounts) — fail rather
|
|
182
|
+
// than let a Map overwrite hide it. Require row count AND unique count to
|
|
183
|
+
// both equal the total, so nothing is duplicated, dropped, or arbitrary.
|
|
184
|
+
const byId = new Map();
|
|
185
|
+
for (const d of rows) {
|
|
186
|
+
const id = d?.id;
|
|
187
|
+
if (typeof id !== "string" || id.trim() === "") {
|
|
188
|
+
throw new Error("--all: a deal row has a missing/invalid id — cannot de-duplicate safely. Aborting.");
|
|
189
|
+
}
|
|
190
|
+
if (byId.has(id)) {
|
|
191
|
+
throw new Error(`--all: the response contains a duplicate deal id (${id}) — inconsistent. Aborting.`);
|
|
192
|
+
}
|
|
193
|
+
byId.set(id, d);
|
|
194
|
+
}
|
|
195
|
+
if (rows.length !== total || byId.size !== total) {
|
|
196
|
+
throw new Error(`--all: page returned ${rows.length} rows (${byId.size} unique) but total=${total} — inconsistent. ` +
|
|
197
|
+
"Aborting — a partial or duplicated set would produce wrong totals/P&L.");
|
|
198
|
+
}
|
|
199
|
+
return { data: { data: [...byId.values()], meta: { total, fetched: byId.size } } };
|
|
200
|
+
}
|
|
201
|
+
// Single page.
|
|
202
|
+
const res = (await client.get(`/api/v1/deals${buildQuery({ ...filterQ, per_page: opts.limit, page: opts.page })}`));
|
|
203
|
+
const rows = res?.data?.data;
|
|
204
|
+
const total = res?.data?.meta?.total;
|
|
205
|
+
// HARD-FAIL on IMPLICIT truncation: if the caller did NOT request a specific
|
|
206
|
+
// page and this page is incomplete, exit non-zero instead of emitting a
|
|
207
|
+
// partial list that a script (piping stdout to jq, ignoring stderr) could
|
|
208
|
+
// compute a wrong P&L from. An explicit --page is an intentional partial.
|
|
209
|
+
if (pageN === undefined &&
|
|
210
|
+
Array.isArray(rows) &&
|
|
211
|
+
typeof total === "number" &&
|
|
212
|
+
rows.length < total) {
|
|
213
|
+
throw new Error(`Result truncated: page 1 has ${rows.length} of ${total} deals. ` +
|
|
214
|
+
`Use --all to fetch every deal (required for correct totals/P&L), ` +
|
|
215
|
+
`or --page <n> for a specific page.`);
|
|
216
|
+
}
|
|
217
|
+
return res;
|
|
218
|
+
}));
|
|
219
|
+
deals
|
|
220
|
+
.command("create")
|
|
221
|
+
.description("Create a deal (use `pipelines list` to get pipeline/stage ids)")
|
|
222
|
+
.option("--title <title>", "deal title")
|
|
223
|
+
.option("--pipeline <id>", "pipeline UUID")
|
|
224
|
+
.option("--stage <id>", "stage UUID")
|
|
225
|
+
.option("--value <n>", "deal value (number)")
|
|
226
|
+
.option("--currency <code>", "ISO currency, e.g. GBP")
|
|
227
|
+
.option("--contact <id>", "contact UUID to link")
|
|
228
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
229
|
+
.action(run(async (client, opts) => {
|
|
230
|
+
const body = {};
|
|
231
|
+
if (opts.title !== undefined)
|
|
232
|
+
body.title = opts.title;
|
|
233
|
+
if (opts.pipeline !== undefined)
|
|
234
|
+
body.pipelineId = opts.pipeline;
|
|
235
|
+
if (opts.stage !== undefined)
|
|
236
|
+
body.stageId = opts.stage;
|
|
237
|
+
if (opts.value !== undefined) {
|
|
238
|
+
const trimmed = opts.value.trim();
|
|
239
|
+
const n = Number(trimmed);
|
|
240
|
+
// Reject blank/whitespace (Number("") is 0) and non-finite values
|
|
241
|
+
// (Infinity/overflow JSON-serialize to null) so we never POST a wrong
|
|
242
|
+
// monetary amount instead of failing.
|
|
243
|
+
if (trimmed === "" || !Number.isFinite(n)) {
|
|
244
|
+
throw new Error("--value must be a finite number.");
|
|
245
|
+
}
|
|
246
|
+
body.value = n;
|
|
247
|
+
}
|
|
248
|
+
if (opts.currency !== undefined)
|
|
249
|
+
body.currency = opts.currency;
|
|
250
|
+
if (opts.contact !== undefined)
|
|
251
|
+
body.contactId = opts.contact;
|
|
252
|
+
if (opts.json !== undefined)
|
|
253
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
254
|
+
assertFiniteValue(body.value);
|
|
255
|
+
return client.post("/api/v1/deals", body);
|
|
256
|
+
}));
|
|
59
257
|
// ---- pipelines ----
|
|
60
|
-
|
|
258
|
+
// Default action preserves the original published `conduyt pipelines` (list)
|
|
259
|
+
// invocation now that pipelines is a group — without it, commander prints help
|
|
260
|
+
// instead of listing and breaks existing users/scripts. `pipelines list` is an
|
|
261
|
+
// explicit alias for the same GET.
|
|
262
|
+
const pipelines = program
|
|
61
263
|
.command("pipelines")
|
|
264
|
+
.description("Manage pipelines")
|
|
265
|
+
.action(run(async (client) => client.get("/api/v1/pipelines")));
|
|
266
|
+
pipelines
|
|
267
|
+
.command("list")
|
|
62
268
|
.description("List pipelines and their stages")
|
|
63
269
|
.action(run(async (client) => client.get("/api/v1/pipelines")));
|
|
270
|
+
pipelines
|
|
271
|
+
.command("create")
|
|
272
|
+
.description("Create a pipeline with stages")
|
|
273
|
+
.option("--name <name>", "pipeline name")
|
|
274
|
+
.option("--stages <json>", "JSON array of stages, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'")
|
|
275
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
276
|
+
.action(run(async (client, opts) => {
|
|
277
|
+
const body = {};
|
|
278
|
+
if (opts.name !== undefined)
|
|
279
|
+
body.name = opts.name;
|
|
280
|
+
if (opts.stages !== undefined) {
|
|
281
|
+
if (opts.stages.trim() === "")
|
|
282
|
+
throw new Error("--stages must be valid JSON (got an empty string).");
|
|
283
|
+
let st;
|
|
284
|
+
try {
|
|
285
|
+
st = JSON.parse(opts.stages);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
throw new Error("--stages must be valid JSON.");
|
|
289
|
+
}
|
|
290
|
+
if (!Array.isArray(st))
|
|
291
|
+
throw new Error("--stages must be a JSON array of stage objects.");
|
|
292
|
+
if (st.length === 0)
|
|
293
|
+
throw new Error("--stages must be a non-empty array of stage objects.");
|
|
294
|
+
if (st.some((s) => s === null || typeof s !== "object" || Array.isArray(s))) {
|
|
295
|
+
throw new Error("--stages must be an array of stage objects, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'.");
|
|
296
|
+
}
|
|
297
|
+
body.stages = st;
|
|
298
|
+
}
|
|
299
|
+
if (opts.json !== undefined)
|
|
300
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
301
|
+
assertStagesShape(body.stages);
|
|
302
|
+
return client.post("/api/v1/pipelines", body);
|
|
303
|
+
}));
|
|
64
304
|
// ---- search ----
|
|
65
305
|
program
|
|
66
306
|
.command("search <query>")
|
|
@@ -84,25 +324,189 @@ program
|
|
|
84
324
|
}
|
|
85
325
|
return client.post("/api/v1/ai/insights", { type, ...extra });
|
|
86
326
|
}));
|
|
327
|
+
// ---- users (team members) ----
|
|
328
|
+
const users = program.command("users").description("Manage team members");
|
|
329
|
+
users
|
|
330
|
+
.command("list")
|
|
331
|
+
.description("List team members")
|
|
332
|
+
.action(run(async (client) => client.get("/api/v1/users")));
|
|
333
|
+
users
|
|
334
|
+
.command("create")
|
|
335
|
+
.description("Create a team member directly (no email invite)")
|
|
336
|
+
.option("--first <name>", "first name")
|
|
337
|
+
.option("--last <name>", "last name")
|
|
338
|
+
.option("--email <email>", "email")
|
|
339
|
+
.option("--role <role>", "role, e.g. member, admin")
|
|
340
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
341
|
+
.action(run(async (client, opts) => {
|
|
342
|
+
const body = {};
|
|
343
|
+
if (opts.first !== undefined)
|
|
344
|
+
body.firstName = opts.first;
|
|
345
|
+
if (opts.last !== undefined)
|
|
346
|
+
body.lastName = opts.last;
|
|
347
|
+
if (opts.email !== undefined)
|
|
348
|
+
body.email = opts.email;
|
|
349
|
+
if (opts.role !== undefined)
|
|
350
|
+
body.role = opts.role;
|
|
351
|
+
if (opts.json !== undefined)
|
|
352
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
353
|
+
return client.post("/api/v1/users", body);
|
|
354
|
+
}));
|
|
355
|
+
users
|
|
356
|
+
.command("invite")
|
|
357
|
+
.description("Invite a team member by email")
|
|
358
|
+
.option("--email <email>", "email to invite")
|
|
359
|
+
.option("--role <role>", "role, e.g. member, admin")
|
|
360
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
361
|
+
.action(run(async (client, opts) => {
|
|
362
|
+
const body = {};
|
|
363
|
+
if (opts.email !== undefined)
|
|
364
|
+
body.email = opts.email;
|
|
365
|
+
if (opts.role !== undefined)
|
|
366
|
+
body.role = opts.role;
|
|
367
|
+
if (opts.json !== undefined)
|
|
368
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
369
|
+
return client.post("/api/v1/users/invite", body);
|
|
370
|
+
}));
|
|
371
|
+
// ---- custom fields ----
|
|
372
|
+
const customFields = program.command("custom-fields").description("Manage custom fields");
|
|
373
|
+
customFields
|
|
374
|
+
.command("create")
|
|
375
|
+
.description("Define a custom field on contacts/deals/companies")
|
|
376
|
+
.option("--entity <type>", "entityType: contact | deal | company")
|
|
377
|
+
.option("--key <fieldKey>", "field key (machine name)")
|
|
378
|
+
.option("--label <label>", "display label")
|
|
379
|
+
.option("--type <fieldType>", "fieldType: text|textarea|number|date|datetime|url|select|radio|multiselect|boolean|phone|email")
|
|
380
|
+
.option("--options <json>", "JSON array of options (for select/radio/multiselect)")
|
|
381
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
382
|
+
.action(run(async (client, opts) => {
|
|
383
|
+
const body = {};
|
|
384
|
+
if (opts.entity !== undefined)
|
|
385
|
+
body.entityType = opts.entity;
|
|
386
|
+
if (opts.key !== undefined)
|
|
387
|
+
body.fieldKey = opts.key;
|
|
388
|
+
if (opts.label !== undefined)
|
|
389
|
+
body.label = opts.label;
|
|
390
|
+
if (opts.type !== undefined)
|
|
391
|
+
body.fieldType = opts.type;
|
|
392
|
+
if (opts.options !== undefined) {
|
|
393
|
+
if (opts.options.trim() === "")
|
|
394
|
+
throw new Error("--options must be valid JSON (got an empty string).");
|
|
395
|
+
let o;
|
|
396
|
+
try {
|
|
397
|
+
o = JSON.parse(opts.options);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
throw new Error("--options must be valid JSON.");
|
|
401
|
+
}
|
|
402
|
+
if (!Array.isArray(o)) {
|
|
403
|
+
throw new Error("--options must be a JSON array, e.g. '[\"Low\",\"High\"]' or '[{\"label\":\"Low\",\"value\":\"low\"}]'.");
|
|
404
|
+
}
|
|
405
|
+
if (o.length === 0)
|
|
406
|
+
throw new Error("--options must be a non-empty array.");
|
|
407
|
+
body.options = o;
|
|
408
|
+
}
|
|
409
|
+
if (opts.json !== undefined)
|
|
410
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
411
|
+
assertOptionsShape(body.options);
|
|
412
|
+
return client.post("/api/v1/custom-fields", body);
|
|
413
|
+
}));
|
|
87
414
|
// ---- raw escape hatch ----
|
|
88
415
|
program
|
|
89
416
|
.command("api <method> <path>")
|
|
90
|
-
.description("Make a raw authenticated API request (escape hatch)")
|
|
417
|
+
.description("Make a raw authenticated API request (escape hatch). Path may be '/users', 'users', or '/api/v1/users' — it is routed to the API base, not the website root.")
|
|
91
418
|
.option("--body <json>", "JSON request body")
|
|
92
419
|
.action(run(async (client, method, path, opts) => {
|
|
93
420
|
let body;
|
|
94
|
-
if (opts.body) {
|
|
421
|
+
if (opts.body !== undefined) {
|
|
422
|
+
// Fail closed: an empty/whitespace --body (e.g. a script expanding an
|
|
423
|
+
// unset var) must error, not silently send a payload-less mutating POST.
|
|
424
|
+
if (opts.body.trim() === "") {
|
|
425
|
+
throw new Error("--body must be valid JSON (got an empty string).");
|
|
426
|
+
}
|
|
95
427
|
try {
|
|
96
428
|
body = JSON.parse(opts.body);
|
|
97
429
|
}
|
|
98
430
|
catch {
|
|
99
|
-
|
|
431
|
+
throw new Error("--body must be valid JSON.");
|
|
432
|
+
}
|
|
433
|
+
if (body !== null && typeof body === "object") {
|
|
434
|
+
rejectProtoPollution(body, "--body");
|
|
100
435
|
}
|
|
101
436
|
}
|
|
102
|
-
|
|
437
|
+
// Normalize to an API path so the escape hatch hits the API, not the site
|
|
438
|
+
// root (the base URL is the bare host). Accept already-prefixed paths.
|
|
439
|
+
let p = path.startsWith("/") ? path : `/${path}`;
|
|
440
|
+
if (!/^\/api\//.test(p))
|
|
441
|
+
p = `/api/v1${p}`;
|
|
442
|
+
return client.request(method.toUpperCase(), p, body);
|
|
103
443
|
}));
|
|
104
444
|
program.parseAsync(process.argv).catch(fail);
|
|
105
445
|
// helpers
|
|
446
|
+
// Parse a --json / --stages style argument, throwing a clear CLI error (caught
|
|
447
|
+
// by run() -> non-zero exit) rather than a stack trace on bad JSON.
|
|
448
|
+
// Final-payload validators for CLI-owned shallow invariants. Run AFTER the
|
|
449
|
+
// --json merge so a value supplied (or overridden) via --json is held to the
|
|
450
|
+
// same rule as the dedicated flag — the escape valve can't smuggle a
|
|
451
|
+
// known-invalid shape past validation to a mutating POST. The SERVER remains
|
|
452
|
+
// the authority on deep field schemas; these only catch obviously-malformed
|
|
453
|
+
// shapes for a fast, clear client-side failure.
|
|
454
|
+
function assertStagesShape(v) {
|
|
455
|
+
if (v === undefined)
|
|
456
|
+
return;
|
|
457
|
+
if (!Array.isArray(v) ||
|
|
458
|
+
v.length === 0 ||
|
|
459
|
+
v.some((s) => s === null || typeof s !== "object" || Array.isArray(s))) {
|
|
460
|
+
throw new Error("stages must be a non-empty array of stage objects, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'.");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
function assertOptionsShape(v) {
|
|
464
|
+
if (v === undefined)
|
|
465
|
+
return;
|
|
466
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
467
|
+
throw new Error("options must be a non-empty JSON array, e.g. '[\"Low\",\"High\"]'.");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function assertFiniteValue(v) {
|
|
471
|
+
if (v === undefined)
|
|
472
|
+
return;
|
|
473
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
474
|
+
throw new Error("value must be a finite number.");
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Reject prototype-polluting keys in untrusted JSON. JSON.parse creates an OWN
|
|
478
|
+
// "__proto__" property, but Object.assign'ing it onto a {} body invokes the
|
|
479
|
+
// setter and changes the body's PROTOTYPE — so a validator could read an
|
|
480
|
+
// inherited value while JSON.stringify only serializes own props, desyncing
|
|
481
|
+
// validation from what is actually POSTed. getOwnPropertyNames sees the parsed
|
|
482
|
+
// own keys (including __proto__) regardless of the prototype chain.
|
|
483
|
+
function rejectProtoPollution(obj, flag) {
|
|
484
|
+
for (const k of Object.getOwnPropertyNames(obj)) {
|
|
485
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") {
|
|
486
|
+
throw new Error(`${flag} may not contain a "${k}" key.`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function jsonArg(raw, flag) {
|
|
491
|
+
if (raw.trim() === "") {
|
|
492
|
+
throw new Error(`${flag} must be valid JSON (got an empty string).`);
|
|
493
|
+
}
|
|
494
|
+
let v;
|
|
495
|
+
try {
|
|
496
|
+
v = JSON.parse(raw);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
throw new Error(`${flag} must be valid JSON.`);
|
|
500
|
+
}
|
|
501
|
+
// Merge-style flags are Object.assign'd onto the request body, so an array or
|
|
502
|
+
// primitive would silently reshape/empty it (e.g. [] -> {}, [{x:1}] -> numeric
|
|
503
|
+
// keys). Require a plain JSON object. Raw array bodies go through `api`.
|
|
504
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
505
|
+
throw new Error(`${flag} must be a JSON object (not an array or primitive).`);
|
|
506
|
+
}
|
|
507
|
+
rejectProtoPollution(v, flag);
|
|
508
|
+
return v;
|
|
509
|
+
}
|
|
106
510
|
function run(handler) {
|
|
107
511
|
return async (...args) => {
|
|
108
512
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conduyt",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Command-line interface for Conduyt CRM
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Command-line interface for Conduyt CRM \u2014 manage contacts, deals, pipelines, and run insight queries from your terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|