@tplog/zendcli 0.2.2 → 1.0.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 +7 -0
- package/dist/cli.js +205 -100
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,11 +39,18 @@ Environment variables take precedence over the config file.
|
|
|
39
39
|
## Usage
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
+
zend --help
|
|
43
|
+
zend tickets --help
|
|
44
|
+
zend email --help
|
|
45
|
+
zend follower --help
|
|
46
|
+
zend comments --help
|
|
42
47
|
zend tickets --limit 10
|
|
43
48
|
zend tickets --status open --limit 20
|
|
44
49
|
zend email user@example.com
|
|
45
50
|
zend email user@example.com --status unresolved
|
|
46
51
|
zend email user@example.com --status open,pending
|
|
52
|
+
zend follower
|
|
53
|
+
zend follower tp@dify.ai --limit 3
|
|
47
54
|
zend comments 12345
|
|
48
55
|
zend comments 12345 --type public
|
|
49
56
|
zend comments 12345 --json
|
package/dist/cli.js
CHANGED
|
@@ -3512,30 +3512,101 @@ function getConfig() {
|
|
|
3512
3512
|
}
|
|
3513
3513
|
|
|
3514
3514
|
// src/api.ts
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3515
|
+
var ApiError = class extends Error {
|
|
3516
|
+
status;
|
|
3517
|
+
body;
|
|
3518
|
+
constructor(message, status, body) {
|
|
3519
|
+
super(message);
|
|
3520
|
+
this.name = "ApiError";
|
|
3521
|
+
this.status = status;
|
|
3522
|
+
this.body = body;
|
|
3522
3523
|
}
|
|
3524
|
+
};
|
|
3525
|
+
function authHeader() {
|
|
3526
|
+
const { email, api_token } = getConfig();
|
|
3523
3527
|
const credentials = btoa(`${email}/token:${api_token}`);
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3528
|
+
return `Basic ${credentials}`;
|
|
3529
|
+
}
|
|
3530
|
+
async function fetchJson(url) {
|
|
3531
|
+
let resp;
|
|
3532
|
+
try {
|
|
3533
|
+
resp = await fetch(url, {
|
|
3534
|
+
headers: { Authorization: authHeader() }
|
|
3535
|
+
});
|
|
3536
|
+
} catch (error) {
|
|
3537
|
+
const message = error instanceof Error ? error.message : "Network request failed";
|
|
3538
|
+
throw new ApiError(message);
|
|
3539
|
+
}
|
|
3527
3540
|
if (!resp.ok) {
|
|
3528
3541
|
const body = await resp.text();
|
|
3529
|
-
|
|
3530
|
-
process.exit(1);
|
|
3542
|
+
throw new ApiError(`HTTP ${resp.status}`, resp.status, body);
|
|
3531
3543
|
}
|
|
3532
3544
|
return resp.json();
|
|
3533
3545
|
}
|
|
3546
|
+
async function apiGet(path, params) {
|
|
3547
|
+
const { subdomain } = getConfig();
|
|
3548
|
+
const url = new URL(`https://${subdomain}.zendesk.com${path}`);
|
|
3549
|
+
for (const [key, value] of Object.entries(params || {})) {
|
|
3550
|
+
url.searchParams.set(key, String(value));
|
|
3551
|
+
}
|
|
3552
|
+
return fetchJson(url.toString());
|
|
3553
|
+
}
|
|
3554
|
+
async function apiGetUrl(url) {
|
|
3555
|
+
return fetchJson(url);
|
|
3556
|
+
}
|
|
3534
3557
|
|
|
3535
3558
|
// src/cli.ts
|
|
3536
3559
|
var program2 = new Command();
|
|
3537
3560
|
var VALID_TICKET_STATUSES = ["new", "open", "pending", "hold", "solved", "closed"];
|
|
3538
|
-
|
|
3561
|
+
var KNOWN_COMMANDS = /* @__PURE__ */ new Set(["configure", "follower", "comments", "help", "email", "ticket"]);
|
|
3562
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
3563
|
+
var DIGITS_RE = /^\d+$/;
|
|
3564
|
+
var CliError = class extends Error {
|
|
3565
|
+
code;
|
|
3566
|
+
details;
|
|
3567
|
+
exitCode;
|
|
3568
|
+
constructor(code, message, details = {}, exitCode = 1) {
|
|
3569
|
+
super(message);
|
|
3570
|
+
this.name = "CliError";
|
|
3571
|
+
this.code = code;
|
|
3572
|
+
this.details = details;
|
|
3573
|
+
this.exitCode = exitCode;
|
|
3574
|
+
}
|
|
3575
|
+
};
|
|
3576
|
+
program2.name("zend").description("Zendesk tickets CLI").version("2.0.0");
|
|
3577
|
+
function printJson(value) {
|
|
3578
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
3579
|
+
`);
|
|
3580
|
+
}
|
|
3581
|
+
function fail(code, message, details = {}, exitCode = 1) {
|
|
3582
|
+
printJson({ error: code, message, ...details });
|
|
3583
|
+
process.exit(exitCode);
|
|
3584
|
+
}
|
|
3585
|
+
function handleError(error) {
|
|
3586
|
+
if (error instanceof CliError) {
|
|
3587
|
+
fail(error.code, error.message, error.details, error.exitCode);
|
|
3588
|
+
}
|
|
3589
|
+
if (error instanceof ApiError) {
|
|
3590
|
+
if (error.status === 401) {
|
|
3591
|
+
fail("auth_failed", "401 Unauthorized", { status: 401 });
|
|
3592
|
+
}
|
|
3593
|
+
if (error.status === 404) {
|
|
3594
|
+
fail("not_found", "Resource not found", { status: 404 });
|
|
3595
|
+
}
|
|
3596
|
+
fail("api_error", error.body || error.message, error.status ? { status: error.status } : {});
|
|
3597
|
+
}
|
|
3598
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
3599
|
+
fail("unknown_error", message);
|
|
3600
|
+
}
|
|
3601
|
+
function run(fn) {
|
|
3602
|
+
return async (...args) => {
|
|
3603
|
+
try {
|
|
3604
|
+
await fn(...args);
|
|
3605
|
+
} catch (error) {
|
|
3606
|
+
handleError(error);
|
|
3607
|
+
}
|
|
3608
|
+
};
|
|
3609
|
+
}
|
|
3539
3610
|
function prompt(question, defaultValue = "") {
|
|
3540
3611
|
return new Promise((resolve) => {
|
|
3541
3612
|
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
@@ -3552,139 +3623,173 @@ function promptHidden(question, hasDefault = false) {
|
|
|
3552
3623
|
const stdout = process.stderr;
|
|
3553
3624
|
let value = "";
|
|
3554
3625
|
readline.emitKeypressEvents(stdin);
|
|
3555
|
-
if (stdin.isTTY)
|
|
3556
|
-
|
|
3557
|
-
}
|
|
3558
|
-
const suffix = hasDefault ? " [****]" : "";
|
|
3559
|
-
stdout.write(`${question}${suffix}: `);
|
|
3626
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
3627
|
+
stdout.write(`${question}${hasDefault ? " [****]" : ""}: `);
|
|
3560
3628
|
const onKeypress = (char, key) => {
|
|
3561
3629
|
if (key.name === "return" || key.name === "enter") {
|
|
3562
3630
|
stdout.write("\n");
|
|
3563
3631
|
stdin.off("keypress", onKeypress);
|
|
3564
|
-
if (stdin.isTTY)
|
|
3565
|
-
stdin.setRawMode(false);
|
|
3566
|
-
}
|
|
3632
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
3567
3633
|
resolve(value);
|
|
3568
3634
|
return;
|
|
3569
3635
|
}
|
|
3570
3636
|
if (key.ctrl && key.name === "c") {
|
|
3571
3637
|
stdout.write("\n");
|
|
3572
3638
|
stdin.off("keypress", onKeypress);
|
|
3573
|
-
if (stdin.isTTY)
|
|
3574
|
-
stdin.setRawMode(false);
|
|
3575
|
-
}
|
|
3639
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
3576
3640
|
process.exit(130);
|
|
3577
3641
|
}
|
|
3578
3642
|
if (key.name === "backspace") {
|
|
3579
3643
|
value = value.slice(0, -1);
|
|
3580
3644
|
return;
|
|
3581
3645
|
}
|
|
3582
|
-
if (char)
|
|
3583
|
-
value += char;
|
|
3584
|
-
}
|
|
3646
|
+
if (char) value += char;
|
|
3585
3647
|
};
|
|
3586
3648
|
stdin.on("keypress", onKeypress);
|
|
3587
3649
|
});
|
|
3588
3650
|
}
|
|
3589
|
-
function parseStatusFilter(input) {
|
|
3590
|
-
if (!input) return [];
|
|
3651
|
+
function parseStatusFilter(input = "unresolved") {
|
|
3591
3652
|
const statuses = input.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
|
|
3592
3653
|
const invalid = statuses.filter((status) => status !== "unresolved" && status !== "all" && !VALID_TICKET_STATUSES.includes(status));
|
|
3593
3654
|
if (invalid.length > 0) {
|
|
3594
|
-
|
|
3595
|
-
console.error(`Allowed values: unresolved, all, ${VALID_TICKET_STATUSES.join(", ")}`);
|
|
3596
|
-
process.exit(1);
|
|
3655
|
+
throw new CliError("invalid_args", `Invalid status value(s): ${invalid.join(", ")}`, { input });
|
|
3597
3656
|
}
|
|
3598
3657
|
if (statuses.includes("all")) return [];
|
|
3599
3658
|
if (statuses.includes("unresolved")) return ["new", "open", "pending", "hold"];
|
|
3600
3659
|
return statuses;
|
|
3601
3660
|
}
|
|
3602
|
-
|
|
3661
|
+
function parseLimit(input = "20") {
|
|
3662
|
+
const limit = Number.parseInt(input, 10);
|
|
3663
|
+
if (!Number.isFinite(limit) || Number.isNaN(limit)) {
|
|
3664
|
+
throw new CliError("invalid_args", "limit must be an integer", { limit: input });
|
|
3665
|
+
}
|
|
3666
|
+
if (limit < 1 || limit > 100) {
|
|
3667
|
+
throw new CliError("invalid_args", "limit must be between 1 and 100", { limit });
|
|
3668
|
+
}
|
|
3669
|
+
return limit;
|
|
3670
|
+
}
|
|
3671
|
+
function parseSort(input = "desc") {
|
|
3672
|
+
if (input !== "asc" && input !== "desc") {
|
|
3673
|
+
throw new CliError("invalid_args", "sort must be asc or desc", { sort: input });
|
|
3674
|
+
}
|
|
3675
|
+
return input;
|
|
3676
|
+
}
|
|
3677
|
+
function buildSearchQuery(base, rawStatus, statusFilter) {
|
|
3678
|
+
if (rawStatus === "unresolved") return `${base} status<solved`;
|
|
3679
|
+
if (statusFilter.length === 1) return `${base} status:${statusFilter[0]}`;
|
|
3680
|
+
return base;
|
|
3681
|
+
}
|
|
3682
|
+
function filterStatuses(tickets, rawStatus, statusFilter) {
|
|
3683
|
+
if (rawStatus === "unresolved" || statusFilter.length <= 1) return tickets;
|
|
3684
|
+
return tickets.filter((ticket) => statusFilter.includes(String(ticket.status || "").toLowerCase()));
|
|
3685
|
+
}
|
|
3686
|
+
async function findUserByEmail(email) {
|
|
3687
|
+
const data = await apiGet("/api/v2/users/search.json", { query: email });
|
|
3688
|
+
const users = data.users || [];
|
|
3689
|
+
const exact = users.find((user) => user.email?.toLowerCase() === email.toLowerCase());
|
|
3690
|
+
if (exact) return exact;
|
|
3691
|
+
if (users[0]) return users[0];
|
|
3692
|
+
throw new CliError("user_not_found", `No Zendesk user found for email: ${email}`, { email });
|
|
3693
|
+
}
|
|
3694
|
+
async function fetchFollowerTickets(email, rawStatus, limit, sort) {
|
|
3695
|
+
const user = await findUserByEmail(email);
|
|
3696
|
+
const statusFilter = parseStatusFilter(rawStatus);
|
|
3697
|
+
const baseUrl = new URL(`${apiBaseUrl()}/api/v2/search.json`);
|
|
3698
|
+
baseUrl.searchParams.set("query", buildSearchQuery("type:ticket", rawStatus, statusFilter));
|
|
3699
|
+
baseUrl.searchParams.set("sort_by", "updated_at");
|
|
3700
|
+
baseUrl.searchParams.set("sort_order", sort);
|
|
3701
|
+
baseUrl.searchParams.set("per_page", "100");
|
|
3702
|
+
const matches = [];
|
|
3703
|
+
let nextUrl = baseUrl.toString();
|
|
3704
|
+
while (nextUrl && matches.length < limit) {
|
|
3705
|
+
const data = await apiGetUrl(nextUrl);
|
|
3706
|
+
const tickets = filterStatuses(data.results || [], rawStatus, statusFilter);
|
|
3707
|
+
for (const ticket of tickets) {
|
|
3708
|
+
if ((ticket.follower_ids || []).includes(user.id) && ticket.assignee_id !== user.id) {
|
|
3709
|
+
matches.push(ticket);
|
|
3710
|
+
}
|
|
3711
|
+
if (matches.length >= limit) break;
|
|
3712
|
+
}
|
|
3713
|
+
nextUrl = data.next_page || null;
|
|
3714
|
+
}
|
|
3715
|
+
return matches;
|
|
3716
|
+
}
|
|
3717
|
+
function apiBaseUrl() {
|
|
3718
|
+
const { subdomain } = getConfig();
|
|
3719
|
+
return `https://${subdomain}.zendesk.com`;
|
|
3720
|
+
}
|
|
3721
|
+
program2.command("configure").description("Set up Zendesk credentials interactively").action(run(async () => {
|
|
3603
3722
|
const existing = loadConfig();
|
|
3604
|
-
|
|
3605
|
-
|
|
3723
|
+
process.stderr.write("Zendesk CLI Configuration\n");
|
|
3724
|
+
process.stderr.write(`${"\u2500".repeat(30)}
|
|
3725
|
+
`);
|
|
3606
3726
|
const subdomain = await prompt("Subdomain (xxx.zendesk.com)", existing.subdomain);
|
|
3607
3727
|
const email = await prompt("Email", existing.email);
|
|
3608
3728
|
const tokenInput = await promptHidden("API Token", Boolean(existing.api_token));
|
|
3609
3729
|
const api_token = tokenInput || existing.api_token || "";
|
|
3610
3730
|
saveConfig({ subdomain, email, api_token });
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
const data = await apiGet("/api/v2/tickets.json", {
|
|
3617
|
-
per_page: Math.min(limit, 100),
|
|
3618
|
-
sort_order: opts.sort,
|
|
3619
|
-
sort_by: "updated_at"
|
|
3620
|
-
});
|
|
3621
|
-
let tickets = data.tickets || [];
|
|
3622
|
-
if (opts.status) {
|
|
3623
|
-
tickets = tickets.filter((t) => t.status === opts.status);
|
|
3731
|
+
printJson({ ok: true });
|
|
3732
|
+
}));
|
|
3733
|
+
program2.command("ticket <id>").description("Get a single ticket").action(run(async (id) => {
|
|
3734
|
+
if (!DIGITS_RE.test(id)) {
|
|
3735
|
+
throw new CliError("invalid_args", "ticket id must be numeric", { id });
|
|
3624
3736
|
}
|
|
3625
|
-
|
|
3626
|
-
const
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3737
|
+
try {
|
|
3738
|
+
const data = await apiGet(`/api/v2/tickets/${id}.json`);
|
|
3739
|
+
if (!data.ticket) {
|
|
3740
|
+
throw new CliError("not_found", `Ticket ${id} not found`, { id: Number(id) });
|
|
3741
|
+
}
|
|
3742
|
+
printJson(data.ticket);
|
|
3743
|
+
} catch (error) {
|
|
3744
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
3745
|
+
fail("not_found", `Ticket ${id} not found`, { id: Number(id) });
|
|
3746
|
+
}
|
|
3747
|
+
throw error;
|
|
3631
3748
|
}
|
|
3632
|
-
});
|
|
3633
|
-
program2.command("email <email>").description("Find tickets for
|
|
3634
|
-
const limit =
|
|
3749
|
+
}));
|
|
3750
|
+
program2.command("email <email>").description("Find tickets for an assignee email").option("--status <status>", "unresolved|all|new|open|pending|hold|solved|closed or comma-separated list", "unresolved").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").action(run(async (email, opts) => {
|
|
3751
|
+
const limit = parseLimit(opts.limit);
|
|
3752
|
+
const sort = parseSort(opts.sort);
|
|
3635
3753
|
const statusFilter = parseStatusFilter(opts.status);
|
|
3636
|
-
const
|
|
3637
|
-
let query = `type:ticket requester:${email}`;
|
|
3638
|
-
if (opts.status === "unresolved") {
|
|
3639
|
-
query += " status<solved";
|
|
3640
|
-
} else if (canUseSearchStatus && statusFilter.length === 1) {
|
|
3641
|
-
query += ` status:${statusFilter[0]}`;
|
|
3642
|
-
}
|
|
3754
|
+
const query = buildSearchQuery(`type:ticket assignee:${email}`, opts.status, statusFilter);
|
|
3643
3755
|
const data = await apiGet("/api/v2/search.json", {
|
|
3644
3756
|
query,
|
|
3645
3757
|
sort_by: "updated_at",
|
|
3646
|
-
sort_order:
|
|
3758
|
+
sort_order: sort,
|
|
3647
3759
|
per_page: 100
|
|
3648
3760
|
});
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3761
|
+
const tickets = filterStatuses(data.results || [], opts.status, statusFilter).slice(0, limit);
|
|
3762
|
+
printJson(tickets);
|
|
3763
|
+
}));
|
|
3764
|
+
program2.command("follower <email>").description("Find tickets where the user is a follower but not the assignee").option("--status <status>", "unresolved|all|new|open|pending|hold|solved|closed or comma-separated list", "unresolved").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").action(run(async (email, opts) => {
|
|
3765
|
+
const limit = parseLimit(opts.limit);
|
|
3766
|
+
const sort = parseSort(opts.sort);
|
|
3767
|
+
const tickets = await fetchFollowerTickets(email, opts.status, limit, sort);
|
|
3768
|
+
printJson(tickets);
|
|
3769
|
+
}));
|
|
3770
|
+
program2.command("comments <ticketId>").description("List comments for a ticket").option("--type <type>", "all|public|internal", "all").option("--sort <order>", "Sort order (asc|desc)", "asc").action(run(async (ticketId, opts) => {
|
|
3771
|
+
if (!DIGITS_RE.test(ticketId)) {
|
|
3772
|
+
throw new CliError("invalid_args", "ticket id must be numeric", { ticketId });
|
|
3652
3773
|
}
|
|
3653
|
-
|
|
3654
|
-
if (opts.
|
|
3655
|
-
|
|
3656
|
-
return;
|
|
3774
|
+
const sort = parseSort(opts.sort);
|
|
3775
|
+
if (!["all", "public", "internal"].includes(opts.type)) {
|
|
3776
|
+
throw new CliError("invalid_args", "type must be all, public, or internal", { type: opts.type });
|
|
3657
3777
|
}
|
|
3658
|
-
for (const t of tickets) {
|
|
3659
|
-
const status = (t.status || "").padEnd(8);
|
|
3660
|
-
const subject = t.subject || "(no subject)";
|
|
3661
|
-
console.log(`[${t.id}] ${status} ${subject}`);
|
|
3662
|
-
console.log(` priority=${t.priority ?? "-"} type=${t.type ?? "-"} requester=${email} updated=${t.updated_at}`);
|
|
3663
|
-
console.log();
|
|
3664
|
-
}
|
|
3665
|
-
});
|
|
3666
|
-
program2.command("comments <ticketId>").description("List comments/thread for a ticket (public=customer-facing, internal=agents only)").option("--type <type>", "Filter: all|public|internal", "all").option("--sort <order>", "Sort order (asc|desc)", "asc").option("--json", "Output raw JSON").action(async (ticketId, opts) => {
|
|
3667
3778
|
const data = await apiGet(
|
|
3668
3779
|
`/api/v2/tickets/${ticketId}/comments.json`,
|
|
3669
|
-
{ sort_order:
|
|
3780
|
+
{ sort_order: sort, per_page: 100 }
|
|
3670
3781
|
);
|
|
3671
3782
|
let comments = data.comments || [];
|
|
3672
|
-
if (opts.type === "public")
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
console.log(`--- [${label}] id=${c.id} author=${c.author_id} via=${via} ${c.created_at} ---`);
|
|
3685
|
-
const body = (c.plain_body || c.body || "").trim();
|
|
3686
|
-
console.log(body);
|
|
3687
|
-
console.log();
|
|
3688
|
-
}
|
|
3689
|
-
});
|
|
3783
|
+
if (opts.type === "public") comments = comments.filter((comment) => comment.public === true);
|
|
3784
|
+
if (opts.type === "internal") comments = comments.filter((comment) => comment.public === false);
|
|
3785
|
+
printJson(comments);
|
|
3786
|
+
}));
|
|
3787
|
+
function preprocessArgv(argv) {
|
|
3788
|
+
const firstArg = argv[2];
|
|
3789
|
+
if (!firstArg || firstArg.startsWith("-") || KNOWN_COMMANDS.has(firstArg)) return argv;
|
|
3790
|
+
if (EMAIL_RE.test(firstArg)) return [...argv.slice(0, 2), "email", ...argv.slice(2)];
|
|
3791
|
+
if (DIGITS_RE.test(firstArg)) return [...argv.slice(0, 2), "ticket", ...argv.slice(2)];
|
|
3792
|
+
return argv;
|
|
3793
|
+
}
|
|
3794
|
+
process.argv = preprocessArgv(process.argv);
|
|
3690
3795
|
program2.parse();
|