@easycustomerfeedback/cli 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -0
  3. package/dist/index.js +344 -0
  4. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Bonner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @easycustomerfeedback/cli
2
+
3
+ `ecf` is the command-line interface for [EasyCustomerFeedback](https://easycustomerfeedback.com). Triage feedback submissions, update statuses, and leave internal comments from your terminal.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @easycustomerfeedback/cli
9
+ ```
10
+
11
+ Or run without installing:
12
+
13
+ ```sh
14
+ npx @easycustomerfeedback/cli --help
15
+ ```
16
+
17
+ Requires Node.js 18 or newer.
18
+
19
+ ## Getting started
20
+
21
+ 1. In the EasyCustomerFeedback dashboard, open **Integrations → Personal API tokens** and create a token. Copy it — tokens are only shown once.
22
+ 2. Log in:
23
+
24
+ ```sh
25
+ ecf login
26
+ ```
27
+
28
+ You'll be prompted for the server URL (defaults to `https://easycustomerfeedback.com`) and the token. Credentials are saved to `~/.config/ecf/config.json`.
29
+ 3. If you belong to multiple workspaces, pick a default:
30
+
31
+ ```sh
32
+ ecf workspace list
33
+ ecf workspace use <workspaceId>
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ ### Authentication
39
+
40
+ | Command | What it does |
41
+ | --- | --- |
42
+ | `ecf login` | Configure server URL and API token. Auto-selects the workspace if you only have one. |
43
+
44
+ ### Workspaces
45
+
46
+ | Command | What it does |
47
+ | --- | --- |
48
+ | `ecf workspace list` | Show the workspaces you belong to (marks the active one). |
49
+ | `ecf workspace use <id>` | Set the default workspace for future commands. |
50
+
51
+ ### Submissions
52
+
53
+ | Command | What it does |
54
+ | --- | --- |
55
+ | `ecf submissions list [options]` | List submissions in the active workspace. |
56
+ | `ecf submissions get <id> [--json]` | Show details for a submission. |
57
+ | `ecf submissions status <id> <status>` | Update a submission's status. |
58
+ | `ecf submissions comment <id> [body]` | Add an internal comment (body may come from stdin). |
59
+
60
+ `ecf submissions list` options:
61
+
62
+ - `--status <s>` — filter by status (repeatable: `untriaged`, `open`, `in_progress`, `resolved`, `closed`)
63
+ - `--type <t>` — filter by type (`bug`, `feature_request`, `general_feedback`)
64
+ - `--project <id>` — filter by project
65
+ - `--limit <n>` — 1..200 (default 50)
66
+ - `--workspace <id>` — override the active workspace
67
+ - `--json` — output raw JSON
68
+
69
+ Valid status values for `ecf submissions status`: `untriaged`, `open`, `in_progress`, `resolved`, `closed`.
70
+
71
+ ## Examples
72
+
73
+ List open bugs, piped to your pager:
74
+
75
+ ```sh
76
+ ecf submissions list --status open --type bug | less
77
+ ```
78
+
79
+ Pipe a longer comment in from a file:
80
+
81
+ ```sh
82
+ cat review.md | ecf submissions comment sub_abc123
83
+ ```
84
+
85
+ Resolve a submission:
86
+
87
+ ```sh
88
+ ecf submissions status sub_abc123 resolved
89
+ ```
90
+
91
+ ## Configuration
92
+
93
+ Configuration lives at `~/.config/ecf/config.json` and is created by `ecf login`. Delete the file to reset.
94
+
95
+ ## API reference
96
+
97
+ The CLI is a thin wrapper around the public REST API. See [the API docs](https://easycustomerfeedback.com/docs/api) for everything the CLI exposes and more.
98
+
99
+ ## License
100
+
101
+ [MIT](./LICENSE)
package/dist/index.js ADDED
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/login.ts
4
+ import { createInterface } from "node:readline/promises";
5
+
6
+ // src/api.ts
7
+ class ApiError extends Error {
8
+ status;
9
+ constructor(message, status) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ }
14
+ async function apiFetch(config, path, init = {}) {
15
+ const url = new URL(path, config.baseUrl).toString();
16
+ const headers = new Headers(init.headers);
17
+ headers.set("authorization", `Bearer ${config.apiKey}`);
18
+ if (init.body && !headers.has("content-type")) {
19
+ headers.set("content-type", "application/json");
20
+ }
21
+ const res = await fetch(url, { ...init, headers });
22
+ const text = await res.text();
23
+ let body = null;
24
+ if (text) {
25
+ try {
26
+ body = JSON.parse(text);
27
+ } catch {
28
+ body = text;
29
+ }
30
+ }
31
+ if (!res.ok) {
32
+ const message = typeof body === "object" && body !== null && "error" in body ? String(body.error) : `Request failed with status ${res.status}`;
33
+ throw new ApiError(message, res.status);
34
+ }
35
+ return body;
36
+ }
37
+
38
+ // src/config.ts
39
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
40
+ import { homedir } from "node:os";
41
+ import { dirname, join } from "node:path";
42
+ var CONFIG_DIR = join(homedir(), ".config", "ecf");
43
+ var CONFIG_PATH = join(CONFIG_DIR, "config.json");
44
+ function loadConfig() {
45
+ if (!existsSync(CONFIG_PATH))
46
+ return null;
47
+ try {
48
+ const raw = readFileSync(CONFIG_PATH, "utf8");
49
+ const parsed = JSON.parse(raw);
50
+ if (typeof parsed?.baseUrl !== "string" || typeof parsed?.apiKey !== "string")
51
+ return null;
52
+ return parsed;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ function saveConfig(config) {
58
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
59
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + `
60
+ `, { mode: 384 });
61
+ }
62
+ function requireConfig() {
63
+ const config = loadConfig();
64
+ if (!config) {
65
+ console.error("Not logged in. Run: ecf login");
66
+ process.exit(1);
67
+ }
68
+ return config;
69
+ }
70
+
71
+ // src/commands/login.ts
72
+ async function prompt(rl, question, def) {
73
+ const suffix = def ? ` [${def}]` : "";
74
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
75
+ return answer || def || "";
76
+ }
77
+ async function login() {
78
+ const existing = loadConfig();
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
80
+ try {
81
+ const baseUrl = await prompt(rl, "Server URL", existing?.baseUrl ?? "https://easycustomerfeedback.com");
82
+ const apiKey = await prompt(rl, "API token (ecf_…)");
83
+ if (!baseUrl || !apiKey) {
84
+ console.error("Server URL and API token are both required.");
85
+ process.exit(1);
86
+ }
87
+ const config = { baseUrl, apiKey, workspaceId: existing?.workspaceId };
88
+ const { workspaces } = await apiFetch(config, "/api/v1/workspaces");
89
+ if (workspaces.length === 1) {
90
+ config.workspaceId = workspaces[0].id;
91
+ }
92
+ saveConfig(config);
93
+ console.log(`
94
+ Saved to ${CONFIG_PATH}`);
95
+ console.log(`You belong to ${workspaces.length} workspace(s):`);
96
+ for (const w of workspaces) {
97
+ const marker = w.id === config.workspaceId ? "* " : " ";
98
+ console.log(`${marker}${w.id} ${w.name}`);
99
+ }
100
+ if (workspaces.length === 1) {
101
+ console.log(`
102
+ Active workspace set to ${workspaces[0].name}.`);
103
+ } else if (!config.workspaceId && workspaces.length > 1) {
104
+ console.log("\nTip: pick a default with `ecf workspace use <id>`");
105
+ }
106
+ } finally {
107
+ rl.close();
108
+ }
109
+ }
110
+
111
+ // src/commands/submissions.ts
112
+ import { parseArgs } from "node:util";
113
+ function resolveWorkspace(config, override) {
114
+ const id = override ?? config.workspaceId;
115
+ if (!id) {
116
+ console.error("No workspace selected. Pass --workspace <id> or set a default with `ecf workspace use <id>`.");
117
+ process.exit(1);
118
+ }
119
+ return id;
120
+ }
121
+ async function readStdin() {
122
+ const chunks = [];
123
+ for await (const chunk of process.stdin) {
124
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
125
+ }
126
+ return Buffer.concat(chunks).toString("utf8").trim();
127
+ }
128
+ async function submissionsCommand(args) {
129
+ const [sub, ...rest] = args;
130
+ if (sub === "list")
131
+ return list(rest);
132
+ if (sub === "get")
133
+ return get(rest);
134
+ if (sub === "status")
135
+ return status(rest);
136
+ if (sub === "comment")
137
+ return comment(rest);
138
+ console.error("Usage: ecf submissions <list|get|status|comment>");
139
+ process.exit(1);
140
+ }
141
+ async function list(args) {
142
+ const { values } = parseArgs({
143
+ args,
144
+ options: {
145
+ status: { type: "string", multiple: true },
146
+ type: { type: "string" },
147
+ project: { type: "string" },
148
+ limit: { type: "string" },
149
+ workspace: { type: "string" },
150
+ json: { type: "boolean" }
151
+ },
152
+ allowPositionals: false
153
+ });
154
+ const config = requireConfig();
155
+ const workspaceId = resolveWorkspace(config, values.workspace);
156
+ const params = new URLSearchParams({ workspaceId });
157
+ for (const s of values.status ?? [])
158
+ params.append("status", s);
159
+ if (values.type)
160
+ params.set("type", values.type);
161
+ if (values.project)
162
+ params.set("projectId", values.project);
163
+ if (values.limit)
164
+ params.set("limit", values.limit);
165
+ const { submissions } = await apiFetch(config, `/api/v1/submissions?${params.toString()}`);
166
+ if (values.json) {
167
+ console.log(JSON.stringify(submissions, null, 2));
168
+ return;
169
+ }
170
+ if (submissions.length === 0) {
171
+ console.log("No submissions match.");
172
+ return;
173
+ }
174
+ for (const s of submissions) {
175
+ const submitter = s.submitterName ?? s.submitterEmail ?? "anonymous";
176
+ console.log(`${s.id} [${s.status.padEnd(11)}] ${s.type.padEnd(18)} ${s.projectName} · ${s.title} (${submitter})`);
177
+ }
178
+ }
179
+ async function get(args) {
180
+ const { values, positionals } = parseArgs({
181
+ args,
182
+ options: { json: { type: "boolean" } },
183
+ allowPositionals: true
184
+ });
185
+ const [id] = positionals;
186
+ if (!id) {
187
+ console.error("Usage: ecf submissions get <id> [--json]");
188
+ process.exit(1);
189
+ }
190
+ const config = requireConfig();
191
+ const { submission } = await apiFetch(config, `/api/v1/submissions/${encodeURIComponent(id)}`);
192
+ if (values.json) {
193
+ console.log(JSON.stringify(submission, null, 2));
194
+ return;
195
+ }
196
+ console.log(`# ${submission.title}`);
197
+ console.log(`id: ${submission.id} type: ${submission.type} status: ${submission.status} priority: ${submission.priority}`);
198
+ console.log(`project: ${submission.projectName} workspace: ${submission.workspaceName}`);
199
+ const submitter = submission.submitterName ?? submission.submitterEmail ?? "anonymous";
200
+ console.log(`from: ${submitter} created: ${submission.createdAt}`);
201
+ if (submission.sourceUrl)
202
+ console.log(`url: ${submission.sourceUrl}`);
203
+ if (submission.assignee) {
204
+ console.log(`assigned: ${submission.assignee.name ?? submission.assignee.email}`);
205
+ }
206
+ console.log("");
207
+ console.log(submission.description);
208
+ if (submission.comments.length > 0) {
209
+ console.log(`
210
+ — comments —`);
211
+ for (const c of submission.comments) {
212
+ const who = c.author.name ?? c.author.email ?? "unknown";
213
+ console.log(`
214
+ ${who} ${c.createdAt}`);
215
+ console.log(c.body);
216
+ }
217
+ }
218
+ if (submission.attachments.length > 0) {
219
+ console.log(`
220
+ — attachments —`);
221
+ for (const a of submission.attachments) {
222
+ console.log(`${a.fileName} ${a.downloadUrl}`);
223
+ }
224
+ }
225
+ }
226
+ async function status(args) {
227
+ const [id, newStatus] = args;
228
+ if (!id || !newStatus) {
229
+ console.error("Usage: ecf submissions status <id> <untriaged|open|in_progress|resolved|closed>");
230
+ process.exit(1);
231
+ }
232
+ const config = requireConfig();
233
+ await apiFetch(config, `/api/v1/submissions/${encodeURIComponent(id)}`, {
234
+ method: "PATCH",
235
+ body: JSON.stringify({ status: newStatus })
236
+ });
237
+ console.log(`Updated ${id} → ${newStatus}`);
238
+ }
239
+ async function comment(args) {
240
+ const [id, ...bodyParts] = args;
241
+ if (!id) {
242
+ console.error("Usage: ecf submissions comment <id> [body] (body may also come via stdin)");
243
+ process.exit(1);
244
+ }
245
+ let body = bodyParts.join(" ").trim();
246
+ if (!body && !process.stdin.isTTY) {
247
+ body = await readStdin();
248
+ }
249
+ if (!body) {
250
+ console.error("Comment body is required (positional arg or stdin).");
251
+ process.exit(1);
252
+ }
253
+ const config = requireConfig();
254
+ const result = await apiFetch(config, `/api/v1/submissions/${encodeURIComponent(id)}/comments`, { method: "POST", body: JSON.stringify({ body }) });
255
+ console.log(`Added comment ${result.comment.id}`);
256
+ }
257
+
258
+ // src/commands/workspace.ts
259
+ async function workspaceCommand(args) {
260
+ const [sub, ...rest] = args;
261
+ if (sub === "list") {
262
+ const config = requireConfig();
263
+ const { workspaces } = await apiFetch(config, "/api/v1/workspaces");
264
+ if (workspaces.length === 0) {
265
+ console.log("No workspaces.");
266
+ return;
267
+ }
268
+ const activeId = config.workspaceId;
269
+ for (const w of workspaces) {
270
+ const marker = w.id === activeId ? "* " : " ";
271
+ console.log(`${marker}${w.id} ${w.name}`);
272
+ }
273
+ return;
274
+ }
275
+ if (sub === "use") {
276
+ const config = requireConfig();
277
+ const [workspaceId] = rest;
278
+ if (!workspaceId) {
279
+ console.error("Usage: ecf workspace use <workspaceId>");
280
+ process.exit(1);
281
+ }
282
+ saveConfig({ ...config, workspaceId });
283
+ console.log(`Active workspace set to ${workspaceId}`);
284
+ return;
285
+ }
286
+ console.error("Usage: ecf workspace <list|use>");
287
+ process.exit(1);
288
+ }
289
+
290
+ // src/index.ts
291
+ var USAGE = `ecf — EasyCustomerFeedback CLI
292
+
293
+ Usage:
294
+ ecf login Configure API token and server
295
+ ecf workspace list List workspaces you belong to
296
+ ecf workspace use <workspaceId> Set the default workspace
297
+
298
+ ecf submissions list [options] List submissions in the active workspace
299
+ --status <s> (repeatable: untriaged|open|in_progress|resolved|closed)
300
+ --type <t> bug | feature_request | general_feedback
301
+ --project <id> Filter by project id
302
+ --limit <n> 1..200 (default 50)
303
+ --workspace <id> Override active workspace
304
+ --json Output raw JSON
305
+
306
+ ecf submissions get <id> [--json] Show details for a submission
307
+ ecf submissions status <id> <status> Update a submission's status
308
+ ecf submissions comment <id> [body] Add an internal comment
309
+ (if body is omitted, reads from stdin)
310
+ `;
311
+ async function main() {
312
+ const argv = process.argv.slice(2);
313
+ const [command, ...rest] = argv;
314
+ if (!command || command === "help" || command === "--help" || command === "-h") {
315
+ process.stdout.write(USAGE);
316
+ return;
317
+ }
318
+ try {
319
+ switch (command) {
320
+ case "login":
321
+ await login();
322
+ return;
323
+ case "workspace":
324
+ await workspaceCommand(rest);
325
+ return;
326
+ case "submissions":
327
+ await submissionsCommand(rest);
328
+ return;
329
+ default:
330
+ console.error(`Unknown command: ${command}
331
+ `);
332
+ process.stdout.write(USAGE);
333
+ process.exit(1);
334
+ }
335
+ } catch (err) {
336
+ if (err instanceof Error) {
337
+ console.error(`Error: ${err.message}`);
338
+ } else {
339
+ console.error("Unknown error", err);
340
+ }
341
+ process.exit(1);
342
+ }
343
+ }
344
+ await main();
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@easycustomerfeedback/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line interface for EasyCustomerFeedback — list, triage, and comment on feedback submissions from your terminal.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ecf": "./dist/index.js"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/michaelbonner/easycustomerfeedback.git",
25
+ "directory": "packages/cli"
26
+ },
27
+ "homepage": "https://easycustomerfeedback.com",
28
+ "bugs": {
29
+ "url": "https://github.com/michaelbonner/easycustomerfeedback/issues"
30
+ },
31
+ "keywords": [
32
+ "easycustomerfeedback",
33
+ "ecf",
34
+ "cli",
35
+ "feedback",
36
+ "customer-feedback"
37
+ ],
38
+ "scripts": {
39
+ "dev": "bun run src/index.ts",
40
+ "build": "bun run build.ts",
41
+ "typecheck": "tsc --noEmit",
42
+ "prepublishOnly": "bun run build"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^6.0.2"
46
+ }
47
+ }