@uic-coe-connect/cli 0.8.0 → 0.9.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.
@@ -1,3 +1,5 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { basename } from "node:path";
1
3
  import { CliError } from "../client.js";
2
4
  import { details, emit, info, table } from "../output.js";
3
5
  import { register } from "../registry.js";
@@ -14,6 +16,31 @@ import { register } from "../registry.js";
14
16
  */
15
17
  const STATUSES = ["open", "planned", "in-progress", "done", "declined"];
16
18
  const CLOSED = ["done", "declined"];
19
+ function humanSize(bytes) {
20
+ if (bytes < 1024)
21
+ return `${bytes} B`;
22
+ if (bytes < 1024 * 1024)
23
+ return `${Math.round(bytes / 1024)} KB`;
24
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
25
+ }
26
+ /** Every file on the thread, wherever it hangs — the whole list is what you search. */
27
+ function allAttachments(report) {
28
+ return [...(report.attachments ?? []), ...(report.comments ?? []).flatMap((c) => c.attachments ?? [])];
29
+ }
30
+ /** Match an attachment by id or by filename, the way `files download` matches. */
31
+ function findAttachment(report, ref) {
32
+ const all = allAttachments(report);
33
+ const byId = all.find((a) => a.id === ref);
34
+ if (byId)
35
+ return byId;
36
+ const matches = all.filter((a) => a.name.toLowerCase().includes(ref.toLowerCase()));
37
+ if (matches.length === 1)
38
+ return matches[0];
39
+ if (matches.length > 1) {
40
+ throw new CliError(`"${ref}" matches ${matches.length} attachments: ${matches.map((a) => a.name).join(", ")}`, 4, "Use the exact attachment id.");
41
+ }
42
+ throw new CliError(`No attachment "${ref}" on that report.`, 4, all.length === 0 ? "It has no attachments." : `It has: ${all.map((a) => a.name).join(", ")}`);
43
+ }
17
44
  function assertStatus(value) {
18
45
  if (!STATUSES.includes(value)) {
19
46
  throw new CliError(`Unknown status "${value}".`, 6, `Use one of: ${STATUSES.join(", ")}`);
@@ -120,10 +147,21 @@ export function registerReportCommands() {
120
147
  ...(r.pageUrl ? [["Page", r.pageUrl]] : []),
121
148
  ]);
122
149
  process.stdout.write(`\n${r.body}\n`);
150
+ for (const a of r.attachments ?? []) {
151
+ process.stdout.write(` [attachment] ${a.name} (${humanSize(a.size)}) ${a.id}\n`);
152
+ }
123
153
  for (const comment of r.comments ?? []) {
124
154
  const change = comment.statusTo ? ` [→ ${comment.statusTo}]` : "";
125
155
  process.stdout.write(`\n--- ${comment.authorName ?? comment.authorNetid} · ${comment.createdAt}${change}\n` +
126
156
  (comment.body ? `${comment.body}\n` : ""));
157
+ for (const a of comment.attachments ?? []) {
158
+ process.stdout.write(` [attachment] ${a.name} (${humanSize(a.size)}) ${a.id}\n`);
159
+ }
160
+ }
161
+ const files = allAttachments(r);
162
+ if (files.length > 0) {
163
+ info("");
164
+ info(`${files.length} attachment(s) — fetch one with: coe reports download ${r.id} <name>`);
127
165
  }
128
166
  if (!data.canRespond) {
129
167
  info("");
@@ -162,14 +200,51 @@ export function registerReportCommands() {
162
200
  const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
163
201
  emit({ report }, () => info(`✓ ${report.id} is now ${report.status}`));
164
202
  },
203
+ }, {
204
+ name: "reports attach",
205
+ summary: "Attach a file to a report",
206
+ usage: "reports attach <id> <path>",
207
+ details: [
208
+ "Screenshots, logs, spreadsheets — anything that shows the problem.",
209
+ "Up to 10 MB each, 20 per report. It lands on the report itself; use the",
210
+ "web UI to attach a file to one particular reply.",
211
+ ],
212
+ async run({ args, client }) {
213
+ const id = args.arg(0, "id");
214
+ const filePath = args.arg(1, "path");
215
+ let bytes;
216
+ try {
217
+ bytes = readFileSync(filePath);
218
+ }
219
+ catch (error) {
220
+ throw new CliError(`Can't read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 4);
221
+ }
222
+ const file = await client().upload(`/reports/${id}/attachments`, basename(filePath), bytes);
223
+ emit({ attachment: file }, () => info(`✓ Attached ${file.name} (${humanSize(file.size)}) to ${id}`));
224
+ },
225
+ }, {
226
+ name: "reports download",
227
+ summary: "Download an attachment from a report",
228
+ usage: "reports download <id> <name|attachment-id> [--out <path>]",
229
+ async run({ args, client }) {
230
+ const c = client();
231
+ const id = args.arg(0, "id");
232
+ const { report } = await c.request("GET", `/reports/${id}`);
233
+ const file = findAttachment(report, args.arg(1, "name"));
234
+ const bytes = await c.download(`/reports/${id}/attachments/${file.id}`);
235
+ const out = args.flag("out") ?? file.name;
236
+ writeFileSync(out, bytes);
237
+ emit({ reportId: id, attachment: file.name, out, bytes: bytes.length }, () => info(`✓ Wrote ${out} (${humanSize(bytes.length)})`));
238
+ },
165
239
  }, {
166
240
  name: "reports file",
167
241
  summary: "File a bug report or feature request",
168
- usage: "reports file <app> <title> [--details <text>] [--request]",
242
+ usage: "reports file <app> <title> [--details <text>] [--request] [--attach <path>]",
169
243
  details: [
170
244
  " <app> The app it's about, or `coeconnect` for the portal itself",
171
245
  " --details The body of the report (defaults to the title)",
172
246
  " --request File it as a feature request rather than a bug",
247
+ " --attach A file to attach — a screenshot, a log excerpt",
173
248
  ],
174
249
  async run({ args, client }) {
175
250
  const c = client();
@@ -182,7 +257,22 @@ export function registerReportCommands() {
182
257
  title,
183
258
  body: args.flag("details") ?? title,
184
259
  });
185
- emit({ report }, () => info(`✓ Filed ${report.id} ${report.title}`));
260
+ // Attached after the report exists, because that is what it hangs off.
261
+ // A failure here must not read as "the report wasn't filed" — it was.
262
+ const attach = args.flag("attach");
263
+ let attachment = null;
264
+ if (attach) {
265
+ try {
266
+ attachment = await c.upload(`/reports/${report.id}/attachments`, basename(attach), readFileSync(attach));
267
+ }
268
+ catch (error) {
269
+ info(`✓ Filed ${report.id} — ${report.title}`);
270
+ throw new CliError(`The report was filed, but ${attach} did not attach: ` +
271
+ (error instanceof Error ? error.message : String(error)), 1, `Try again with: coe reports attach ${report.id} ${attach}`);
272
+ }
273
+ }
274
+ emit({ report, attachment }, () => info(`✓ Filed ${report.id} — ${report.title}` +
275
+ (attachment ? ` (attached ${attachment.name})` : "")));
186
276
  },
187
277
  });
188
278
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uic-coe-connect/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "The coe CLI — manage COEConnect apps (pipelines, deploys, access, env) from a terminal, with browser-approved auth. Designed to be driven by an AI agent.",
5
5
  "type": "module",
6
6
  "bin": {