@tplog/zendcli 0.1.1 → 0.2.3

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 (3) hide show
  1. package/README.md +142 -0
  2. package/dist/cli.js +198 -25
  3. package/package.json +9 -1
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # zendcli
2
+
3
+ Minimal Zendesk CLI for listing tickets and reading ticket comment threads.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @tplog/zendcli
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ ### Option 1: interactive setup
14
+
15
+ ```bash
16
+ zend configure
17
+ ```
18
+
19
+ Credentials are stored locally in:
20
+
21
+ ```bash
22
+ ~/.zendcli/config.json
23
+ ```
24
+
25
+ The CLI writes this file with restricted permissions.
26
+
27
+ ### Option 2: environment variables
28
+
29
+ This is the recommended option for temporary or CI usage.
30
+
31
+ ```bash
32
+ export ZENDESK_SUBDOMAIN="your-subdomain"
33
+ export ZENDESK_EMAIL="you@example.com"
34
+ export ZENDESK_API_TOKEN="your_zendesk_api_token"
35
+ ```
36
+
37
+ Environment variables take precedence over the config file.
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ zend --help
43
+ zend tickets --help
44
+ zend email --help
45
+ zend follower --help
46
+ zend comments --help
47
+ zend tickets --limit 10
48
+ zend tickets --status open --limit 20
49
+ zend email user@example.com
50
+ zend email user@example.com --status unresolved
51
+ zend email user@example.com --status open,pending
52
+ zend follower
53
+ zend follower tp@dify.ai --limit 3
54
+ zend comments 12345
55
+ zend comments 12345 --type public
56
+ zend comments 12345 --json
57
+ ```
58
+
59
+ ## Development workflow
60
+
61
+ ### Daily development
62
+
63
+ 1. Create a feature branch from `main`
64
+ 2. Develop locally
65
+ 3. Commit as needed
66
+ 4. Push branch to GitHub
67
+ 5. Open a PR to `main`
68
+ 6. Merge after CI passes
69
+
70
+ Example:
71
+
72
+ ```bash
73
+ git checkout main
74
+ git pull
75
+ git checkout -b feat/some-change
76
+
77
+ # work locally
78
+ npm ci
79
+ npm run build
80
+
81
+ git add .
82
+ git commit -m "feat: add some change"
83
+ git push -u origin feat/some-change
84
+ ```
85
+
86
+ ## Release workflow
87
+
88
+ This repository uses a safer release flow:
89
+
90
+ - normal merges to `main` do **not** publish automatically
91
+ - npm publishing happens only when a version tag is pushed
92
+ - the release tag must match `package.json` version exactly
93
+
94
+ ### Publish a new version
95
+
96
+ 1. Make sure `main` is in the state you want to release
97
+ 2. Bump the version locally
98
+ 3. Push `main` and the new tag
99
+ 4. GitHub Actions publishes to npm
100
+
101
+ ```bash
102
+ git checkout main
103
+ git pull
104
+ npm version patch
105
+ git push origin main --tags
106
+ ```
107
+
108
+ Or for a feature release:
109
+
110
+ ```bash
111
+ npm version minor
112
+ git push origin main --tags
113
+ ```
114
+
115
+ This creates tags like `v0.1.2`, and the publish workflow verifies that the tag matches `package.json`.
116
+
117
+ ## CI/CD
118
+
119
+ - `CI`: runs on branch pushes, PRs to `main`, and pushes to `main`
120
+ - `Publish to npm`: runs only on `v*` tags or manual trigger
121
+
122
+ ## Trusted publishing
123
+
124
+ The publish workflow is set up for npm trusted publishing via GitHub Actions OIDC.
125
+
126
+ Recommended setup on npm:
127
+
128
+ 1. Go to the package settings on npm
129
+ 2. Add a Trusted Publisher for GitHub Actions
130
+ 3. Point it to:
131
+ - owner: `tplog`
132
+ - repo: `zendcli`
133
+ - workflow file: `publish.yml`
134
+
135
+ This avoids storing long-lived npm tokens in the repository.
136
+
137
+ ## Security notes
138
+
139
+ - Never commit real Zendesk credentials
140
+ - Prefer environment variables for temporary use
141
+ - If a token is ever exposed, revoke and rotate it immediately
142
+ - Do not store npm publish credentials in the repo or in gitignored files
package/dist/cli.js CHANGED
@@ -3478,37 +3478,48 @@ var import_os = require("os");
3478
3478
  var import_path = require("path");
3479
3479
  var CONFIG_DIR = (0, import_path.join)((0, import_os.homedir)(), ".zendcli");
3480
3480
  var CONFIG_FILE = (0, import_path.join)(CONFIG_DIR, "config.json");
3481
+ function getEnvConfig() {
3482
+ return {
3483
+ subdomain: process.env.ZENDESK_SUBDOMAIN,
3484
+ email: process.env.ZENDESK_EMAIL,
3485
+ api_token: process.env.ZENDESK_API_TOKEN
3486
+ };
3487
+ }
3481
3488
  function loadConfig() {
3489
+ const envConfig = getEnvConfig();
3490
+ let fileConfig = {};
3482
3491
  if ((0, import_fs.existsSync)(CONFIG_FILE)) {
3483
- return JSON.parse((0, import_fs.readFileSync)(CONFIG_FILE, "utf-8"));
3492
+ fileConfig = JSON.parse((0, import_fs.readFileSync)(CONFIG_FILE, "utf-8"));
3484
3493
  }
3485
- return {};
3494
+ return {
3495
+ ...fileConfig,
3496
+ ...Object.fromEntries(Object.entries(envConfig).filter(([, value]) => value))
3497
+ };
3486
3498
  }
3487
3499
  function saveConfig(config) {
3488
- (0, import_fs.mkdirSync)(CONFIG_DIR, { recursive: true });
3489
- (0, import_fs.writeFileSync)(CONFIG_FILE, JSON.stringify(config, null, 2));
3500
+ (0, import_fs.mkdirSync)(CONFIG_DIR, { recursive: true, mode: 448 });
3501
+ (0, import_fs.chmodSync)(CONFIG_DIR, 448);
3502
+ (0, import_fs.writeFileSync)(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
3503
+ (0, import_fs.chmodSync)(CONFIG_FILE, 384);
3490
3504
  }
3491
3505
  function getConfig() {
3492
3506
  const config = loadConfig();
3493
3507
  if (!config.subdomain || !config.email || !config.api_token) {
3494
- console.error("Not configured. Run: zend configure");
3508
+ console.error("Not configured. Run: zend configure or set ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN");
3495
3509
  process.exit(1);
3496
3510
  }
3497
3511
  return config;
3498
3512
  }
3499
3513
 
3500
3514
  // src/api.ts
3501
- async function apiGet(path, params) {
3502
- const { subdomain, email, api_token } = getConfig();
3503
- const url = new URL(`https://${subdomain}.zendesk.com${path}`);
3504
- if (params) {
3505
- for (const [key, value] of Object.entries(params)) {
3506
- url.searchParams.set(key, String(value));
3507
- }
3508
- }
3515
+ function getAuthHeader() {
3516
+ const { email, api_token } = getConfig();
3509
3517
  const credentials = btoa(`${email}/token:${api_token}`);
3510
- const resp = await fetch(url.toString(), {
3511
- headers: { Authorization: `Basic ${credentials}` }
3518
+ return `Basic ${credentials}`;
3519
+ }
3520
+ async function fetchJson(url) {
3521
+ const resp = await fetch(url, {
3522
+ headers: { Authorization: getAuthHeader() }
3512
3523
  });
3513
3524
  if (!resp.ok) {
3514
3525
  const body = await resp.text();
@@ -3517,29 +3528,159 @@ async function apiGet(path, params) {
3517
3528
  }
3518
3529
  return resp.json();
3519
3530
  }
3531
+ async function apiGet(path, params) {
3532
+ const { subdomain } = getConfig();
3533
+ const url = new URL(`https://${subdomain}.zendesk.com${path}`);
3534
+ if (params) {
3535
+ for (const [key, value] of Object.entries(params)) {
3536
+ url.searchParams.set(key, String(value));
3537
+ }
3538
+ }
3539
+ return fetchJson(url.toString());
3540
+ }
3541
+ async function apiGetUrl(url) {
3542
+ return fetchJson(url);
3543
+ }
3520
3544
 
3521
3545
  // src/cli.ts
3522
3546
  var program2 = new Command();
3547
+ var VALID_TICKET_STATUSES = ["new", "open", "pending", "hold", "solved", "closed"];
3523
3548
  program2.name("zend").description("Zendesk tickets & comments CLI").version("0.1.0");
3524
- function prompt(question, defaultValue = "", hidden = false) {
3549
+ function prompt(question, defaultValue = "") {
3525
3550
  return new Promise((resolve) => {
3526
3551
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
3527
- const suffix = defaultValue ? ` [${hidden ? "****" : defaultValue}]` : "";
3552
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
3528
3553
  rl.question(`${question}${suffix}: `, (answer) => {
3529
3554
  rl.close();
3530
3555
  resolve(answer || defaultValue);
3531
3556
  });
3532
3557
  });
3533
3558
  }
3559
+ function promptHidden(question, hasDefault = false) {
3560
+ return new Promise((resolve) => {
3561
+ const stdin = process.stdin;
3562
+ const stdout = process.stderr;
3563
+ let value = "";
3564
+ readline.emitKeypressEvents(stdin);
3565
+ if (stdin.isTTY) {
3566
+ stdin.setRawMode(true);
3567
+ }
3568
+ const suffix = hasDefault ? " [****]" : "";
3569
+ stdout.write(`${question}${suffix}: `);
3570
+ const onKeypress = (char, key) => {
3571
+ if (key.name === "return" || key.name === "enter") {
3572
+ stdout.write("\n");
3573
+ stdin.off("keypress", onKeypress);
3574
+ if (stdin.isTTY) {
3575
+ stdin.setRawMode(false);
3576
+ }
3577
+ resolve(value);
3578
+ return;
3579
+ }
3580
+ if (key.ctrl && key.name === "c") {
3581
+ stdout.write("\n");
3582
+ stdin.off("keypress", onKeypress);
3583
+ if (stdin.isTTY) {
3584
+ stdin.setRawMode(false);
3585
+ }
3586
+ process.exit(130);
3587
+ }
3588
+ if (key.name === "backspace") {
3589
+ value = value.slice(0, -1);
3590
+ return;
3591
+ }
3592
+ if (char) {
3593
+ value += char;
3594
+ }
3595
+ };
3596
+ stdin.on("keypress", onKeypress);
3597
+ });
3598
+ }
3599
+ function parseStatusFilter(input) {
3600
+ if (!input) return [];
3601
+ const statuses = input.split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
3602
+ const invalid = statuses.filter((status) => status !== "unresolved" && status !== "all" && !VALID_TICKET_STATUSES.includes(status));
3603
+ if (invalid.length > 0) {
3604
+ console.error(`Invalid status value(s): ${invalid.join(", ")}`);
3605
+ console.error(`Allowed values: unresolved, all, ${VALID_TICKET_STATUSES.join(", ")}`);
3606
+ process.exit(1);
3607
+ }
3608
+ if (statuses.includes("all")) return [];
3609
+ if (statuses.includes("unresolved")) return ["new", "open", "pending", "hold"];
3610
+ return statuses;
3611
+ }
3612
+ function buildSearchQuery(base, rawStatus, statusFilter, canUseSearchStatus) {
3613
+ let query = base;
3614
+ if (rawStatus === "unresolved") {
3615
+ query += " status<solved";
3616
+ } else if (canUseSearchStatus && statusFilter.length === 1) {
3617
+ query += ` status:${statusFilter[0]}`;
3618
+ }
3619
+ return query;
3620
+ }
3621
+ function printTickets(tickets, meta) {
3622
+ for (const t of tickets) {
3623
+ const status = (t.status || "").padEnd(8);
3624
+ const subject = t.subject || "(no subject)";
3625
+ console.log(`[${t.id}] ${status} ${subject}`);
3626
+ console.log(meta(t));
3627
+ console.log();
3628
+ }
3629
+ }
3630
+ async function findUserByEmail(email) {
3631
+ const data = await apiGet("/api/v2/users/search.json", {
3632
+ query: email
3633
+ });
3634
+ const user = (data.users || []).find((item) => item.email?.toLowerCase() === email.toLowerCase()) || data.users?.[0];
3635
+ if (!user) {
3636
+ console.error(`No Zendesk user found for email: ${email}`);
3637
+ process.exit(1);
3638
+ }
3639
+ return user;
3640
+ }
3641
+ async function findFollowerTickets(email, rawStatus, limit, sort) {
3642
+ const user = await findUserByEmail(email);
3643
+ const statusFilter = parseStatusFilter(rawStatus);
3644
+ const canUseSearchStatus = rawStatus === "unresolved" || statusFilter.length === 1;
3645
+ let url = new URL(`https://${getConfig().subdomain}.zendesk.com/api/v2/search.json`).toString();
3646
+ const params = new URLSearchParams({
3647
+ query: buildSearchQuery("type:ticket", rawStatus, statusFilter, canUseSearchStatus),
3648
+ sort_by: "updated_at",
3649
+ sort_order: sort,
3650
+ per_page: "100"
3651
+ });
3652
+ url += `?${params.toString()}`;
3653
+ const matches = [];
3654
+ while (url && matches.length < limit) {
3655
+ const data = await apiGetUrl(url);
3656
+ let tickets = data.results || [];
3657
+ if (statusFilter.length > 0 && rawStatus !== "unresolved" && !canUseSearchStatus) {
3658
+ tickets = tickets.filter((ticket) => statusFilter.includes((ticket.status || "").toLowerCase()));
3659
+ }
3660
+ for (const ticket of tickets) {
3661
+ const followers = ticket.follower_ids || [];
3662
+ if (followers.includes(user.id) && ticket.assignee_id !== user.id) {
3663
+ matches.push(ticket);
3664
+ }
3665
+ if (matches.length >= limit) {
3666
+ break;
3667
+ }
3668
+ }
3669
+ url = data.next_page || null;
3670
+ }
3671
+ return matches;
3672
+ }
3534
3673
  program2.command("configure").description("Set up Zendesk credentials interactively").action(async () => {
3535
3674
  const existing = loadConfig();
3536
3675
  console.error("Zendesk CLI Configuration");
3537
3676
  console.error("\u2500".repeat(30));
3538
3677
  const subdomain = await prompt("Subdomain (xxx.zendesk.com)", existing.subdomain);
3539
3678
  const email = await prompt("Email", existing.email);
3540
- const api_token = await prompt("API Token", existing.api_token, true);
3679
+ const tokenInput = await promptHidden("API Token", Boolean(existing.api_token));
3680
+ const api_token = tokenInput || existing.api_token || "";
3541
3681
  saveConfig({ subdomain, email, api_token });
3542
- console.error("\nSaved to ~/.zendcli/config.json");
3682
+ console.error("\nSaved to ~/.zendcli/config.json with restricted permissions (0600)");
3683
+ console.error("Environment variables also work: ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN");
3543
3684
  });
3544
3685
  program2.command("tickets").description("List tickets from Zendesk, sorted by updated_at").option("--status <status>", "Filter by status (new|open|pending|hold|solved|closed)").option("--limit <n>", "Max tickets to return", "20").option("--sort <order>", "Sort order (asc|desc)", "desc").action(async (opts) => {
3545
3686
  const limit = parseInt(opts.limit, 10);
@@ -3552,13 +3693,45 @@ program2.command("tickets").description("List tickets from Zendesk, sorted by up
3552
3693
  if (opts.status) {
3553
3694
  tickets = tickets.filter((t) => t.status === opts.status);
3554
3695
  }
3555
- for (const t of tickets.slice(0, limit)) {
3556
- const status = (t.status || "").padEnd(8);
3557
- const subject = t.subject || "(no subject)";
3558
- console.log(`[${t.id}] ${status} ${subject}`);
3559
- console.log(` priority=${t.priority ?? "-"} type=${t.type ?? "-"} created=${t.created_at}`);
3560
- console.log();
3696
+ printTickets(tickets.slice(0, limit), (ticket) => {
3697
+ return ` priority=${ticket.priority ?? "-"} type=${ticket.type ?? "-"} created=${ticket.created_at}`;
3698
+ });
3699
+ });
3700
+ program2.command("email <email>").description("Find tickets for a requester email").option("--status <status>", "Filter 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").option("--json", "Output raw JSON").action(async (email, opts) => {
3701
+ const limit = parseInt(opts.limit, 10);
3702
+ const statusFilter = parseStatusFilter(opts.status);
3703
+ const canUseSearchStatus = opts.status === "unresolved" || statusFilter.length === 1;
3704
+ const query = buildSearchQuery(`type:ticket requester:${email}`, opts.status, statusFilter, canUseSearchStatus);
3705
+ const data = await apiGet("/api/v2/search.json", {
3706
+ query,
3707
+ sort_by: "updated_at",
3708
+ sort_order: opts.sort,
3709
+ per_page: 100
3710
+ });
3711
+ let tickets = data.results || [];
3712
+ if (statusFilter.length > 0 && opts.status !== "unresolved" && !canUseSearchStatus) {
3713
+ tickets = tickets.filter((ticket) => statusFilter.includes((ticket.status || "").toLowerCase()));
3561
3714
  }
3715
+ tickets = tickets.slice(0, limit);
3716
+ if (opts.json) {
3717
+ console.log(JSON.stringify(tickets, null, 2));
3718
+ return;
3719
+ }
3720
+ printTickets(tickets, (ticket) => {
3721
+ return ` priority=${ticket.priority ?? "-"} type=${ticket.type ?? "-"} requester=${email} updated=${ticket.updated_at}`;
3722
+ });
3723
+ });
3724
+ program2.command("follower [email]").description("Find tickets where the user is a follower but not the assignee").option("--status <status>", "Filter 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").option("--json", "Output raw JSON").action(async (email, opts) => {
3725
+ const targetEmail = email || getConfig().email;
3726
+ const limit = parseInt(opts.limit, 10);
3727
+ const tickets = await findFollowerTickets(targetEmail, opts.status, limit, opts.sort);
3728
+ if (opts.json) {
3729
+ console.log(JSON.stringify(tickets, null, 2));
3730
+ return;
3731
+ }
3732
+ printTickets(tickets, (ticket) => {
3733
+ return ` priority=${ticket.priority ?? "-"} type=${ticket.type ?? "-"} follower=${targetEmail} assignee=${ticket.assignee_id ?? "-"} updated=${ticket.updated_at}`;
3734
+ });
3562
3735
  });
3563
3736
  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) => {
3564
3737
  const data = await apiGet(
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "@tplog/zendcli",
3
- "version": "0.1.1",
3
+ "version": "0.2.3",
4
4
  "description": "Minimal Zendesk CLI for tickets and comments",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/tplog/zendcli.git"
8
+ },
9
+ "homepage": "https://github.com/tplog/zendcli#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/tplog/zendcli/issues"
12
+ },
5
13
  "bin": {
6
14
  "zend": "dist/cli.js"
7
15
  },