conduyt 1.1.1 → 1.2.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.
- package/dist/index.js +285 -5
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -6,6 +6,17 @@ import { Command } from "commander";
|
|
|
6
6
|
import { ConduytClient, buildQuery } from "./client.js";
|
|
7
7
|
import { saveConfig, resolveConfig, configPath } from "./config.js";
|
|
8
8
|
import { print, fail } from "./output.js";
|
|
9
|
+
// Exit cleanly when a downstream pipe closes early (e.g. `conduyt deals list |
|
|
10
|
+
// head`). Without this, the stdout write after the reader is gone throws an
|
|
11
|
+
// unhandled EPIPE and prints a Node stack trace — broken-pipe is normal Unix
|
|
12
|
+
// behavior, not an error. Mirror it on stderr too.
|
|
13
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
14
|
+
stream.on("error", (err) => {
|
|
15
|
+
if (err.code === "EPIPE")
|
|
16
|
+
process.exit(0);
|
|
17
|
+
throw err;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
9
20
|
// Derive the version from package.json at runtime so `--version` can never
|
|
10
21
|
// drift from the published package (dist/index.js -> ../package.json). Read at
|
|
11
22
|
// top level runs on every invocation, so fall back gracefully rather than
|
|
@@ -63,6 +74,31 @@ contacts
|
|
|
63
74
|
.command("get <id>")
|
|
64
75
|
.description("Get a single contact by id")
|
|
65
76
|
.action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
|
|
77
|
+
contacts
|
|
78
|
+
.command("create")
|
|
79
|
+
.description("Create a contact")
|
|
80
|
+
.option("--first <name>", "first name")
|
|
81
|
+
.option("--last <name>", "last name")
|
|
82
|
+
.option("--email <email>", "email")
|
|
83
|
+
.option("--phone <phone>", "phone")
|
|
84
|
+
.option("--company <company>", "company name (auto-creates/links a company)")
|
|
85
|
+
.option("--json <json>", "full JSON body, merged over the flags above (for any field)")
|
|
86
|
+
.action(run(async (client, opts) => {
|
|
87
|
+
const body = {};
|
|
88
|
+
if (opts.first !== undefined)
|
|
89
|
+
body.firstName = opts.first;
|
|
90
|
+
if (opts.last !== undefined)
|
|
91
|
+
body.lastName = opts.last;
|
|
92
|
+
if (opts.email !== undefined)
|
|
93
|
+
body.email = opts.email;
|
|
94
|
+
if (opts.phone !== undefined)
|
|
95
|
+
body.phone = opts.phone;
|
|
96
|
+
if (opts.company !== undefined)
|
|
97
|
+
body.company = opts.company;
|
|
98
|
+
if (opts.json !== undefined)
|
|
99
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
100
|
+
return client.post("/api/v1/contacts", body);
|
|
101
|
+
}));
|
|
66
102
|
// ---- deals ----
|
|
67
103
|
const deals = program.command("deals").description("Manage deals");
|
|
68
104
|
deals
|
|
@@ -191,11 +227,91 @@ deals
|
|
|
191
227
|
}
|
|
192
228
|
return res;
|
|
193
229
|
}));
|
|
230
|
+
deals
|
|
231
|
+
.command("create")
|
|
232
|
+
.description("Create a deal (use `pipelines list` to get pipeline/stage ids)")
|
|
233
|
+
.option("--title <title>", "deal title")
|
|
234
|
+
.option("--pipeline <id>", "pipeline UUID")
|
|
235
|
+
.option("--stage <id>", "stage UUID")
|
|
236
|
+
.option("--value <n>", "deal value (number)")
|
|
237
|
+
.option("--currency <code>", "ISO currency, e.g. GBP")
|
|
238
|
+
.option("--contact <id>", "contact UUID to link")
|
|
239
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
240
|
+
.action(run(async (client, opts) => {
|
|
241
|
+
const body = {};
|
|
242
|
+
if (opts.title !== undefined)
|
|
243
|
+
body.title = opts.title;
|
|
244
|
+
if (opts.pipeline !== undefined)
|
|
245
|
+
body.pipelineId = opts.pipeline;
|
|
246
|
+
if (opts.stage !== undefined)
|
|
247
|
+
body.stageId = opts.stage;
|
|
248
|
+
if (opts.value !== undefined) {
|
|
249
|
+
const trimmed = opts.value.trim();
|
|
250
|
+
const n = Number(trimmed);
|
|
251
|
+
// Reject blank/whitespace (Number("") is 0) and non-finite values
|
|
252
|
+
// (Infinity/overflow JSON-serialize to null) so we never POST a wrong
|
|
253
|
+
// monetary amount instead of failing.
|
|
254
|
+
if (trimmed === "" || !Number.isFinite(n)) {
|
|
255
|
+
throw new Error("--value must be a finite number.");
|
|
256
|
+
}
|
|
257
|
+
body.value = n;
|
|
258
|
+
}
|
|
259
|
+
if (opts.currency !== undefined)
|
|
260
|
+
body.currency = opts.currency;
|
|
261
|
+
if (opts.contact !== undefined)
|
|
262
|
+
body.contactId = opts.contact;
|
|
263
|
+
if (opts.json !== undefined)
|
|
264
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
265
|
+
assertFiniteValue(body.value);
|
|
266
|
+
return client.post("/api/v1/deals", body);
|
|
267
|
+
}));
|
|
194
268
|
// ---- pipelines ----
|
|
195
|
-
|
|
269
|
+
// Default action preserves the original published `conduyt pipelines` (list)
|
|
270
|
+
// invocation now that pipelines is a group — without it, commander prints help
|
|
271
|
+
// instead of listing and breaks existing users/scripts. `pipelines list` is an
|
|
272
|
+
// explicit alias for the same GET.
|
|
273
|
+
const pipelines = program
|
|
196
274
|
.command("pipelines")
|
|
275
|
+
.description("Manage pipelines")
|
|
276
|
+
.action(run(async (client) => client.get("/api/v1/pipelines")));
|
|
277
|
+
pipelines
|
|
278
|
+
.command("list")
|
|
197
279
|
.description("List pipelines and their stages")
|
|
198
280
|
.action(run(async (client) => client.get("/api/v1/pipelines")));
|
|
281
|
+
pipelines
|
|
282
|
+
.command("create")
|
|
283
|
+
.description("Create a pipeline with stages")
|
|
284
|
+
.option("--name <name>", "pipeline name")
|
|
285
|
+
.option("--stages <json>", "JSON array of stages, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'")
|
|
286
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
287
|
+
.action(run(async (client, opts) => {
|
|
288
|
+
const body = {};
|
|
289
|
+
if (opts.name !== undefined)
|
|
290
|
+
body.name = opts.name;
|
|
291
|
+
if (opts.stages !== undefined) {
|
|
292
|
+
if (opts.stages.trim() === "")
|
|
293
|
+
throw new Error("--stages must be valid JSON (got an empty string).");
|
|
294
|
+
let st;
|
|
295
|
+
try {
|
|
296
|
+
st = JSON.parse(opts.stages);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
throw new Error("--stages must be valid JSON.");
|
|
300
|
+
}
|
|
301
|
+
if (!Array.isArray(st))
|
|
302
|
+
throw new Error("--stages must be a JSON array of stage objects.");
|
|
303
|
+
if (st.length === 0)
|
|
304
|
+
throw new Error("--stages must be a non-empty array of stage objects.");
|
|
305
|
+
if (st.some((s) => s === null || typeof s !== "object" || Array.isArray(s))) {
|
|
306
|
+
throw new Error("--stages must be an array of stage objects, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'.");
|
|
307
|
+
}
|
|
308
|
+
body.stages = st;
|
|
309
|
+
}
|
|
310
|
+
if (opts.json !== undefined)
|
|
311
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
312
|
+
assertStagesShape(body.stages);
|
|
313
|
+
return client.post("/api/v1/pipelines", body);
|
|
314
|
+
}));
|
|
199
315
|
// ---- search ----
|
|
200
316
|
program
|
|
201
317
|
.command("search <query>")
|
|
@@ -219,25 +335,189 @@ program
|
|
|
219
335
|
}
|
|
220
336
|
return client.post("/api/v1/ai/insights", { type, ...extra });
|
|
221
337
|
}));
|
|
338
|
+
// ---- users (team members) ----
|
|
339
|
+
const users = program.command("users").description("Manage team members");
|
|
340
|
+
users
|
|
341
|
+
.command("list")
|
|
342
|
+
.description("List team members")
|
|
343
|
+
.action(run(async (client) => client.get("/api/v1/users")));
|
|
344
|
+
users
|
|
345
|
+
.command("create")
|
|
346
|
+
.description("Create a team member directly (no email invite)")
|
|
347
|
+
.option("--first <name>", "first name")
|
|
348
|
+
.option("--last <name>", "last name")
|
|
349
|
+
.option("--email <email>", "email")
|
|
350
|
+
.option("--role <role>", "role, e.g. member, admin")
|
|
351
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
352
|
+
.action(run(async (client, opts) => {
|
|
353
|
+
const body = {};
|
|
354
|
+
if (opts.first !== undefined)
|
|
355
|
+
body.firstName = opts.first;
|
|
356
|
+
if (opts.last !== undefined)
|
|
357
|
+
body.lastName = opts.last;
|
|
358
|
+
if (opts.email !== undefined)
|
|
359
|
+
body.email = opts.email;
|
|
360
|
+
if (opts.role !== undefined)
|
|
361
|
+
body.role = opts.role;
|
|
362
|
+
if (opts.json !== undefined)
|
|
363
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
364
|
+
return client.post("/api/v1/users", body);
|
|
365
|
+
}));
|
|
366
|
+
users
|
|
367
|
+
.command("invite")
|
|
368
|
+
.description("Invite a team member by email")
|
|
369
|
+
.option("--email <email>", "email to invite")
|
|
370
|
+
.option("--role <role>", "role, e.g. member, admin")
|
|
371
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
372
|
+
.action(run(async (client, opts) => {
|
|
373
|
+
const body = {};
|
|
374
|
+
if (opts.email !== undefined)
|
|
375
|
+
body.email = opts.email;
|
|
376
|
+
if (opts.role !== undefined)
|
|
377
|
+
body.role = opts.role;
|
|
378
|
+
if (opts.json !== undefined)
|
|
379
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
380
|
+
return client.post("/api/v1/users/invite", body);
|
|
381
|
+
}));
|
|
382
|
+
// ---- custom fields ----
|
|
383
|
+
const customFields = program.command("custom-fields").description("Manage custom fields");
|
|
384
|
+
customFields
|
|
385
|
+
.command("create")
|
|
386
|
+
.description("Define a custom field on contacts/deals/companies")
|
|
387
|
+
.option("--entity <type>", "entityType: contact | deal | company")
|
|
388
|
+
.option("--key <fieldKey>", "field key (machine name)")
|
|
389
|
+
.option("--label <label>", "display label")
|
|
390
|
+
.option("--type <fieldType>", "fieldType: text|textarea|number|date|datetime|url|select|radio|multiselect|boolean|phone|email")
|
|
391
|
+
.option("--options <json>", "JSON array of options (for select/radio/multiselect)")
|
|
392
|
+
.option("--json <json>", "full JSON body, merged over the flags")
|
|
393
|
+
.action(run(async (client, opts) => {
|
|
394
|
+
const body = {};
|
|
395
|
+
if (opts.entity !== undefined)
|
|
396
|
+
body.entityType = opts.entity;
|
|
397
|
+
if (opts.key !== undefined)
|
|
398
|
+
body.fieldKey = opts.key;
|
|
399
|
+
if (opts.label !== undefined)
|
|
400
|
+
body.label = opts.label;
|
|
401
|
+
if (opts.type !== undefined)
|
|
402
|
+
body.fieldType = opts.type;
|
|
403
|
+
if (opts.options !== undefined) {
|
|
404
|
+
if (opts.options.trim() === "")
|
|
405
|
+
throw new Error("--options must be valid JSON (got an empty string).");
|
|
406
|
+
let o;
|
|
407
|
+
try {
|
|
408
|
+
o = JSON.parse(opts.options);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
throw new Error("--options must be valid JSON.");
|
|
412
|
+
}
|
|
413
|
+
if (!Array.isArray(o)) {
|
|
414
|
+
throw new Error("--options must be a JSON array, e.g. '[\"Low\",\"High\"]' or '[{\"label\":\"Low\",\"value\":\"low\"}]'.");
|
|
415
|
+
}
|
|
416
|
+
if (o.length === 0)
|
|
417
|
+
throw new Error("--options must be a non-empty array.");
|
|
418
|
+
body.options = o;
|
|
419
|
+
}
|
|
420
|
+
if (opts.json !== undefined)
|
|
421
|
+
Object.assign(body, jsonArg(opts.json, "--json"));
|
|
422
|
+
assertOptionsShape(body.options);
|
|
423
|
+
return client.post("/api/v1/custom-fields", body);
|
|
424
|
+
}));
|
|
222
425
|
// ---- raw escape hatch ----
|
|
223
426
|
program
|
|
224
427
|
.command("api <method> <path>")
|
|
225
|
-
.description("Make a raw authenticated API request (escape hatch)")
|
|
428
|
+
.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.")
|
|
226
429
|
.option("--body <json>", "JSON request body")
|
|
227
430
|
.action(run(async (client, method, path, opts) => {
|
|
228
431
|
let body;
|
|
229
|
-
if (opts.body) {
|
|
432
|
+
if (opts.body !== undefined) {
|
|
433
|
+
// Fail closed: an empty/whitespace --body (e.g. a script expanding an
|
|
434
|
+
// unset var) must error, not silently send a payload-less mutating POST.
|
|
435
|
+
if (opts.body.trim() === "") {
|
|
436
|
+
throw new Error("--body must be valid JSON (got an empty string).");
|
|
437
|
+
}
|
|
230
438
|
try {
|
|
231
439
|
body = JSON.parse(opts.body);
|
|
232
440
|
}
|
|
233
441
|
catch {
|
|
234
|
-
|
|
442
|
+
throw new Error("--body must be valid JSON.");
|
|
443
|
+
}
|
|
444
|
+
if (body !== null && typeof body === "object") {
|
|
445
|
+
rejectProtoPollution(body, "--body");
|
|
235
446
|
}
|
|
236
447
|
}
|
|
237
|
-
|
|
448
|
+
// Normalize to an API path so the escape hatch hits the API, not the site
|
|
449
|
+
// root (the base URL is the bare host). Accept already-prefixed paths.
|
|
450
|
+
let p = path.startsWith("/") ? path : `/${path}`;
|
|
451
|
+
if (!/^\/api\//.test(p))
|
|
452
|
+
p = `/api/v1${p}`;
|
|
453
|
+
return client.request(method.toUpperCase(), p, body);
|
|
238
454
|
}));
|
|
239
455
|
program.parseAsync(process.argv).catch(fail);
|
|
240
456
|
// helpers
|
|
457
|
+
// Parse a --json / --stages style argument, throwing a clear CLI error (caught
|
|
458
|
+
// by run() -> non-zero exit) rather than a stack trace on bad JSON.
|
|
459
|
+
// Final-payload validators for CLI-owned shallow invariants. Run AFTER the
|
|
460
|
+
// --json merge so a value supplied (or overridden) via --json is held to the
|
|
461
|
+
// same rule as the dedicated flag — the escape valve can't smuggle a
|
|
462
|
+
// known-invalid shape past validation to a mutating POST. The SERVER remains
|
|
463
|
+
// the authority on deep field schemas; these only catch obviously-malformed
|
|
464
|
+
// shapes for a fast, clear client-side failure.
|
|
465
|
+
function assertStagesShape(v) {
|
|
466
|
+
if (v === undefined)
|
|
467
|
+
return;
|
|
468
|
+
if (!Array.isArray(v) ||
|
|
469
|
+
v.length === 0 ||
|
|
470
|
+
v.some((s) => s === null || typeof s !== "object" || Array.isArray(s))) {
|
|
471
|
+
throw new Error("stages must be a non-empty array of stage objects, e.g. '[{\"name\":\"Lead\"},{\"name\":\"Won\",\"isWon\":true}]'.");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function assertOptionsShape(v) {
|
|
475
|
+
if (v === undefined)
|
|
476
|
+
return;
|
|
477
|
+
if (!Array.isArray(v) || v.length === 0) {
|
|
478
|
+
throw new Error("options must be a non-empty JSON array, e.g. '[\"Low\",\"High\"]'.");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function assertFiniteValue(v) {
|
|
482
|
+
if (v === undefined)
|
|
483
|
+
return;
|
|
484
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
485
|
+
throw new Error("value must be a finite number.");
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// Reject prototype-polluting keys in untrusted JSON. JSON.parse creates an OWN
|
|
489
|
+
// "__proto__" property, but Object.assign'ing it onto a {} body invokes the
|
|
490
|
+
// setter and changes the body's PROTOTYPE — so a validator could read an
|
|
491
|
+
// inherited value while JSON.stringify only serializes own props, desyncing
|
|
492
|
+
// validation from what is actually POSTed. getOwnPropertyNames sees the parsed
|
|
493
|
+
// own keys (including __proto__) regardless of the prototype chain.
|
|
494
|
+
function rejectProtoPollution(obj, flag) {
|
|
495
|
+
for (const k of Object.getOwnPropertyNames(obj)) {
|
|
496
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") {
|
|
497
|
+
throw new Error(`${flag} may not contain a "${k}" key.`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function jsonArg(raw, flag) {
|
|
502
|
+
if (raw.trim() === "") {
|
|
503
|
+
throw new Error(`${flag} must be valid JSON (got an empty string).`);
|
|
504
|
+
}
|
|
505
|
+
let v;
|
|
506
|
+
try {
|
|
507
|
+
v = JSON.parse(raw);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
throw new Error(`${flag} must be valid JSON.`);
|
|
511
|
+
}
|
|
512
|
+
// Merge-style flags are Object.assign'd onto the request body, so an array or
|
|
513
|
+
// primitive would silently reshape/empty it (e.g. [] -> {}, [{x:1}] -> numeric
|
|
514
|
+
// keys). Require a plain JSON object. Raw array bodies go through `api`.
|
|
515
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
516
|
+
throw new Error(`${flag} must be a JSON object (not an array or primitive).`);
|
|
517
|
+
}
|
|
518
|
+
rejectProtoPollution(v, flag);
|
|
519
|
+
return v;
|
|
520
|
+
}
|
|
241
521
|
function run(handler) {
|
|
242
522
|
return async (...args) => {
|
|
243
523
|
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.1",
|
|
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": {
|